Repository: tetratelabs/wazero
Pull request #1030 (2023-01-13 00:19:10)

cli: rewrites compositeFS to syscallfs and adds read-only (:ro) mounts
This rewrites compositeFS to syscallfs.FS following wasi-sdk preopen rules. Notably, this allows use of read-only mounts now.

For example,
```bash
$ GOOS=js GOARCH=wasm bin/go test -c -o template.wasm text/template
$ wazero run -mount=src/text/template:/:ro -mount=/tmp:/tmp template.wasm -test.v
=== RUN   TestExecute
--- PASS: TestExecute (0.07s)
--snip--
```

This is the first step to native WASI handling of multiple pre-opens. After this change, it is still the case that there's only one pre-open FD visible to wasm. A later change will make it possible for WASI to see multiple pre-opened FS while `GOOS=js` which doesn't use preopens, remains on a single root FS.

A future PR may need to add a CLI flag to disable escaping directories, (e.g. make ../.. EINVAL), similar to `fs.FS` in Go. The simplest way to allow this is to wrap an FS with a to-be-written filename filter.

codefromthecrypt: read-only mounts currently work. cleanup phase


```bash
$ GOOS=js GOARCH=wasm bin/go test -c -o template.wasm text/template
$ wazero run -mount=src/text/template:/ -mount=/tmp:/tmp template.wasm -test.v
=== RUN   TestExecute
--- PASS: TestExecute (0.07s)
=== RUN   TestDelims
--- PASS: TestDelims (0.00s)
=== RUN   TestExecuteError
--- PASS: TestExecuteError (0.00s)
=== RUN   TestExecError
--- PASS: TestExecError (0.00s)
=== RUN   TestExecError_CustomError
--- PASS: TestExecError_CustomError (0.00s)
=== RUN   TestJSEscaping
--- PASS: TestJSEscaping (0.00s)
=== RUN   TestTree
--- PASS: TestTree (0.00s)
=== RUN   TestExecuteOnNewTemplate
--- PASS: TestExecuteOnNewTemplate (0.00s)
=== RUN   TestMessageForExecuteEmpty
--- PASS: TestMessageForExecuteEmpty (0.00s)
=== RUN   TestFinalForPrintf
--- PASS: TestFinalForPrintf (0.00s)
=== RUN   TestComparison
--- PASS: TestComparison (0.02s)
=== RUN   TestMissingMapKey
    exec_test.go:1332: template: t1:1:2: executing "t1" at <.x>: nil data; no entry for key "x"
--- PASS: TestMissingMapKey (0.00s)
=== RUN   TestUnterminatedStringError
--- PASS: TestUnterminatedStringError (0.00s)
=== RUN   TestExecuteGivesExecError
--- PASS: TestExecuteGivesExecError (0.00s)
=== RUN   TestGoodFuncNames
--- PASS: TestGoodFuncNames (0.00s)
=== RUN   TestBadFuncNames
--- PASS: TestBadFuncNames (0.00s)
=== RUN   TestBlock
--- PASS: TestBlock (0.00s)
=== RUN   TestEvalFieldErrors
=== RUN   TestEvalFieldErrors/MissingFieldOnNil
=== RUN   TestEvalFieldErrors/MissingFieldOnNonNil
=== RUN   TestEvalFieldErrors/ExistingFieldOnNil
=== RUN   TestEvalFieldErrors/MissingKeyOnNilMap
=== RUN   TestEvalFieldErrors/MissingKeyOnNilMapPtr
=== RUN   TestEvalFieldErrors/MissingKeyOnMapPtrToNil
--- PASS: TestEvalFieldErrors (0.00s)
    --- PASS: TestEvalFieldErrors/MissingFieldOnNil (0.00s)
    --- PASS: TestEvalFieldErrors/MissingFieldOnNonNil (0.00s)
    --- PASS: TestEvalFieldErrors/ExistingFieldOnNil (0.00s)
    --- PASS: TestEvalFieldErrors/MissingKeyOnNilMap (0.00s)
    --- PASS: TestEvalFieldErrors/MissingKeyOnNilMapPtr (0.00s)
    --- PASS: TestEvalFieldErrors/MissingKeyOnMapPtrToNil (0.00s)
=== RUN   TestMaxExecDepth
--- PASS: TestMaxExecDepth (0.03s)
=== RUN   TestAddrOfIndex
--- PASS: TestAddrOfIndex (0.00s)
=== RUN   TestInterfaceValues
--- PASS: TestInterfaceValues (0.01s)
=== RUN   TestExecutePanicDuringCall
--- PASS: TestExecutePanicDuringCall (0.00s)
=== RUN   TestIssue31810
--- PASS: TestIssue31810 (0.00s)
=== RUN   TestIssue43065
--- PASS: TestIssue43065 (0.00s)
=== RUN   TestIssue39807
--- PASS: TestIssue39807 (0.00s)
=== RUN   TestIssue48215
--- PASS: TestIssue48215 (0.00s)
=== RUN   TestMultiParse
--- PASS: TestMultiParse (0.00s)
=== RUN   TestMultiExecute
--- PASS: TestMultiExecute (0.00s)
=== RUN   TestParseFiles
--- PASS: TestParseFiles (0.00s)
=== RUN   TestParseGlob
--- PASS: TestParseGlob (0.00s)
=== RUN   TestParseFS
--- PASS: TestParseFS (0.01s)
=== RUN   TestParseFilesWithData
--- PASS: TestParseFilesWithData (0.00s)
=== RUN   TestParseGlobWithData
--- PASS: TestParseGlobWithData (0.00s)
=== RUN   TestClone
--- PASS: TestClone (0.00s)
=== RUN   TestAddParseTree
--- PASS: TestAddParseTree (0.00s)
=== RUN   TestAddParseTreeToUnparsedTemplate
--- PASS: TestAddParseTreeToUnparsedTemplate (0.00s)
=== RUN   TestRedefinition
--- PASS: TestRedefinition (0.00s)
=== RUN   TestEmptyTemplateCloneCrash
--- PASS: TestEmptyTemplateCloneCrash (0.00s)
=== RUN   TestTemplateLookUp
--- PASS: TestTemplateLookUp (0.00s)
=== RUN   TestNew
--- PASS: TestNew (0.00s)
=== RUN   TestParse
--- PASS: TestParse (0.00s)
=== RUN   TestEmptyTemplate
--- PASS: TestEmptyTemplate (0.00s)
=== RUN   TestIssue19294
--- PASS: TestIssue19294 (0.02s)
=== RUN   TestAddToZeroTemplate
--- PASS: TestAddToZeroTemplate (0.00s)
=== RUN   TestLinkerGC
    testenv.go:63: skipping test: 'go build' not available on js/wasm
--- SKIP: TestLinkerGC (0.00s)
=== RUN   ExampleTemplate
--- PASS: ExampleTemplate (0.00s)
=== RUN   ExampleTemplate_block
--- PASS: ExampleTemplate_block (0.00s)
=== RUN   ExampleTemplate_glob
--- PASS: ExampleTemplate_glob (0.01s)
=== RUN   ExampleTemplate_helpers
--- PASS: ExampleTemplate_helpers (0.01s)
=== RUN   ExampleTemplate_share
--- PASS: ExampleTemplate_share (0.01s)
=== RUN   ExampleTemplate_func
--- PASS: ExampleTemplate_func (0.00s)
PASS
```

Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index abf65379..e22cf3c1 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -6,7 +6,6 @@ import (
 	"flag"
 	"fmt"
 	"io"
-	"io/fs"
 	"os"
 	"path/filepath"
 	"strings"
@@ -15,9 +14,9 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
 	"github.com/tetratelabs/wazero/experimental/logging"
-	"github.com/tetratelabs/wazero/experimental/writefs"
 	gojs "github.com/tetratelabs/wazero/imports/go"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	"github.com/tetratelabs/wazero/internal/version"
 	"github.com/tetratelabs/wazero/sys"
 )
@@ -121,8 +120,9 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 
 	var mounts sliceFlag
 	flags.Var(&mounts, "mount",
-		"filesystem path to expose to the binary in the form of <host path>[:<wasm path>]. If wasm path is not "+
-			"provided, the host path will be used. Can be specified multiple times.")
+		"filesystem path to expose to the binary in the form of <host path>[:<wasm path>][:ro]. "+
+			"This may be specified multiple times. When <wasm path> is unset, the host path is used. "+
+			"For read-only mounts, append the suffix ':ro'.")
 
 	hostLogging := hostLoggingFlag(flags)
 
@@ -161,36 +161,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 		env = append(env, fields[0], fields[1])
 	}
 
-	validatedMounts := validateMounts(mounts, stdErr, exit)
-
-	var rootFS fs.FS
-	switch len(validatedMounts) {
-	case 0: // nofs
-	case 1:
-		mount := validatedMounts[0]
-		host := mount[0]
-		guest := mount[1]
-		if guest == "" { // guest is root
-			var err error
-			rootFS, err = writefs.NewDirFS(host)
-			if err != nil {
-				fmt.Fprintf(stdErr, "invalid root mount %s: %v\n", host, err)
-				exit(1)
-			}
-		} else { // TODO: subfs
-			rootFS = &compositeFS{
-				paths: map[string]fs.FS{guest: os.DirFS(host)},
-			}
-		}
-	default:
-		cfs := &compositeFS{paths: map[string]fs.FS{}}
-		for _, mount := range validatedMounts {
-			host := mount[0]
-			guest := mount[1]
-			cfs.paths[guest] = os.DirFS(host)
-		}
-		rootFS = cfs
-	}
+	rootFS := validateMounts(mounts, stdErr, exit)
 
 	wasm, err := os.ReadFile(wasmPath)
 	if err != nil {
@@ -256,13 +227,20 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	exit(0)
 }
 
-func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) (validatedMounts [][2]string) {
+func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) syscallfs.FS {
+	fs := make([]syscallfs.FS, 0, len(mounts))
 	for _, mount := range mounts {
 		if len(mount) == 0 {
 			fmt.Fprintln(stdErr, "invalid mount: empty string")
 			exit(1)
 		}
 
+		readOnly := false
+		if trimmed := strings.TrimSuffix(mount, ":ro"); trimmed != mount {
+			mount = trimmed
+			readOnly = true
+		}
+
 		// TODO(anuraaga): Support wasm paths with colon in them.
 		var host, guest string
 		if clnIdx := strings.LastIndexByte(mount, ':'); clnIdx != -1 {
@@ -272,24 +250,35 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)
 			guest = host
 		}
 
-		if guest[0] == '.' {
-			fmt.Fprintf(stdErr, "invalid mount: guest path must not start with .: %s\n", guest)
-			exit(1)
+		// Provide a better experience if duplicates are found later.
+		if guest == "" {
+			guest = "/"
 		}
-
-		// wazero always calls fs.Open with a relative path.
-		if guest[0] == '/' {
-			guest = guest[1:]
+		if abs, err := filepath.Abs(host); err != nil {
+			fmt.Fprintf(stdErr, "invalid mount: host path %q invalid: %v\n", host, err)
+			exit(1)
+		} else {
+			host = abs
 		}
 
-		if abs, err := filepath.Abs(host); err != nil {
+		next, err := syscallfs.NewDirFS(guest, host)
+		if err != nil {
 			fmt.Fprintf(stdErr, "invalid mount: host path %q invalid: %v\n", host, err)
 			exit(1)
 		} else {
-			validatedMounts = append(validatedMounts, [2]string{abs, guest})
+			if readOnly {
+				next = syscallfs.NewReadFS(next)
+			}
+			fs = append(fs, next)
 		}
 	}
-	return
+	if fs, err := syscallfs.NewRootFS(fs...); err != nil {
+		fmt.Fprintf(stdErr, "invalid mounts: %v\n", err)
+		exit(1)
+		return nil
+	} else {
+		return fs
+	}
 }
 
 func detectImports(imports []api.FunctionDefinition) (needsWASI, needsGo bool) {
@@ -383,35 +372,3 @@ func (f *sliceFlag) Set(s string) error {
 	*f = append(*f, s)
 	return nil
 }
-
-// compositeFS is defined in wazero.go to allow debugging in GoLand.
-type compositeFS struct {
-	paths map[string]fs.FS
-}
-
-func (c *compositeFS) Open(name string) (fs.File, error) {
-	if !fs.ValidPath(name) {
-		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
-	}
-	for path, f := range c.paths {
-		if !strings.HasPrefix(name, path) {
-			continue
-		}
-		rest := name[len(path):]
-		if len(rest) == 0 {
-			// Special case reading directory
-			rest = "."
-		} else {
-			// fs.Open requires a relative path
-			if rest[0] == '/' {
-				rest = rest[1:]
-			}
-		}
-		file, err := f.Open(rest)
-		if err == nil {
-			return file, err
-		}
-	}
-
-	return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
-}
diff --git a/experimental/writefs/writefs.go b/experimental/writefs/writefs.go
index 6dbf34ca..5bbc34f1 100644
--- a/experimental/writefs/writefs.go
+++ b/experimental/writefs/writefs.go
@@ -34,7 +34,7 @@ import (
 //
 // Do not attempt to use the result as a fs.FS, as it will panic. This is a
 // bridge to a future filesystem abstraction made for wazero.
-func NewDirFS(dir string) (fs.FS, error) {
+func NewDirFS(hostDir string) (fs.FS, error) {
 	// syscallfs.DirFS is intentionally internal as it is still evolving
-	return syscallfs.NewDirFS(dir)
+	return syscallfs.NewDirFS("/", hostDir)
 }
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index f01feda8..2313efa9 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -1356,7 +1356,7 @@ func preopenPath(fsc *sys.FSContext, dirFD uint32) (string, Errno) {
 		return "", ErrnoInval
 	} else {
 		// TODO: multiple pre-opens
-		return fsc.FS().Path(), ErrnoSuccess
+		return fsc.FS().GuestDir(), ErrnoSuccess
 	}
 }
 
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index 2b19976e..5fb5fdf3 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -182,7 +182,7 @@ func NewContext(
 	}
 
 	if fs != nil {
-		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, syscallfs.Adapt(fs))
+		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, syscallfs.Adapt("/", fs))
 	} else {
 		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, syscallfs.EmptyFS)
 	}
diff --git a/internal/syscallfs/adapter.go b/internal/syscallfs/adapter.go
index a2039375..381275bf 100644
--- a/internal/syscallfs/adapter.go
+++ b/internal/syscallfs/adapter.go
@@ -15,33 +15,42 @@ import (
 // flags as there is no parameter to pass them through with. Moreover, fs.FS
 // documentation does not require the file to be present. In summary, we can't
 // enforce flag behavior.
-func Adapt(fs fs.FS) FS {
+func Adapt(guestDir string, fs fs.FS) FS {
 	if sys, ok := fs.(FS); ok {
 		return sys
 	}
-	return &adapter{fs}
+	return &adapter{guestDir, fs}
 }
 
 type adapter struct {
-	fs fs.FS
+	guestDir string
+	fs       fs.FS
 }
 
 // Open implements the same method as documented on fs.FS
-func (ro *adapter) Open(name string) (fs.File, error) {
+func (a *adapter) Open(name string) (fs.File, error) {
 	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
 }
 
-// Path implements FS.Path
-func (ro *adapter) Path() string {
-	return "/"
+// GuestDir implements FS.GuestDir
+func (a *adapter) GuestDir() string {
+	return a.guestDir
 }
 
 // OpenFile implements FS.OpenFile
-func (ro *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
+func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	path = cleanPath(path)
-	f, err := ro.fs.Open(path)
+	f, err := a.fs.Open(path)
 
 	if err != nil {
+		if pe, ok := err.(*fs.PathError); ok {
+			switch pe.Err {
+			case os.ErrInvalid:
+				pe.Err = syscall.EINVAL // adjust it
+			case os.ErrNotExist:
+				pe.Err = syscall.ENOENT // adjust it
+			}
+		}
 		return nil, err
 	} else if osF, ok := f.(*os.File); ok {
 		// If this is an OS file, it has same portability issues as dirFS.
@@ -64,26 +73,26 @@ func cleanPath(name string) string {
 }
 
 // Mkdir implements FS.Mkdir
-func (ro *adapter) Mkdir(path string, perm fs.FileMode) error {
+func (a *adapter) Mkdir(path string, perm fs.FileMode) error {
 	return syscall.ENOSYS
 }
 
 // Rename implements FS.Rename
-func (ro *adapter) Rename(from, to string) error {
+func (a *adapter) Rename(from, to string) error {
 	return syscall.ENOSYS
 }
 
 // Rmdir implements FS.Rmdir
-func (ro *adapter) Rmdir(path string) error {
+func (a *adapter) Rmdir(path string) error {
 	return syscall.ENOSYS
 }
 
 // Unlink implements FS.Unlink
-func (ro *adapter) Unlink(path string) error {
+func (a *adapter) Unlink(path string) error {
 	return syscall.ENOSYS
 }
 
 // Utimes implements FS.Utimes
-func (ro *adapter) Utimes(path string, atimeNsec, mtimeNsec int64) error {
+func (a *adapter) Utimes(path string, atimeNsec, mtimeNsec int64) error {
 	return syscall.ENOSYS
 }
diff --git a/internal/syscallfs/dirfs.go b/internal/syscallfs/dirfs.go
index 3c708036..66535142 100644
--- a/internal/syscallfs/dirfs.go
+++ b/internal/syscallfs/dirfs.go
@@ -7,18 +7,15 @@ import (
 	"syscall"
 )
 
-func NewDirFS(dir string) (FS, error) {
-	if dir == "" {
-		panic("empty dir")
-	}
+func NewDirFS(guestDir, hostDir string) (FS, error) {
 	// For easier OS-specific concatenation later, append the path separator.
-	dir = ensureTrailingPathSeparator(dir)
-	if stat, err := os.Stat(dir); err != nil {
+	hostDir = ensureTrailingPathSeparator(hostDir)
+	if stat, err := os.Stat(hostDir); err != nil {
 		return nil, syscall.ENOENT
 	} else if !stat.IsDir() {
 		return nil, syscall.ENOTDIR
 	}
-	return dirFS(dir), nil
+	return &dirFS{guestDir, hostDir}, nil
 }
 
 func ensureTrailingPathSeparator(dir string) string {
@@ -28,24 +25,23 @@ func ensureTrailingPathSeparator(dir string) string {
 	return dir
 }
 
-// dirFS currently validates each path, which means that input paths cannot
-// escape the directory, except via symlink. We may want to relax this in the
-// future, especially as we decoupled from fs.FS which has this requirement.
-type dirFS string
+type dirFS struct {
+	guestDir, hostDir string
+}
 
 // Open implements the same method as documented on fs.FS
-func (dir dirFS) Open(name string) (fs.File, error) {
+func (d *dirFS) Open(name string) (fs.File, error) {
 	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
 }
 
-// Path implements FS.Path
-func (dir dirFS) Path() string {
-	return "/"
+// GuestDir implements FS.GuestDir
+func (d *dirFS) GuestDir() string {
+	return d.guestDir
 }
 
 // OpenFile implements FS.OpenFile
-func (dir dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
-	f, err := os.OpenFile(dir.join(name), flag, perm)
+func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
+	f, err := os.OpenFile(d.join(name), flag, perm)
 	if err != nil {
 		return nil, err
 	}
@@ -53,44 +49,44 @@ 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 {
-	err := os.Mkdir(dir.join(name), perm)
+func (d *dirFS) Mkdir(name string, perm fs.FileMode) error {
+	err := os.Mkdir(d.join(name), perm)
 	return adjustMkdirError(err)
 }
 
 // Rename implements FS.Rename
-func (dir dirFS) Rename(from, to string) error {
+func (d *dirFS) Rename(from, to string) error {
 	if from == to {
 		return nil
 	}
-	return rename(dir.join(from), dir.join(to))
+	return rename(d.join(from), d.join(to))
 }
 
 // Rmdir implements FS.Rmdir
-func (dir dirFS) Rmdir(name string) error {
-	err := syscall.Rmdir(dir.join(name))
+func (d *dirFS) Rmdir(name string) error {
+	err := syscall.Rmdir(d.join(name))
 	return adjustRmdirError(err)
 }
 
 // Unlink implements FS.Unlink
-func (dir dirFS) Unlink(name string) error {
-	err := syscall.Unlink(dir.join(name))
+func (d *dirFS) Unlink(name string) error {
+	err := syscall.Unlink(d.join(name))
 	return adjustUnlinkError(err)
 }
 
 // Utimes implements FS.Utimes
-func (dir dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
-	return syscall.UtimesNano(dir.join(name), []syscall.Timespec{
+func (d *dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
+	return syscall.UtimesNano(d.join(name), []syscall.Timespec{
 		syscall.NsecToTimespec(atimeNsec),
 		syscall.NsecToTimespec(mtimeNsec),
 	})
 }
 
-func (dir dirFS) join(name string) string {
+func (d *dirFS) join(name string) string {
 	if name == "." {
-		return string(dir)
+		return d.hostDir
 	}
 	// TODO: Enforce similar to safefilepath.FromFS(name), but be careful as
 	// relative path inputs are allowed. e.g. dir or name == ../
-	return string(dir) + name
+	return d.hostDir + name
 }
diff --git a/internal/syscallfs/empty.go b/internal/syscallfs/empty.go
index 25797946..c8e69f90 100644
--- a/internal/syscallfs/empty.go
+++ b/internal/syscallfs/empty.go
@@ -17,8 +17,8 @@ func (empty) Open(name string) (fs.File, error) {
 	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
 }
 
-// Path implements FS.Path
-func (empty) Path() string {
+// GuestDir implements FS.GuestDir
+func (empty) GuestDir() string {
 	return "/"
 }
 
diff --git a/internal/syscallfs/readfs.go b/internal/syscallfs/readfs.go
index 99d35e53..954cb100 100644
--- a/internal/syscallfs/readfs.go
+++ b/internal/syscallfs/readfs.go
@@ -26,8 +26,8 @@ func (r *readFS) Open(name string) (fs.File, error) {
 	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
 }
 
-// Path implements FS.Path
-func (r *readFS) Path() string {
+// GuestDir implements FS.GuestDir
+func (r *readFS) GuestDir() string {
 	return "/"
 }
 
diff --git a/internal/syscallfs/rootfs.go b/internal/syscallfs/rootfs.go
new file mode 100644
index 00000000..08b5c8e8
--- /dev/null
+++ b/internal/syscallfs/rootfs.go
@@ -0,0 +1,372 @@
+package syscallfs
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"strings"
+	"syscall"
+	"time"
+)
+
+func NewRootFS(fs ...FS) (FS, error) {
+	switch len(fs) {
+	case 0:
+		return EmptyFS, nil
+	case 1:
+		if fs[0].GuestDir() == "/" {
+			return fs[0], nil
+		}
+	}
+
+	// Last is the highest precedence, so we iterate backwards to keep runtime
+	// code simpler.
+	ret := &CompositeFS{
+		prefixes:     make([]string, len(fs)),
+		rootPrefixes: map[string]int{},
+		fs:           make([]FS, len(fs)),
+		rootIndex:    -1,
+	}
+	j := 0
+	for i := len(fs) - 1; i >= 0; i-- {
+		// Clean the prefix in the same way path matches will.
+		path := fs[i].GuestDir()
+		pathI, pathLen := stripPrefixesAndTrailingSlash(path)
+		prefix := path[pathI:pathLen]
+		if prefix == "" {
+			if ret.rootIndex != -1 {
+				return nil, errors.New("multiple root filesystems are invalid")
+			}
+			ret.rootIndex = j
+		} else if strings.HasPrefix(prefix, "..") {
+			// ../ mounts are special cased and aren't returned in a directory
+			// listing, so we can ignore them for now.
+		} else if strings.Contains(prefix, "/") {
+			return nil, fmt.Errorf("unsupported GuestDir %s, only root level allowed", path)
+		} else {
+			ret.rootPrefixes[prefix] = j
+		}
+		ret.prefixes[j] = prefix
+		ret.fs[j] = fs[i]
+		j++
+	}
+
+	// Ensure there is always a root match to keep runtime logic simpler.
+	if ret.rootIndex == -1 {
+		return nil, errors.New("you must supply a root filesystem")
+	}
+	return ret, nil
+}
+
+type CompositeFS struct {
+	// prefixes to match in precedence order
+	prefixes []string
+	// rootPrefixes are prefixes that exist directly under root, such as "tmp".
+	rootPrefixes map[string]int
+	// fs is index-correlated with prefixes
+	fs []FS
+	// rootIndex is the index in fs that is the root filesystem
+	rootIndex int
+}
+
+// Unwrap returns the underlying filesystems in original order.
+func (c *CompositeFS) Unwrap() []FS {
+	result := make([]FS, 0, len(c.fs))
+	for i := len(c.fs) - 1; i >= 0; i-- {
+		if fs := c.fs[i]; fs != EmptyFS {
+			result = append(result, fs)
+		}
+	}
+	return result
+}
+
+// Open implements the same method as documented on fs.FS
+func (c *CompositeFS) Open(name string) (fs.File, error) {
+	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
+}
+
+// GuestDir implements FS.GuestDir
+func (c *CompositeFS) GuestDir() string {
+	return "/"
+}
+
+// OpenFile implements FS.OpenFile
+func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f fs.File, err error) {
+	matchIndex, relativePath := c.chooseFS(path)
+
+	f, err = c.fs[matchIndex].OpenFile(relativePath, flag, perm)
+	if err != nil {
+		return
+	}
+
+	// Ensure the root directory listing includes any prefix mounts.
+	if matchIndex == c.rootIndex {
+		switch path {
+		case ".", "/", "":
+			if len(c.rootPrefixes) > 0 {
+				f = &openRootDir{c: c, f: f.(fs.ReadDirFile)}
+			}
+		}
+	}
+	return
+}
+
+// An openRootDir is a root directory open for reading, which has mounts inside
+// of it.
+type openRootDir struct {
+	c        *CompositeFS
+	f        fs.ReadDirFile // the directory file itself
+	dirents  []fs.DirEntry  // the directory contents
+	direntsI int            // the read offset, an index into the files slice
+}
+
+func (d *openRootDir) Close() error { return d.f.Close() }
+
+func (d *openRootDir) Stat() (fs.FileInfo, error) { return d.f.Stat() }
+
+func (d *openRootDir) Read([]byte) (int, error) {
+	return 0, &fs.PathError{Op: "read", Path: "/", Err: syscall.EISDIR}
+}
+
+// readDir reads the directory fully into d.dirents, replacing any entries that
+// correspond to prefix matches or appending them to the end.
+func (d *openRootDir) readDir() (err error) {
+	if d.dirents, err = d.f.ReadDir(-1); err != nil {
+		return
+	}
+
+	remaining := make(map[string]int, len(d.c.rootPrefixes))
+	for k, v := range d.c.rootPrefixes {
+		remaining[k] = v
+	}
+
+	for i, e := range d.dirents {
+		if fsI, ok := remaining[e.Name()]; ok {
+			if d.dirents[i], err = d.rootEntry(e.Name(), fsI); err != nil {
+				return
+			}
+			delete(remaining, e.Name())
+		}
+	}
+
+	var di fs.DirEntry
+	for n, fsI := range remaining {
+		if di, err = d.rootEntry(n, fsI); err != nil {
+			return
+		}
+		d.dirents = append(d.dirents, di)
+	}
+	return
+}
+
+func (d *openRootDir) rootEntry(name string, fsI int) (fs.DirEntry, error) {
+	if fi, err := StatPath(d.c.fs[fsI], "."); err != nil {
+		return nil, err
+	} else {
+		return fs.FileInfoToDirEntry(&renamedFileInfo{name, fi}), nil
+	}
+}
+
+// renamedFileInfo is needed to retain the stat info for a mount, knowing the
+// directory is masked. For example, we don't want to leak the underlying host
+// directory name.
+type renamedFileInfo struct {
+	name string
+	f    fs.FileInfo
+}
+
+func (i *renamedFileInfo) Name() string       { return i.name }
+func (i *renamedFileInfo) Size() int64        { return i.f.Size() }
+func (i *renamedFileInfo) Mode() fs.FileMode  { return i.f.Mode() }
+func (i *renamedFileInfo) ModTime() time.Time { return i.f.ModTime() }
+func (i *renamedFileInfo) IsDir() bool        { return i.f.IsDir() }
+func (i *renamedFileInfo) Sys() interface{}   { return i.f.Sys() }
+
+func (d *openRootDir) ReadDir(count int) ([]fs.DirEntry, error) {
+	if d.dirents == nil {
+		if err := d.readDir(); err != nil {
+			return nil, err
+		}
+	}
+
+	// logic similar to go:embed
+	n := len(d.dirents) - d.direntsI
+	if n == 0 {
+		if count <= 0 {
+			return nil, nil
+		}
+		return nil, io.EOF
+	}
+	if count > 0 && n > count {
+		n = count
+	}
+	list := make([]fs.DirEntry, n)
+	for i := range list {
+		list[i] = d.dirents[d.direntsI+i]
+	}
+	d.direntsI += n
+	return list, nil
+}
+
+// Mkdir implements FS.Mkdir
+func (c *CompositeFS) Mkdir(path string, perm fs.FileMode) error {
+	matchIndex, relativePath := c.chooseFS(path)
+	return c.fs[matchIndex].Mkdir(relativePath, perm)
+}
+
+// Rename implements FS.Rename
+func (c *CompositeFS) Rename(from, to string) error {
+	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)
+}
+
+// Rmdir implements FS.Rmdir
+func (c *CompositeFS) Rmdir(path string) error {
+	matchIndex, relativePath := c.chooseFS(path)
+	return c.fs[matchIndex].Rmdir(relativePath)
+}
+
+// Unlink implements FS.Unlink
+func (c *CompositeFS) Unlink(path string) error {
+	matchIndex, relativePath := c.chooseFS(path)
+	return c.fs[matchIndex].Unlink(relativePath)
+}
+
+// Utimes implements FS.Utimes
+func (c *CompositeFS) Utimes(path string, atimeNsec, mtimeNsec int64) error {
+	matchIndex, relativePath := c.chooseFS(path)
+	return c.fs[matchIndex].Utimes(relativePath, atimeNsec, mtimeNsec)
+}
+
+// chooseFS chooses the best fs and the relative path to use for the input.
+func (c *CompositeFS) chooseFS(path string) (matchIndex int, relativePath string) {
+	// c.prefixes are already in precedence order. The first longest match wins
+	// so that pre-open "tmp" wins vs "" regardless of order.
+	matchIndex = -1
+	matchPrefixLen := 0
+	pathI, pathLen := stripPrefixesAndTrailingSlash(path)
+	for i, prefix := range c.prefixes {
+		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 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
+}
+
+// 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
+}
diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go
index f1583dcd..13e40a66 100644
--- a/internal/syscallfs/syscallfs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -13,8 +13,8 @@ import (
 //
 // See https://github.com/golang/go/issues/45757
 type FS interface {
-	// Path is the name of the path the guest should use this filesystem for,
-	// or root ("/") if unknown.
+	// GuestDir is the name of the path the guest should use this filesystem
+	// for, or root ("/") for any files.
 	//
 	// This value allows the guest to avoid making file-system calls when they
 	// won't succeed. e.g. if "/tmp" is returned and the guest requests
@@ -22,6 +22,7 @@ type FS interface {
 	// pre-opens.
 	//
 	// # Notes
+	//   - Implementations must always return the same value.
 	//   - 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.
@@ -30,7 +31,7 @@ type FS interface {
 	//     to resolve a path "../.." in unit tests.
 	//   - Zig uses the first path name it sees as the initial working
 	//     directory of the process.
-	Path() string
+	GuestDir() string
 
 	// Open is only defined to match the signature of fs.FS until we remove it.
 	// Once we are done bridging, we will remove this function. Meanwhile,


Test patch:
diff --git a/cmd/wazero/compositefs_test.go b/cmd/wazero/compositefs_test.go
deleted file mode 100644
index e0ec0fac..00000000
--- a/cmd/wazero/compositefs_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"embed"
-	"fmt"
-	"io/fs"
-	"testing"
-	"testing/fstest"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-//go:embed testdata/fs
-var testFS embed.FS
-
-func TestCompositeFS(t *testing.T) {
-	tests := []struct {
-		name    string
-		fs      *compositeFS
-		path    string
-		content string
-	}{
-		{
-			name: "empty",
-			fs:   &compositeFS{},
-			path: "bear.txt",
-		},
-		{
-			name: "single mount to root",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"": testFSSub(""),
-				},
-			},
-			path:    "bear.txt",
-			content: "pooh",
-		},
-		{
-			name: "single mount to root",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"": testFSSub(""),
-				},
-			},
-			path:    "fish/clownfish.txt",
-			content: "nemo",
-		},
-		{
-			name: "single mount to root",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"": testFSSub(""),
-				},
-			},
-			path:    "mammals/primates/ape.txt",
-			content: "king kong",
-		},
-		{
-			name: "single mount to path",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"mammals": testFSSub(""),
-				},
-			},
-			path:    "mammals/bear.txt",
-			content: "pooh",
-		},
-		{
-			name: "single mount to path",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"mammals": testFSSub(""),
-				},
-			},
-			path: "mammals/whale.txt",
-		},
-		{
-			name: "single mount to path",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"mammals": testFSSub(""),
-				},
-			},
-			path: "bear.txt",
-		},
-		{
-			name: "non-overlapping mounts",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"fish":    testFSSub("fish"),
-					"mammals": testFSSub("mammals"),
-				},
-			},
-			path:    "fish/clownfish.txt",
-			content: "nemo",
-		},
-		{
-			name: "non-overlapping mounts",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"fish":    testFSSub("fish"),
-					"mammals": testFSSub("mammals"),
-				},
-			},
-			path:    "mammals/whale.txt",
-			content: "moby dick",
-		},
-		{
-			name: "non-overlapping mounts",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"fish":    testFSSub("fish"),
-					"mammals": testFSSub("mammals"),
-				},
-			},
-			path:    "mammals/primates/ape.txt",
-			content: "king kong",
-		},
-		{
-			name: "non-overlapping mounts",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"fish":    testFSSub("fish"),
-					"mammals": testFSSub("mammals"),
-				},
-			},
-			path: "bear.txt",
-		},
-		{
-			name: "overlapping mounts, deep first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals/fish": testFSSub("fish"),
-					"animals":      testFSSub("mammals"),
-				},
-			},
-			path:    "animals/fish/clownfish.txt",
-			content: "nemo",
-		},
-		{
-			name: "overlapping mounts, deep first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals/fish": testFSSub("fish"),
-					"animals":      testFSSub("mammals"),
-				},
-			},
-			path:    "animals/whale.txt",
-			content: "moby dick",
-		},
-		{
-			name: "overlapping mounts, deep first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals/fish": testFSSub("fish"),
-					"animals":      testFSSub("mammals"),
-				},
-			},
-			path: "animals/bear.txt",
-		},
-		{
-			name: "overlapping mounts, shallow first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals":      testFSSub("mammals"),
-					"animals/fish": testFSSub("fish"),
-				},
-			},
-			path:    "animals/fish/clownfish.txt",
-			content: "nemo",
-		},
-		{
-			name: "overlapping mounts, shallow first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals":      testFSSub("mammals"),
-					"animals/fish": testFSSub("fish"),
-				},
-			},
-			path:    "animals/whale.txt",
-			content: "moby dick",
-		},
-		{
-			name: "overlapping mounts, shallow first",
-			fs: &compositeFS{
-				paths: map[string]fs.FS{
-					"animals":      testFSSub("mammals"),
-					"animals/fish": testFSSub("fish"),
-				},
-			},
-			path: "animals/bear.txt",
-		},
-	}
-
-	for _, tc := range tests {
-		tt := tc
-		t.Run(fmt.Sprintf("%s - %s", tt.name, tt.path), func(t *testing.T) {
-			content, err := fs.ReadFile(tt.fs, tt.path)
-			if tt.content != "" {
-				require.NoError(t, err)
-				require.Equal(t, tt.content, string(bytes.TrimSpace(content)))
-			} else {
-				require.ErrorIs(t, err, fs.ErrNotExist)
-			}
-		})
-	}
-}
-
-func TestFSTest(t *testing.T) {
-	fs := &compositeFS{
-		paths: map[string]fs.FS{
-			// TestFS requires non-rooted paths to be read from current directory so we mount
-			// both with . and no prefix.
-			".": testFSSub(""),
-			"":  testFSSub(""),
-		},
-	}
-
-	require.NoError(t, fstest.TestFS(fs, "bear.txt"))
-}
-
-func testFSSub(path string) fs.FS {
-	// Can't use filepath.Join because we need unix behavior even on Windows.
-	p := "testdata/fs"
-	if len(path) > 0 {
-		p = fmt.Sprintf("%s/%s", p, path)
-	}
-	f, err := fs.Sub(testFS, p)
-	if err != nil {
-		panic(err)
-	}
-	return f
-}
diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go
index db96a847..ee93237c 100644
--- a/cmd/wazero/wazero_test.go
+++ b/cmd/wazero/wazero_test.go
@@ -221,13 +221,19 @@ func TestRun(t *testing.T) {
 			stdOut:     "ANIMAL=bear\x00FOOD=sushi\x00",
 		},
 		{
-			name:       "fd",
+			name:       "wasi",
 			wasm:       wasmWasiFd,
 			wazeroOpts: []string{fmt.Sprintf("--mount=%s:/", filepath.Dir(bearPath))},
 			stdOut:     "pooh\n",
 		},
 		{
-			name:       "wasi filesystem",
+			name:       "wasi readonly",
+			wasm:       wasmWasiFd,
+			wazeroOpts: []string{fmt.Sprintf("--mount=%s:/:ro", filepath.Dir(bearPath))},
+			stdOut:     "pooh\n",
+		},
+		{
+			name:       "wasi logging",
 			wasm:       wasmWasiFd,
 			wazeroOpts: []string{"--hostlogging=filesystem", fmt.Sprintf("--mount=%s:/", filepath.Dir(bearPath))},
 			stdOut:     "pooh\n",
@@ -245,7 +251,14 @@ func TestRun(t *testing.T) {
 			stdOut:     "pooh\n",
 		},
 		{
-			name:       "GOARCH=wasm GOOS=js filesystem",
+			name:       "GOARCH=wasm GOOS=js readonly",
+			wasm:       wasmCat,
+			wazeroOpts: []string{fmt.Sprintf("--mount=%s:/:ro", filepath.Dir(bearPath))},
+			wasmArgs:   []string{"/bear.txt"},
+			stdOut:     "pooh\n",
+		},
+		{
+			name:       "GOARCH=wasm GOOS=js logging",
 			wasm:       wasmCat,
 			wazeroOpts: []string{"--hostlogging=filesystem", fmt.Sprintf("--mount=%s:/", filepath.Dir(bearPath))},
 			wasmArgs:   []string{"/bear.txt"},
@@ -379,8 +392,8 @@ func TestRun_Errors(t *testing.T) {
 			args:    []string{"--env=ANIMAL", "testdata/wasi_env.wasm"},
 		},
 		{
-			message: "invalid mount",
-			args:    []string{"--mount=.", "testdata/wasi_env.wasm"},
+			message: "invalid mount", // not found
+			args:    []string{"--mount=te", "testdata/wasi_env.wasm"},
 		},
 		{
 			message: "invalid cachedir",
diff --git a/experimental/writefs/writefs_example_test.go b/experimental/writefs/writefs_example_test.go
index 8652c49d..6a31352a 100644
--- a/experimental/writefs/writefs_example_test.go
+++ b/experimental/writefs/writefs_example_test.go
@@ -10,7 +10,7 @@ import (
 
 var config wazero.ModuleConfig //nolint
 
-// This shows how to use writefs.DirFS to map paths relative to "/work/appA",
+// This shows how to use writefs.NewDirFS to map paths relative to "/work/appA",
 // as "/". Unlike os.DirFS, these paths will be writable.
 func Example_dirFS() {
 	fs, err := writefs.NewDirFS("/work/appA")
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index d22df9f0..bfc46387 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -710,7 +710,7 @@ func Test_fdPread_Errors(t *testing.T) {
 }
 
 func Test_fdPrestatGet(t *testing.T) {
-	testfs, err := syscallfs.NewDirFS(t.TempDir())
+	testfs, err := syscallfs.NewDirFS("/", t.TempDir())
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testfs))
@@ -797,7 +797,7 @@ func Test_fdPrestatGet_Errors(t *testing.T) {
 }
 
 func Test_fdPrestatDirName(t *testing.T) {
-	testfs, err := syscallfs.NewDirFS(t.TempDir())
+	testfs, err := syscallfs.NewDirFS("/", t.TempDir())
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testfs))
@@ -1873,7 +1873,7 @@ func Test_fdWrite_Errors(t *testing.T) {
 
 func Test_pathCreateDirectory(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -1904,7 +1904,7 @@ func Test_pathCreateDirectory(t *testing.T) {
 
 func Test_pathCreateDirectory_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -2192,7 +2192,7 @@ func Test_pathLink(t *testing.T) {
 
 func Test_pathOpen(t *testing.T) {
 	dir := t.TempDir() // open before loop to ensure no locking problems.
-	writeFS, err := syscallfs.NewDirFS(dir)
+	writeFS, err := syscallfs.NewDirFS("/", dir)
 	require.NoError(t, err)
 	readFS := syscallfs.NewReadFS(writeFS)
 
@@ -2483,7 +2483,7 @@ func writeFile(t *testing.T, tmpDir, file string, contents []byte) {
 
 func Test_pathOpen_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -2627,7 +2627,7 @@ func Test_pathReadlink(t *testing.T) {
 
 func Test_pathRemoveDirectory(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -2660,7 +2660,7 @@ func Test_pathRemoveDirectory(t *testing.T) {
 
 func Test_pathRemoveDirectory_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -2798,7 +2798,7 @@ func Test_pathSymlink(t *testing.T) {
 
 func Test_pathRename(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -2842,7 +2842,7 @@ func Test_pathRename(t *testing.T) {
 
 func Test_pathRename_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -3018,7 +3018,7 @@ func Test_pathRename_Errors(t *testing.T) {
 
 func Test_pathUnlinkFile(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -3051,7 +3051,7 @@ func Test_pathUnlinkFile(t *testing.T) {
 
 func Test_pathUnlinkFile_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	fs, err := syscallfs.NewDirFS(tmpDir)
+	fs, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
@@ -3162,7 +3162,7 @@ func requireOpenFile(t *testing.T, tmpDir string, pathName string, data []byte,
 		require.NoError(t, os.WriteFile(realPath, data, 0o600))
 	}
 
-	writeFS, err := syscallfs.NewDirFS(tmpDir)
+	writeFS, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	testFS := writeFS
diff --git a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
index fd601039..cad12cca 100644
--- a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
@@ -51,7 +51,7 @@ func Test_fdReaddir_ls(t *testing.T) {
 func testFdReaddirLs(t *testing.T, bin []byte) {
 	// TODO: make a subfs
 	moduleConfig := wazero.NewModuleConfig().
-		WithFS(syscallfs.Adapt(fstest.MapFS{
+		WithFS(syscallfs.Adapt("/", fstest.MapFS{
 			"-":   {},
 			"a-":  {Mode: fs.ModeDir},
 			"ab-": {},
@@ -87,7 +87,7 @@ ENOTDIR
 		for i := 0; i < count; i++ {
 			testFS[strconv.Itoa(i)] = &fstest.MapFile{}
 		}
-		config := wazero.NewModuleConfig().WithFS(syscallfs.Adapt(testFS)).WithArgs("wasi", "ls", ".")
+		config := wazero.NewModuleConfig().WithFS(syscallfs.Adapt("/", testFS)).WithArgs("wasi", "ls", ".")
 		console := compileAndRun(t, config, bin)
 
 		lines := strings.Split(console, "\n")
@@ -112,7 +112,7 @@ func Test_fdReaddir_stat(t *testing.T) {
 func testFdReaddirStat(t *testing.T, bin []byte) {
 	moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "stat")
 
-	console := compileAndRun(t, moduleConfig.WithFS(syscallfs.Adapt(fstest.MapFS{})), bin)
+	console := compileAndRun(t, moduleConfig.WithFS(syscallfs.Adapt("/", fstest.MapFS{})), bin)
 
 	// TODO: switch this to a real stat test
 	require.Equal(t, `
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 5c90f4e4..6b96dd9f 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -45,7 +45,7 @@ func Test_testfs(t *testing.T) {
 	require.NoError(t, os.Mkdir(testfsDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(testfsDir))
 
-	rootFS, err := syscallfs.NewDirFS(tmpDir)
+	rootFS, err := syscallfs.NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	stdout, stderr, err := compileAndRun(testCtx, "testfs", wazero.NewModuleConfig().WithFS(rootFS))
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 1018b92b..7ac0de1a 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -31,7 +31,7 @@ func TestNewFSContext(t *testing.T) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
-	dirfs, err := syscallfs.NewDirFS(".")
+	dirfs, err := syscallfs.NewDirFS("/", ".")
 	require.NoError(t, err)
 
 	// Test various usual configuration for the file system.
@@ -41,7 +41,7 @@ func TestNewFSContext(t *testing.T) {
 	}{
 		{
 			name: "embed.FS",
-			fs:   syscallfs.Adapt(embedFS),
+			fs:   syscallfs.Adapt("/", embedFS),
 		},
 		{
 			name: "syscallfs.NewDirFS",
@@ -55,7 +55,7 @@ func TestNewFSContext(t *testing.T) {
 		},
 		{
 			name: "fstest.MapFS",
-			fs:   syscallfs.Adapt(fstest.MapFS{}),
+			fs:   syscallfs.Adapt("/", fstest.MapFS{}),
 		},
 	}
 
@@ -68,7 +68,7 @@ func TestNewFSContext(t *testing.T) {
 			defer fsc.Close(testCtx)
 
 			preopenedDir, _ := fsc.openedFiles.Lookup(FdPreopen)
-			preopenedPath := fsc.fs.Path()
+			preopenedPath := fsc.fs.GuestDir()
 			require.Equal(t, tc.fs, fsc.fs)
 			require.NotNil(t, preopenedDir)
 			require.Equal(t, "", preopenedDir.Name)
@@ -114,7 +114,7 @@ func TestEmptyFSContext(t *testing.T) {
 }
 
 func TestContext_Close(t *testing.T) {
-	testFS := syscallfs.Adapt(testfs.FS{"foo": &testfs.File{}})
+	testFS := syscallfs.Adapt("/", testfs.FS{"foo": &testfs.File{}})
 
 	fsc, err := NewFSContext(nil, nil, nil, testFS)
 	require.NoError(t, err)
@@ -139,7 +139,7 @@ func TestContext_Close(t *testing.T) {
 func TestContext_Close_Error(t *testing.T) {
 	file := &testfs.File{CloseErr: errors.New("error closing")}
 
-	testFS := syscallfs.Adapt(testfs.FS{"foo": file})
+	testFS := syscallfs.Adapt("/", testfs.FS{"foo": file})
 
 	fsc, err := NewFSContext(nil, nil, nil, testFS)
 	require.NoError(t, err)
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index e13ea0e6..493d8f10 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -51,7 +51,7 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, &ns, sysCtx.nanosleep)
 	require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource())
 
-	testFS := syscallfs.Adapt(testfs.FS{})
+	testFS := syscallfs.Adapt("/", testfs.FS{})
 	expectedFS, _ := NewFSContext(nil, nil, nil, testFS)
 
 	expectedOpenedFiles := FileTable{}
diff --git a/internal/syscallfs/adapter_test.go b/internal/syscallfs/adapter_test.go
index ebd6c323..1d489a4f 100644
--- a/internal/syscallfs/adapter_test.go
+++ b/internal/syscallfs/adapter_test.go
@@ -14,7 +14,7 @@ import (
 )
 
 func TestAdapt_MkDir(t *testing.T) {
-	testFS := Adapt(os.DirFS(t.TempDir()))
+	testFS := Adapt("/", os.DirFS(t.TempDir()))
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.Equal(t, syscall.ENOSYS, err)
@@ -22,7 +22,7 @@ func TestAdapt_MkDir(t *testing.T) {
 
 func TestAdapt_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := Adapt("/", os.DirFS(tmpDir))
 
 	file1 := "file1"
 	file1Path := pathutil.Join(tmpDir, file1)
@@ -42,7 +42,7 @@ func TestAdapt_Rename(t *testing.T) {
 
 func TestAdapt_Rmdir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := Adapt("/", os.DirFS(tmpDir))
 
 	path := "rmdir"
 	realPath := pathutil.Join(tmpDir, path)
@@ -54,7 +54,7 @@ func TestAdapt_Rmdir(t *testing.T) {
 
 func TestAdapt_Unlink(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := Adapt("/", os.DirFS(tmpDir))
 
 	path := "unlink"
 	realPath := pathutil.Join(tmpDir, path)
@@ -66,7 +66,7 @@ func TestAdapt_Unlink(t *testing.T) {
 
 func TestAdapt_Utimes(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := Adapt("/", os.DirFS(tmpDir))
 
 	path := "utimes"
 	realPath := pathutil.Join(tmpDir, path)
@@ -81,7 +81,7 @@ func TestAdapt_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := Adapt("/", os.DirFS(tmpDir))
 
 	testOpen_Read(t, tmpDir, testFS)
 
@@ -89,7 +89,7 @@ func TestAdapt_Open_Read(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))
+		requireErrno(t, syscall.EINVAL, err)
 	})
 }
 
@@ -115,7 +115,7 @@ func (dir hackFS) Open(name string) (fs.File, error) {
 // fs.FS contract.
 func TestAdapt_HackedWrites(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(hackFS(tmpDir))
+	testFS := Adapt("/", hackFS(tmpDir))
 
 	testOpen_O_RDWR(t, tmpDir, testFS)
 }
@@ -133,7 +133,7 @@ func TestAdapt_TestFS(t *testing.T) {
 	}
 
 	// Adapt a normal fs.FS to syscallfs.FS
-	testFS := Adapt(fsFS)
+	testFS := Adapt("/", fsFS)
 
 	// Adapt it back to fs.FS and run the tests
 	require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
diff --git a/internal/syscallfs/dirfs_test.go b/internal/syscallfs/dirfs_test.go
index f4e1a2fd..ebe49da1 100644
--- a/internal/syscallfs/dirfs_test.go
+++ b/internal/syscallfs/dirfs_test.go
@@ -15,7 +15,7 @@ import (
 
 func TestDirFS_MkDir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	name := "mkdir"
@@ -46,7 +46,7 @@ func TestDirFS_MkDir(t *testing.T) {
 func TestDirFS_Rename(t *testing.T) {
 	t.Run("from doesn't exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		file1 := "file1"
@@ -59,7 +59,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		file1 := "file1"
@@ -83,7 +83,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
@@ -105,7 +105,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
@@ -133,7 +133,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		file1 := "file1"
@@ -151,7 +151,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
@@ -188,7 +188,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		file1 := "file1"
@@ -217,7 +217,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
@@ -233,7 +233,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS, err := NewDirFS(tmpDir)
+		testFS, err := NewDirFS("/", tmpDir)
 		require.NoError(t, err)
 
 		file1 := "file1"
@@ -254,7 +254,7 @@ func TestDirFS_Rename(t *testing.T) {
 func TestDirFS_Rmdir(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	name := "rmdir"
@@ -295,7 +295,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 func TestDirFS_Unlink(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	name := "unlink"
@@ -327,7 +327,7 @@ func TestDirFS_Unlink(t *testing.T) {
 func TestDirFS_Utimes(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	testUtimes(t, tmpDir, testFS)
@@ -340,7 +340,7 @@ func TestDirFS_Open(t *testing.T) {
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	testOpen_Read(t, tmpDir, testFS)
@@ -363,7 +363,7 @@ func TestDirFS_TestFS(t *testing.T) {
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	// Create a writeable filesystem
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	// Run TestFS via the adapter
diff --git a/internal/syscallfs/readfs_test.go b/internal/syscallfs/readfs_test.go
index f36af30d..93f78fa3 100644
--- a/internal/syscallfs/readfs_test.go
+++ b/internal/syscallfs/readfs_test.go
@@ -13,7 +13,7 @@ import (
 
 func TestReadFS_MkDir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.Equal(t, syscall.ENOSYS, err)
@@ -21,7 +21,7 @@ func TestReadFS_MkDir(t *testing.T) {
 
 func TestReadFS_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	file1 := "file1"
 	file1Path := pathutil.Join(tmpDir, file1)
@@ -41,7 +41,7 @@ func TestReadFS_Rename(t *testing.T) {
 
 func TestReadFS_Rmdir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	path := "rmdir"
 	realPath := pathutil.Join(tmpDir, path)
@@ -53,7 +53,7 @@ func TestReadFS_Rmdir(t *testing.T) {
 
 func TestReadFS_Unlink(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	path := "unlink"
 	realPath := pathutil.Join(tmpDir, path)
@@ -65,7 +65,7 @@ func TestReadFS_Unlink(t *testing.T) {
 
 func TestReadFS_Utimes(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	path := "utimes"
 	realPath := pathutil.Join(tmpDir, path)
@@ -77,7 +77,7 @@ func TestReadFS_Utimes(t *testing.T) {
 
 func TestReadFS_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
+	testFS := NewReadFS(Adapt("/", hackFS(tmpDir)))
 
 	testOpen_Read(t, tmpDir, testFS)
 }
@@ -90,7 +90,7 @@ func TestReadFS_TestFS(t *testing.T) {
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	// Create a writeable filesystem
-	testFS, err := NewDirFS(tmpDir)
+	testFS, err := NewDirFS("/", tmpDir)
 	require.NoError(t, err)
 
 	// Wrap it as read-only
diff --git a/internal/syscallfs/rootfs_test.go b/internal/syscallfs/rootfs_test.go
new file mode 100644
index 00000000..12a02b95
--- /dev/null
+++ b/internal/syscallfs/rootfs_test.go
@@ -0,0 +1,433 @@
+package syscallfs
+
+import (
+	"errors"
+	"io"
+	"io/fs"
+	"os"
+	pathutil "path"
+	"sort"
+	"strings"
+	"syscall"
+	"testing"
+	gofstest "testing/fstest"
+
+	"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()
+		require.NoError(t, err)
+
+		require.Equal(t, EmptyFS, rootFS)
+	})
+	t.Run("only root", func(t *testing.T) {
+		testFS, err := NewDirFS("/", t.TempDir())
+		require.NoError(t, err)
+
+		rootFS, err := NewRootFS(testFS)
+		require.NoError(t, err)
+
+		// Should not be a composite filesystem
+		require.Equal(t, testFS, rootFS)
+	})
+	t.Run("only non root unsupported", func(t *testing.T) {
+		testFS, err := NewDirFS("/tmp", t.TempDir())
+		require.NoError(t, err)
+
+		_, err = NewRootFS(testFS, testFS)
+		require.EqualError(t, err, "you must supply a root filesystem")
+	})
+	t.Run("multiple roots unsupported", func(t *testing.T) {
+		testFS, err := NewDirFS("/", t.TempDir())
+		require.NoError(t, err)
+
+		_, err = NewRootFS(testFS, testFS)
+		require.EqualError(t, err, "multiple root filesystems are invalid")
+	})
+	t.Run("virtual paths unsupported", func(t *testing.T) {
+		testFS, err := NewDirFS("/usr/bin", t.TempDir())
+		require.NoError(t, err)
+
+		_, err = NewRootFS(testFS)
+		require.EqualError(t, err, "unsupported GuestDir /usr/bin, only root level allowed")
+	})
+	t.Run("multiple matches", func(t *testing.T) {
+		tmpDir1 := t.TempDir()
+		testFS1, err := NewDirFS("/", tmpDir1)
+		require.NoError(t, err)
+		require.NoError(t, os.Mkdir(pathutil.Join(tmpDir1, "tmp"), 0o700))
+		require.NoError(t, os.WriteFile(pathutil.Join(tmpDir1, "a"), []byte{1}, 0o600))
+
+		tmpDir2 := t.TempDir()
+		testFS2, err := NewDirFS("/tmp", tmpDir2)
+		require.NoError(t, err)
+		require.NoError(t, os.WriteFile(pathutil.Join(tmpDir2, "a"), []byte{2}, 0o600))
+
+		rootFS, err := NewRootFS(testFS2, testFS1)
+		require.NoError(t, err)
+
+		// unwrapping returns in original order
+		unwrapped := rootFS.(*CompositeFS).Unwrap()
+		require.Equal(t, []FS{testFS2, testFS1}, unwrapped)
+
+		// Should be a composite filesystem
+		require.NotEqual(t, testFS1, rootFS)
+		require.NotEqual(t, testFS2, rootFS)
+
+		t.Run("last wins", func(t *testing.T) {
+			f, err := rootFS.OpenFile("/tmp/a", os.O_RDONLY, 0)
+			require.NoError(t, err)
+			defer f.Close()
+			b, err := io.ReadAll(f)
+			require.NoError(t, err)
+			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, err := rootFS.OpenFile(".", os.O_RDONLY, 0)
+			require.NoError(t, err)
+			defer f.Close()
+
+			require.Equal(t, []string{"a", "tmp"}, readDirNames(t, f))
+		})
+	})
+}
+
+func readDirNames(t *testing.T, f fs.File) []string {
+	entries, err := f.(fs.ReadDirFile).ReadDir(-1)
+	require.NoError(t, err)
+
+	names := make([]string, 0, len(entries))
+	for _, e := range entries {
+		names = append(names, e.Name())
+	}
+	sort.Strings(names)
+	return names
+}
+
+func TestRootFS_Open(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Create a subdirectory, so we can test reads outside the FS root.
+	tmpDir = pathutil.Join(tmpDir, t.Name())
+	require.NoError(t, os.Mkdir(tmpDir, 0o700))
+
+	testFS, err := NewDirFS("/", tmpDir)
+	require.NoError(t, err)
+
+	testOpen_Read(t, tmpDir, testFS)
+
+	testOpen_O_RDWR(t, tmpDir, testFS)
+
+	t.Run("path outside root valid", func(t *testing.T) {
+		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
+
+		// syscall.FS allows relative path lookups
+		require.True(t, errors.Is(err, fs.ErrNotExist))
+	})
+}
+
+func TestRootFS_TestFS(t *testing.T) {
+	t.Parallel()
+
+	// Set up the test files
+	tmpDir1 := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir1))
+
+	// move one directory outside the other
+	tmpDir2 := t.TempDir()
+	require.NoError(t, os.Rename(pathutil.Join(tmpDir1, "dir"), pathutil.Join(tmpDir2, "dir")))
+
+	// Create a root mount
+	testFS1, err := NewDirFS("/", tmpDir1)
+	require.NoError(t, err)
+
+	// Create a dir mount
+	testFS2, err := NewDirFS("/dir", pathutil.Join(tmpDir2, "dir"))
+	require.NoError(t, err)
+
+	testFS, err := NewRootFS(testFS1, testFS2)
+	require.NoError(t, err)
+
+	// Run TestFS via the adapter
+	require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
+}
+
+func TestRootFS_examples(t *testing.T) {
+	tests := []struct {
+		name                 string
+		fs                   []FS
+		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: []FS{
+				&adapter{"/tmp", testfs.FS{"go-example-stdout-ExampleTemplate-0.txt": &testfs.File{}}},
+				&adapter{".", testfs.FS{"testdata/file1.tmpl": &testfs.File{}}},
+			},
+			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: []FS{
+				&adapter{"/", testfs.FS{}},
+				&adapter{"../", testfs.FS{"testdata/e.txt": &testfs.File{}}},
+				&adapter{"../../", testfs.FS{"testdata/Isaac.Newton-Opticks.txt": &testfs.File{}}},
+			},
+			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: []FS{
+				&adapter{"/etc", testfs.FS{"services": &testfs.File{}}},
+				&adapter{"/", testfs.FS{"testdata/aliases": &testfs.File{}}},
+			},
+			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: []FS{
+				&adapter{"/", gofstest.MapFS{ // to allow resolution of "."
+					"pybuilddir.txt": &gofstest.MapFile{},
+					"opt/wasi-python/lib/python3.11/__phello__/__init__.py": &gofstest.MapFile{},
+				}},
+			},
+			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: []FS{
+				&adapter{"/", testfs.FS{"zig-cache": &testfs.File{}}},
+				&adapter{"/tmp", testfs.FS{"qSQRrUkgJX9L20mr": &testfs.File{}}},
+			},
+			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...)
+			require.NoError(t, err)
+
+			for _, p := range tc.expected {
+				f, err := root.OpenFile(p, os.O_RDONLY, 0)
+				require.NoError(t, err, p)
+				require.NoError(t, f.Close(), p)
+			}
+
+			for _, p := range tc.unexpected {
+				_, err := root.OpenFile(p, os.O_RDONLY, 0)
+				requireErrno(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/testing/fs/fs.go b/internal/testing/fs/fs.go
index c169b0f1..48a93953 100644
--- a/internal/testing/fs/fs.go
+++ b/internal/testing/fs/fs.go
@@ -11,11 +11,9 @@ var _ fs.FS = &FS{}
 // FS emulates fs.FS. Note: the path (map key) cannot begin with "/"!
 type FS map[string]*File
 
-// Open implements the same method as documented on fs.FS.
+// Open implements the same method as documented on fs.FS, except it doesn't
+// validate the path.
 func (f FS) Open(name string) (fs.File, error) {
-	if !fs.ValidPath(name) {
-		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
-	}
 	if file, ok := f[name]; !ok {
 		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
 	} else {
diff --git a/internal/testing/fs/fs_test.go b/internal/testing/fs/fs_test.go
index d1261b16..39e31de0 100644
--- a/internal/testing/fs/fs_test.go
+++ b/internal/testing/fs/fs_test.go
@@ -10,12 +10,6 @@ import (
 func TestFS(t *testing.T) {
 	testFS := &FS{}
 
-	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)