Repository: tetratelabs/wazero
Pull request #1061 (2023-01-25 01:57:53)

fs: adds FSConfig to replace experimental writefs
This adds a new top-level type FSConfig, which is configured via `ModuleConfig.WithFSConfig(fcfg)`. This implements read-only and read-write directory mounts, something not formally supported before. It also implements `WithFS` which adapts a normal `fs.FS`. For convenience, we retain the old `ModuleConfig.WithFS` signature so as to not affect existing users much.

Here's an example:
```go
moduleConfig = wazero.NewModuleConfig().
	// Make the current directory read-only accessible to the guest.
	WithReadOnlyDirMount(".", "/")
	// Make "/tmp/wasm" accessible to the guest as "/tmp".
	WithDirMount("/tmp/wasm", "/tmp")
```

A new configuration for our emerging sysfs filesystem, `FSConfig.WithMount(sysfs, guestPath)` will happen later without breaking this API. This PR is an important milestone for API stability meanwhile.

codefromthecrypt: @mathetake lemme know if you think the API is sound. if so, I'll backfill missing unit tests on fsconfig!
codefromthecrypt: cool I'll backfill w/in 18hrs

Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index e7eaf903..3dce1d2b 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -16,7 +16,6 @@ import (
 	"github.com/tetratelabs/wazero/experimental/logging"
 	gojs "github.com/tetratelabs/wazero/imports/go"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
-	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/version"
 	"github.com/tetratelabs/wazero/sys"
 )
@@ -161,7 +160,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 		env = append(env, fields[0], fields[1])
 	}
 
-	rootFS := validateMounts(mounts, stdErr, exit)
+	fsConfig := validateMounts(mounts, stdErr, exit)
 
 	wasm, err := os.ReadFile(wasmPath)
 	if err != nil {
@@ -188,6 +187,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 		WithStderr(stdErr).
 		WithStdin(os.Stdin).
 		WithRandSource(rand.Reader).
+		WithFSConfig(fsConfig).
 		WithSysNanosleep().
 		WithSysNanotime().
 		WithSysWalltime().
@@ -195,9 +195,6 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	for i := 0; i < len(env); i += 2 {
 		conf = conf.WithEnv(env[i], env[i+1])
 	}
-	if rootFS != nil {
-		conf = conf.WithFS(&sysfs.FSHolder{FS: rootFS})
-	}
 
 	code, err := rt.CompileModule(ctx, wasm)
 	if err != nil {
@@ -227,9 +224,8 @@ 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)) sysfs.FS {
-	fs := make([]sysfs.FS, 0, len(mounts))
-	guestPaths := make([]string, 0, len(mounts))
+func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) (config wazero.FSConfig) {
+	config = wazero.NewFSConfig()
 	for _, mount := range mounts {
 		if len(mount) == 0 {
 			fmt.Fprintln(stdErr, "invalid mount: empty string")
@@ -243,48 +239,41 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)
 		}
 
 		// TODO(anuraaga): Support wasm paths with colon in them.
-		var host, guest string
+		var dir, guestPath string
 		if clnIdx := strings.LastIndexByte(mount, ':'); clnIdx != -1 {
-			host, guest = mount[:clnIdx], mount[clnIdx+1:]
+			dir, guestPath = mount[:clnIdx], mount[clnIdx+1:]
 		} else {
-			host = mount
-			guest = host
+			dir = mount
+			guestPath = dir
 		}
 
 		// Provide a better experience if duplicates are found later.
-		if guest == "" {
-			guest = "/"
+		if guestPath == "" {
+			guestPath = "/"
 		}
 
 		// Eagerly validate the mounts as we know they should be on the host.
-		if abs, err := filepath.Abs(host); err != nil {
-			fmt.Fprintf(stdErr, "invalid mount: path %q invalid: %v\n", host, err)
+		if abs, err := filepath.Abs(dir); err != nil {
+			fmt.Fprintf(stdErr, "invalid mount: path %q invalid: %v\n", dir, err)
 			exit(1)
 		} else {
-			host = abs
+			dir = abs
 		}
 
-		if stat, err := os.Stat(host); err != nil {
-			fmt.Fprintf(stdErr, "invalid mount: path %q error: %v\n", host, err)
+		if stat, err := os.Stat(dir); err != nil {
+			fmt.Fprintf(stdErr, "invalid mount: path %q error: %v\n", dir, err)
 			exit(1)
 		} else if !stat.IsDir() {
-			fmt.Fprintf(stdErr, "invalid mount: path %q is not a directory\n", host)
+			fmt.Fprintf(stdErr, "invalid mount: path %q is not a directory\n", dir)
 		}
 
-		next := sysfs.NewDirFS(host)
 		if readOnly {
-			next = sysfs.NewReadFS(next)
+			config = config.WithReadOnlyDirMount(dir, guestPath)
+		} else {
+			config = config.WithDirMount(dir, guestPath)
 		}
-		fs = append(fs, next)
-		guestPaths = append(guestPaths, guest)
-	}
-	if fs, err := sysfs.NewRootFS(fs, guestPaths); err != nil {
-		fmt.Fprintf(stdErr, "invalid mounts %v: %v\n", fs, err)
-		exit(1)
-		return nil
-	} else {
-		return fs
 	}
+	return
 }
 
 func detectImports(imports []api.FunctionDefinition) (needsWASI, needsGo bool) {
diff --git a/config.go b/config.go
index bf0143fa..8e648848 100644
--- a/config.go
+++ b/config.go
@@ -15,6 +15,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/filecache"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/sys"
 )
@@ -413,38 +414,18 @@ type ModuleConfig interface {
 	// See https://linux.die.net/man/3/environ and https://en.wikipedia.org/wiki/Null-terminated_string
 	WithEnv(key, value string) ModuleConfig
 
-	// WithFS assigns the file system to use for any paths beginning at "/".
-	// Defaults return fs.ErrNotExist.
-	//
-	// This example sets a read-only, embedded file-system:
-	//
-	//	//go:embed testdata/index.html
-	//	var testdataIndex embed.FS
-	//
-	//	rooted, err := fs.Sub(testdataIndex, "testdata")
-	//	require.NoError(t, err)
-	//
-	//	// "index.html" is accessible as "/index.html".
-	//	config := wazero.NewModuleConfig().WithFS(rooted)
-	//
-	// This example sets a mutable file-system:
-	//
-	//	// Files relative to "/work/appA" are accessible as "/".
-	//	config := wazero.NewModuleConfig().WithFS(os.DirFS("/work/appA"))
-	//
-	// Isolation
-	//
-	// os.DirFS documentation includes important notes about isolation, which
-	// also applies to fs.Sub. As of Go 1.19, the built-in file-systems are not
-	// jailed (chroot). See https://github.com/golang/go/issues/42322
-	//
-	// Working Directory "."
-	//
-	// Relative path resolution, such as "./config.yml" to "/config.yml" or
-	// otherwise, is compiler-specific. See /RATIONALE.md for notes.
+	// WithFS is a convenience that calls WithFSConfig with an FSConfig of the
+	// input for the root ("/") guest path.
 	WithFS(fs.FS) ModuleConfig
 
-	// WithName configures the module name. Defaults to what was decoded from the name section.
+	// WithFSConfig configures the filesystem available to each guest
+	// instantiated with this configuration. By default, no file access is
+	// allowed, so functions like `path_open` result in unsupported errors
+	// (e.g. syscall.ENOSYS).
+	WithFSConfig(FSConfig) ModuleConfig
+
+	// WithName configures the module name. Defaults to what was decoded from
+	// the name section.
 	WithName(string) ModuleConfig
 
 	// WithStartFunctions configures the functions to call after the module is
@@ -593,8 +574,8 @@ type moduleConfig struct {
 	environ [][]byte
 	// environKeys allow overwriting of existing values.
 	environKeys map[string]int
-	// fs is the file system to open files with
-	fs fs.FS
+	// fsConfig is the file system configuration for ABI like WASI.
+	fsConfig FSConfig
 }
 
 // NewModuleConfig returns a ModuleConfig that can be used for configuring module instantiation.
@@ -648,8 +629,13 @@ func (c *moduleConfig) WithEnv(key, value string) ModuleConfig {
 
 // WithFS implements ModuleConfig.WithFS
 func (c *moduleConfig) WithFS(fs fs.FS) ModuleConfig {
+	return c.WithFSConfig(NewFSConfig().WithFSMount(fs, ""))
+}
+
+// WithFSConfig implements ModuleConfig.WithFSConfig
+func (c *moduleConfig) WithFSConfig(config FSConfig) ModuleConfig {
 	ret := c.clone()
-	ret.fs = fs
+	ret.fsConfig = config
 	return ret
 }
 
@@ -764,6 +750,13 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) {
 		environ = append(environ, result)
 	}
 
+	var fs sysfs.FS
+	if f, ok := c.fsConfig.(*fsConfig); ok {
+		if fs, err = f.toFS(); err != nil {
+			return
+		}
+	}
+
 	return internalsys.NewContext(
 		math.MaxUint32,
 		c.args,
@@ -775,6 +768,6 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) {
 		c.walltime, c.walltimeResolution,
 		c.nanotime, c.nanotimeResolution,
 		c.nanosleep,
-		c.fs,
+		fs,
 	)
 }
diff --git a/experimental/writefs/writefs.go b/experimental/writefs/writefs.go
deleted file mode 100644
index 289ce14d..00000000
--- a/experimental/writefs/writefs.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Package writefs includes wazero-specific fs.FS implementations that allow
-// creation and deletion of files and directories.
-//
-// This is a work-in-progress and a workaround needed because write support is
-// not yet supported in fs.FS. See https://github.com/golang/go/issues/45757
-//
-// Tracking issue: https://github.com/tetratelabs/wazero/issues/390
-package writefs
-
-import (
-	"io/fs"
-
-	"github.com/tetratelabs/wazero/internal/sysfs"
-)
-
-// NewDirFS creates a writeable filesystem at the given path on the host
-// filesystem.
-//
-// This is like os.DirFS, but allows creation and deletion of files and
-// directories, as well as timestamp modifications. None of which are supported
-// in fs.FS.
-//
-// The following errors are expected:
-//   - syscall.EINVAL: `dir` is invalid.
-//   - syscall.ENOENT: `dir` doesn't exist.
-//   - syscall.ENOTDIR: `dir` exists, but is not a directory.
-//
-// # Isolation
-//
-// Symbolic links can escape the root path as files are opened via os.OpenFile
-// which cannot restrict following them.
-//
-// # This is wazero-only
-//
-// 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 {
-	// sysfs.DirFS is intentionally internal as it is still evolving
-	return &sysfs.FSHolder{FS: sysfs.NewDirFS(dir)}
-}
diff --git a/fsconfig.go b/fsconfig.go
new file mode 100644
index 00000000..d2eedcb8
--- /dev/null
+++ b/fsconfig.go
@@ -0,0 +1,165 @@
+package wazero
+
+import (
+	"io/fs"
+
+	"github.com/tetratelabs/wazero/internal/sysfs"
+)
+
+// FSConfig configures filesystem paths the embedding host allows the wasm
+// guest to access. Unconfigured paths are not allowed, so functions like
+// `path_open` result in unsupported errors (e.g. syscall.ENOSYS).
+//
+// # Guest Path
+//
+// `guestPath` is the name of the path the guest should use a filesystem for, or
+// empty for any files.
+//
+// All `guestPath` paths are normalized, specifically removing any leading or
+// trailing slashes. This means "/", "./" or "." all coerce to empty "".
+//
+// Multiple `guestPath` values can be configured, but the last longest match
+// wins. For example, if "tmp", then "" were added, a request to open
+// "tmp/foo.txt" use the filesystem associated with "tmp" even though a wider
+// path, "" (all files), was added later.
+//
+// A `guestPath` of "." coerces to the empty string "" because the current
+// directory is handled by the guest. In other words, the guest resolves ites
+// current directory prior to requesting files.
+//
+// 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.
+//   - 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.
+//   - Zig uses the first path name it sees as the initial working directory of
+//     the process.
+//
+// # Scope
+//
+// Configuration here is module instance scoped. This means you can use the
+// same configuration for multiple calls to Runtime.InstantiateModule. Each
+// module will have a different file descriptor table. Any errors accessing
+// resources allowed here are deferred to instantiation time of each module.
+//
+// Any host resources present at the time of configuration, but deleted before
+// Runtime.InstantiateModule will trap/panic when the guest wasm initializes or
+// calls functions like `fd_read`.
+//
+// # Windows
+//
+// While wazero supports Windows as a platform, all known compilers use POSIX
+// conventions at runtime. For example, even when running on Windows, paths
+// used by wasm are separated by forward slash (/), not backslash (\).
+//
+// # Notes
+//
+//   - FSConfig is immutable. Each WithXXX function returns a new instance
+//     including the corresponding change.
+//   - RATIONALE.md includes design background and relationship to WebAssembly
+//     System Interfaces (WASI).
+type FSConfig interface {
+	// WithDirMount assigns a directory at `dir` to any paths beginning at
+	// `guestPath`.
+	//
+	// If the same `guestPath` was assigned before, this overrides its value,
+	// retaining the original precedence. See the documentation of FSConfig for
+	// more details on `guestPath`.
+	//
+	// # Isolation
+	//
+	// The guest will have full access to this directory including escaping it
+	// via relative path lookups like "../../". Full access includes operations
+	// such as creating or deleting files, limited to any host level access
+	// controls.
+	WithDirMount(dir, guestPath string) FSConfig
+
+	// WithReadOnlyDirMount assigns a directory at `dir` to any paths
+	// beginning at `guestPath`.
+	//
+	// This is the same as WithDirMount except only read operations are
+	// permitted. However, escaping the directory via relative path lookups
+	// like "../../" is still allowed.
+	WithReadOnlyDirMount(dir, guestPath string) FSConfig
+
+	// WithFSMount assigns a fs.FS file system for any paths beginning at
+	// `guestPath`.
+	//
+	// If the same `guestPath` was assigned before, this overrides its value,
+	// retaining the original precedence. See the documentation of FSConfig for
+	// more details on `guestPath`.
+	//
+	// # Isolation
+	//
+	// fs.FS does not restrict the ability to overwrite returned files via
+	// io.Writer. Moreover, os.DirFS documentation includes important notes
+	// about isolation, which also applies to fs.Sub. As of Go 1.19, the
+	// built-in file-systems are not jailed (chroot). See
+	// https://github.com/golang/go/issues/42322
+	WithFSMount(fs fs.FS, guestPath string) FSConfig
+}
+
+type fsConfig struct {
+	// fs are the currently configured filesystems.
+	fs []sysfs.FS
+	// guestPaths are the user-supplied names of the filesystems, retained for
+	// error messages and fmt.Stringer.
+	guestPaths []string
+	// guestPathToFS are the normalized paths to the currently configured
+	// filesystems, used for de-duplicating.
+	guestPathToFS map[string]int
+}
+
+// NewFSConfig returns a FSConfig that can be used for configuring module instantiation.
+func NewFSConfig() FSConfig {
+	return &fsConfig{guestPathToFS: map[string]int{}}
+}
+
+// clone makes a deep copy of this module config.
+func (c *fsConfig) clone() *fsConfig {
+	ret := *c // copy except slice and maps which share a ref
+	ret.fs = make([]sysfs.FS, 0, len(c.fs))
+	ret.fs = append(ret.fs, c.fs...)
+	ret.guestPaths = make([]string, 0, len(c.guestPaths))
+	ret.guestPaths = append(ret.guestPaths, c.guestPaths...)
+	ret.guestPathToFS = make(map[string]int, len(c.guestPathToFS))
+	for key, value := range c.guestPathToFS {
+		ret.guestPathToFS[key] = value
+	}
+	return &ret
+}
+
+// WithDirMount implements FSConfig.WithDirMount
+func (c *fsConfig) WithDirMount(dir, guestPath string) FSConfig {
+	return c.withMount(sysfs.NewDirFS(dir), guestPath)
+}
+
+// WithReadOnlyDirMount implements FSConfig.WithReadOnlyDirMount
+func (c *fsConfig) WithReadOnlyDirMount(dir, guestPath string) FSConfig {
+	return c.withMount(sysfs.NewReadFS(sysfs.NewDirFS(dir)), guestPath)
+}
+
+// WithFSMount implements FSConfig.WithFSMount
+func (c *fsConfig) WithFSMount(fs fs.FS, guestPath string) FSConfig {
+	return c.withMount(sysfs.Adapt(fs), guestPath)
+}
+
+func (c *fsConfig) withMount(fs sysfs.FS, guestPath string) FSConfig {
+	cleaned := sysfs.StripPrefixesAndTrailingSlash(guestPath)
+	ret := c.clone()
+	if i, ok := ret.guestPathToFS[cleaned]; ok {
+		ret.fs[i] = fs
+		ret.guestPaths[i] = guestPath
+	} else {
+		ret.guestPathToFS[cleaned] = len(ret.fs)
+		ret.fs = append(ret.fs, fs)
+		ret.guestPaths = append(ret.guestPaths, guestPath)
+	}
+	return ret
+}
+
+func (c *fsConfig) toFS() (sysfs.FS, error) {
+	return sysfs.NewRootFS(c.fs, c.guestPaths)
+}
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index 052deb0d..0399c25f 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/fs"
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
@@ -109,7 +108,7 @@ func (eofReader) Read([]byte) (int, error) {
 }
 
 // DefaultContext returns Context with no values set except a possible nil fs.FS
-func DefaultContext(fs fs.FS) *Context {
+func DefaultContext(fs sysfs.FS) *Context {
 	if sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, fs); err != nil {
 		panic(fmt.Errorf("BUG: DefaultContext should never error: %w", err))
 	} else {
@@ -135,7 +134,7 @@ func NewContext(
 	nanotime *sys.Nanotime,
 	nanotimeResolution sys.ClockResolution,
 	nanosleep *sys.Nanosleep,
-	fs fs.FS,
+	fs sysfs.FS,
 ) (sysCtx *Context, err error) {
 	sysCtx = &Context{args: args, environ: environ}
 
@@ -182,7 +181,7 @@ func NewContext(
 	}
 
 	if fs != nil {
-		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, sysfs.Adapt(fs))
+		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, fs)
 	} else {
 		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, sysfs.UnimplementedFS{})
 	}
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index 18630d0a..28f52466 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -5,6 +5,8 @@ import (
 	"io/fs"
 	"os"
 	pathutil "path"
+	"runtime"
+	"strings"
 )
 
 // Adapt adapts the input to FS unless it is already one. NewDirFS should be
@@ -15,8 +17,8 @@ import (
 // documentation does not require the file to be present. In summary, we can't
 // enforce flag behavior.
 func Adapt(fs fs.FS) FS {
-	if sys, ok := fs.(*FSHolder); ok {
-		return sys.FS
+	if sys, ok := fs.(FS); ok {
+		return sys
 	}
 	return &adapter{fs: fs}
 }
@@ -31,6 +33,11 @@ func (a *adapter) String() string {
 	return fmt.Sprintf("%v", a.fs)
 }
 
+// Open implements the same method as documented on fs.FS
+func (a *adapter) Open(name string) (fs.File, error) {
+	return a.fs.Open(name)
+}
+
 // OpenFile implements FS.OpenFile
 func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	path = cleanPath(path)
@@ -57,3 +64,26 @@ func cleanPath(name string) string {
 	cleaned = pathutil.Clean(cleaned) // e.g. "sub/." -> "sub"
 	return cleaned
 }
+
+// fsOpen implements the Open method as documented on fs.FS
+func fsOpen(f FS, name string) (fs.File, error) {
+	if !fs.ValidPath(name) { // FS.OpenFile has fewer constraints than fs.FS
+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
+	}
+
+	// This isn't a production-grade fs.FS implementation. The only special
+	// cases we address here are to pass testfs.TestFS.
+
+	if runtime.GOOS == "windows" {
+		switch {
+		case strings.Contains(name, "\\"):
+			return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
+		}
+	}
+
+	if f, err := f.OpenFile(name, os.O_RDONLY, 0); err != nil {
+		return nil, &fs.PathError{Op: "open", Path: name, Err: err}
+	} else {
+		return f, nil
+	}
+}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 76175b44..ccb3fe8e 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -33,6 +33,11 @@ func (d *dirFS) String() string {
 	return d.dir
 }
 
+// Open implements the same method as documented on fs.FS
+func (d *dirFS) Open(name string) (fs.File, error) {
+	return fsOpen(d, name)
+}
+
 // OpenFile implements FS.OpenFile
 func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
 	f, err := os.OpenFile(d.join(name), flag, perm)
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 7dbc1f39..001e2ca4 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -32,6 +32,11 @@ func (r *readFS) String() string {
 	return r.fs.String()
 }
 
+// Open implements the same method as documented on fs.FS
+func (r *readFS) Open(name string) (fs.File, error) {
+	return fsOpen(r, name)
+}
+
 // OpenFile implements FS.OpenFile
 func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	// TODO: Once the real implementation is complete, move the below to
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index 8d67dba3..232eb95c 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -56,7 +56,7 @@ func NewRootFS(fs []FS, guestPaths []string) (FS, error) {
 	if ret.rootIndex == -1 {
 		ret.rootIndex = len(fs)
 		ret.guestPaths = append(ret.guestPaths, "")
-		ret.fs = append(ret.fs, fakeRootFS{})
+		ret.fs = append(ret.fs, &fakeRootFS{})
 	}
 	return ret, nil
 }
@@ -106,13 +106,19 @@ func writeMount(ret *strings.Builder, f FS, guestPath string) {
 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 != (fakeRootFS{}) {
+		fs := c.fs[i]
+		if _, ok := fs.(*fakeRootFS); !ok {
 			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) {
+	return fsOpen(c, name)
+}
+
 // 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)
@@ -401,7 +407,7 @@ loop:
 type fakeRootFS struct{ UnimplementedFS }
 
 // OpenFile implements FS.OpenFile
-func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
+func (*fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	switch path {
 	case ".", "/", "":
 		return fakeRootDir{}, nil
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index cc4eaa90..3395d2d0 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -6,24 +6,12 @@
 package sysfs
 
 import (
-	"fmt"
 	"io"
 	"io/fs"
 	"os"
 	"syscall"
 )
 
-// FSHolder implements fs.FS in order to pass an FS until configuration
-// supports it natively.
-type FSHolder struct {
-	FS FS
-}
-
-// Open implements the same method as documented on fs.FS
-func (*FSHolder) Open(name string) (fs.File, error) {
-	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
-}
-
 // FS is a writeable fs.FS bridge backed by syscall functions needed for ABI
 // including WASI and runtime.GOOS=js.
 //
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index 42cad374..fb95e073 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -14,6 +14,11 @@ func (UnimplementedFS) String() string {
 	return "Unimplemented:/"
 }
 
+// Open implements the same method as documented on fs.FS
+func (UnimplementedFS) Open(name string) (fs.File, error) {
+	return nil, &fs.PathError{Op: "open", Path: name, Err: syscall.ENOSYS}
+}
+
 // OpenFile implements FS.OpenFile
 func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	return nil, syscall.ENOSYS


Test patch:
diff --git a/config_test.go b/config_test.go
index b5fa17df..1f216ab4 100644
--- a/config_test.go
+++ b/config_test.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"crypto/rand"
 	"io"
-	"io/fs"
 	"math"
 	"testing"
 
@@ -12,6 +11,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/sysfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -323,7 +323,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				&wt, 1,         // walltime, walltimeResolution
 				&nt, 1, // nanotime, nanotimeResolution
 				nil, // nanosleep
-				testFS,
+				sysfs.Adapt(testFS),
 			),
 		},
 		{
@@ -339,8 +339,8 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // randSource
 				&wt, 1,         // walltime, walltimeResolution
 				&nt, 1, // nanotime, nanotimeResolution
-				nil,     // nanosleep
-				testFS2, // fs
+				nil,                  // nanosleep
+				sysfs.Adapt(testFS2), // fs
 			),
 		},
 		{
@@ -563,7 +563,7 @@ func TestModuleConfig_clone(t *testing.T) {
 	cloned := mc.clone()
 
 	// Make post-clone changes
-	mc.fs = fstest.FS
+	mc.fsConfig = NewFSConfig().WithFSMount(fstest.FS, "/")
 	mc.environKeys["2"] = 2
 
 	cloned.environKeys["1"] = 1
@@ -573,7 +573,7 @@ func TestModuleConfig_clone(t *testing.T) {
 	require.Equal(t, map[string]int{"1": 1}, cloned.environKeys)
 
 	// Ensure the fs is not shared
-	require.Nil(t, cloned.fs)
+	require.Nil(t, cloned.fsConfig)
 }
 
 func Test_compiledModule_Name(t *testing.T) {
@@ -697,7 +697,7 @@ func requireSysContext(
 	walltime *sys.Walltime, walltimeResolution sys.ClockResolution,
 	nanotime *sys.Nanotime, nanotimeResolution sys.ClockResolution,
 	nanosleep *sys.Nanosleep,
-	fs fs.FS,
+	fs sysfs.FS,
 ) *internalsys.Context {
 	sysCtx, err := internalsys.NewContext(
 		max,
diff --git a/experimental/writefs/writefs_example_test.go b/experimental/writefs/writefs_example_test.go
deleted file mode 100644
index 2d73d2df..00000000
--- a/experimental/writefs/writefs_example_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package writefs_test
-
-import (
-	_ "embed"
-
-	"github.com/tetratelabs/wazero"
-	"github.com/tetratelabs/wazero/experimental/writefs"
-)
-
-var config wazero.ModuleConfig //nolint
-
-// 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 := writefs.NewDirFS("/work/appA")
-	config = wazero.NewModuleConfig().WithFS(fs)
-}
diff --git a/fsconfig_example_test.go b/fsconfig_example_test.go
new file mode 100644
index 00000000..1993421e
--- /dev/null
+++ b/fsconfig_example_test.go
@@ -0,0 +1,27 @@
+package wazero_test
+
+import (
+	"embed"
+	"io/fs"
+	"log"
+
+	"github.com/tetratelabs/wazero"
+)
+
+//go:embed testdata/index.html
+var testdataIndex embed.FS
+
+var moduleConfig wazero.ModuleConfig
+
+// This example shows how to configure an embed.FS.
+func Example_withFSConfig_embedFS() {
+	// Strip the embedded path testdata/
+	rooted, err := fs.Sub(testdataIndex, "testdata")
+	if err != nil {
+		log.Panicln(err)
+	}
+
+	moduleConfig = wazero.NewModuleConfig().
+		// Make "index.html" accessible to the guest as "/index.html".
+		WithFSConfig(wazero.NewFSConfig().WithFSMount(rooted, "/"))
+}
diff --git a/fsconfig_test.go b/fsconfig_test.go
new file mode 100644
index 00000000..144b1f87
--- /dev/null
+++ b/fsconfig_test.go
@@ -0,0 +1,106 @@
+package wazero
+
+import (
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/sysfs"
+	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+// TestFSConfig only tests the cases that change the inputs to sysfs.NewRootFS.
+func TestFSConfig(t *testing.T) {
+	base := NewFSConfig()
+
+	testFS := testfs.FS{}
+	testFS2 := testfs.FS{"/": &testfs.File{}}
+
+	tests := []struct {
+		name     string
+		input    FSConfig
+		expected sysfs.FS
+	}{
+		{
+			name:     "empty",
+			input:    base,
+			expected: sysfs.UnimplementedFS{},
+		},
+		{
+			name:     "WithFSMount",
+			input:    base.WithFSMount(testFS, "/"),
+			expected: sysfs.Adapt(testFS),
+		},
+		{
+			name:     "WithFSMount overwrites",
+			input:    base.WithFSMount(testFS, "/").WithFSMount(testFS2, "/"),
+			expected: sysfs.Adapt(testFS2),
+		},
+		{
+			name:     "WithDirMount overwrites",
+			input:    base.WithFSMount(testFS, "/").WithDirMount(".", "/"),
+			expected: sysfs.NewDirFS("."),
+		},
+		{
+			name:  "Composition",
+			input: base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"),
+			expected: func() sysfs.FS {
+				f, err := sysfs.NewRootFS(
+					[]sysfs.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
+					[]string{"/", "/tmp"},
+				)
+				require.NoError(t, err)
+				return f
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		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)
+		})
+	}
+}
+
+func TestFSConfig_clone(t *testing.T) {
+	fc := NewFSConfig().(*fsConfig)
+	fc.guestPathToFS["/"] = 0
+
+	cloned := fc.clone()
+
+	// Make post-clone changes
+	fc.guestPaths = []string{"/"}
+	fc.guestPathToFS["/"] = 1
+
+	// Ensure the guestPathToFS map is not shared
+	require.Equal(t, map[string]int{"/": 1}, fc.guestPathToFS)
+	require.Equal(t, map[string]int{"/": 0}, cloned.guestPathToFS)
+
+	// Ensure the guestPaths slice is not shared
+	require.Zero(t, len(cloned.guestPaths))
+}
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 93f68c30..c8d673ba 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -14,7 +14,6 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
-	"github.com/tetratelabs/wazero/experimental/writefs"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/leb128"
 	"github.com/tetratelabs/wazero/internal/sys"
@@ -720,9 +719,8 @@ func Test_fdPread_Errors(t *testing.T) {
 }
 
 func Test_fdPrestatGet(t *testing.T) {
-	testFS := writefs.NewDirFS(t.TempDir())
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(t.TempDir(), "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 	dirFD := sys.FdPreopen
 
@@ -806,9 +804,8 @@ func Test_fdPrestatGet_Errors(t *testing.T) {
 }
 
 func Test_fdPrestatDirName(t *testing.T) {
-	testFS := writefs.NewDirFS(t.TempDir())
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(t.TempDir(), "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 	dirFD := sys.FdPreopen
 
@@ -2282,9 +2279,8 @@ func Test_fdWrite_Errors(t *testing.T) {
 
 func Test_pathCreateDirectory(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	// set up the initial memory to include the path name starting at an offset.
@@ -2312,9 +2308,8 @@ func Test_pathCreateDirectory(t *testing.T) {
 
 func Test_pathCreateDirectory_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	file := "file"
@@ -2820,7 +2815,7 @@ func Test_pathOpen(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().
-				WithFS(&sysfs.FSHolder{FS: tc.fs}))
+				WithFS(tc.fs.(fs.FS))) // built-in impls reverse-implement fs.FS
 			defer r.Close(testCtx)
 			pathName := tc.path(t)
 			mod.Memory().Write(0, []byte(pathName))
@@ -2890,9 +2885,8 @@ 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.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	preopenedFD := sys.FdPreopen
@@ -3033,9 +3027,8 @@ func Test_pathReadlink(t *testing.T) {
 
 func Test_pathRemoveDirectory(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	// set up the initial memory to include the path name starting at an offset.
@@ -3065,9 +3058,8 @@ func Test_pathRemoveDirectory(t *testing.T) {
 
 func Test_pathRemoveDirectory_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	file := "file"
@@ -3202,9 +3194,8 @@ func Test_pathSymlink(t *testing.T) {
 
 func Test_pathRename(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	// set up the initial memory to include the old path name starting at an offset.
@@ -3245,9 +3236,8 @@ func Test_pathRename(t *testing.T) {
 
 func Test_pathRename_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	file := "file"
@@ -3420,9 +3410,8 @@ func Test_pathRename_Errors(t *testing.T) {
 
 func Test_pathUnlinkFile(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	// set up the initial memory to include the path name starting at an offset.
@@ -3452,9 +3441,8 @@ func Test_pathUnlinkFile(t *testing.T) {
 
 func Test_pathUnlinkFile_Errors(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
-	testFS := writefs.NewDirFS(tmpDir)
-
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	defer r.Close(testCtx)
 
 	file := "file"
@@ -3562,15 +3550,16 @@ func requireOpenFile(t *testing.T, tmpDir string, pathName string, data []byte,
 		require.NoError(t, os.WriteFile(realPath, data, 0o600))
 	}
 
-	testFS := sysfs.NewDirFS(tmpDir)
+	fsConfig := wazero.NewFSConfig()
 
 	if readOnly {
 		oflags = os.O_RDONLY
-		testFS = sysfs.NewReadFS(testFS)
+		fsConfig = fsConfig.WithReadOnlyDirMount(tmpDir, "/")
+	} else {
+		fsConfig = fsConfig.WithDirMount(tmpDir, "/")
 	}
 
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().
-		WithFS(&sysfs.FSHolder{FS: testFS}))
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig))
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
 	fd, err := fsc.OpenFile(pathName, oflags, 0)
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 61ef81a2..6ab65733 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -6,7 +6,6 @@ import (
 	"testing"
 
 	"github.com/tetratelabs/wazero"
-	"github.com/tetratelabs/wazero/experimental/writefs"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -44,9 +43,8 @@ func Test_testfs(t *testing.T) {
 	require.NoError(t, os.Mkdir(testfsDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(testfsDir))
 
-	testFS := writefs.NewDirFS(tmpDir)
-
-	stdout, stderr, err := compileAndRun(testCtx, "testfs", wazero.NewModuleConfig().WithFS(testFS))
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
+	stdout, stderr, err := compileAndRun(testCtx, "testfs", wazero.NewModuleConfig().WithFSConfig(fsConfig))
 
 	require.Zero(t, stderr)
 	require.EqualError(t, err, `module "" closed with exit_code(0)`)
@@ -56,12 +54,12 @@ func Test_testfs(t *testing.T) {
 func Test_writefs(t *testing.T) {
 	t.Parallel()
 	tmpDir := t.TempDir()
-	testFS := writefs.NewDirFS(tmpDir)
+	fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/")
 
 	// test expects to write under /tmp
 	require.NoError(t, os.Mkdir(path.Join(tmpDir, "tmp"), 0o700))
 
-	stdout, stderr, err := compileAndRun(testCtx, "writefs", wazero.NewModuleConfig().WithFS(testFS))
+	stdout, stderr, err := compileAndRun(testCtx, "writefs", wazero.NewModuleConfig().WithFSConfig(fsConfig))
 
 	require.Zero(t, stderr)
 	require.EqualError(t, err, `module "" closed with exit_code(0)`)
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index d26b9d96..fa2fac0a 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -22,6 +22,8 @@ func TestContext_FS(t *testing.T) {
 }
 
 func TestDefaultSysContext(t *testing.T) {
+	testFS := sysfs.Adapt(testfs.FS{})
+
 	sysCtx, err := NewContext(
 		0,      // max
 		nil,    // args
@@ -32,8 +34,8 @@ func TestDefaultSysContext(t *testing.T) {
 		nil,    // randSource
 		nil, 0, // walltime, walltimeResolution
 		nil, 0, // nanotime, nanotimeResolution
-		nil,         // nanosleep
-		testfs.FS{}, // fs
+		nil,    // nanosleep
+		testFS, // fs
 	)
 	require.NoError(t, err)
 
@@ -51,7 +53,6 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, &ns, sysCtx.nanosleep)
 	require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource())
 
-	testFS := sysfs.Adapt(testfs.FS{})
 	expectedFS, _ := NewFSContext(nil, nil, nil, testFS)
 
 	expectedOpenedFiles := FileTable{}
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 8971caed..46a19caf 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -159,7 +159,7 @@ func TestAdapt_TestFS(t *testing.T) {
 			testFS := Adapt(tc.fs)
 
 			// Adapt it back to fs.FS and run the tests
-			require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
+			require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
 		})
 	}
 }
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index f27b808e..0387f1ef 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -380,5 +380,5 @@ func TestDirFS_TestFS(t *testing.T) {
 	testFS := NewDirFS(tmpDir)
 
 	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
+	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
 }
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 6beeb90d..9a6a165b 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -123,5 +123,5 @@ func TestReadFS_TestFS(t *testing.T) {
 	testFS = NewReadFS(testFS)
 
 	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
+	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
 }
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
index aa721c97..76eaf60f 100644
--- a/internal/sysfs/rootfs_test.go
+++ b/internal/sysfs/rootfs_test.go
@@ -173,7 +173,7 @@ func TestRootFS_TestFS(t *testing.T) {
 	require.NoError(t, err)
 
 	// Run TestFS via the adapter
-	require.NoError(t, fstest.TestFS(&testFSAdapter{testFS}))
+	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
 }
 
 func TestRootFS_examples(t *testing.T) {
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index fe4017d3..5c9f7155 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -10,7 +10,6 @@ import (
 	"path"
 	"runtime"
 	"sort"
-	"strings"
 	"syscall"
 	"testing"
 	"testing/fstest"
@@ -231,30 +230,6 @@ func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 	}
 }
 
-// testFSAdapter implements fs.FS only to use fstest.TestFS
-type testFSAdapter struct {
-	fs FS
-}
-
-// Open implements the same method as documented on fs.FS
-func (f *testFSAdapter) Open(name string) (fs.File, error) {
-	if !fs.ValidPath(name) { // FS.OpenFile has fewer constraints than fs.FS
-		return nil, os.ErrInvalid
-	}
-
-	// This isn't a production-grade fs.FS implementation. The only special
-	// cases we address here are to pass testfs.TestFS.
-
-	if runtime.GOOS == "windows" {
-		switch {
-		case strings.Contains(name, "\\"):
-			return nil, os.ErrInvalid
-		}
-	}
-
-	return f.fs.OpenFile(name, os.O_RDONLY, 0)
-}
-
 // requireErrno should only be used for functions that wrap the underlying
 // syscall.Errno.
 func requireErrno(t *testing.T, expected syscall.Errno, actual error) {
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index bbb9ee8d..b25b2dc0 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/sysfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -144,7 +145,7 @@ func TestCallContext_Close(t *testing.T) {
 	}
 
 	t.Run("calls Context.Close()", func(t *testing.T) {
-		sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}})
+		sysCtx := sys.DefaultContext(sysfs.Adapt(testfs.FS{"foo": &testfs.File{}}))
 		fsCtx := sysCtx.FS()
 
 		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
@@ -172,7 +173,7 @@ func TestCallContext_Close(t *testing.T) {
 	t.Run("error closing", func(t *testing.T) {
 		// Right now, the only way to err closing the sys context is if a File.Close erred.
 		testFS := testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}}
-		sysCtx := sys.DefaultContext(testFS)
+		sysCtx := sys.DefaultContext(sysfs.Adapt(testFS))
 		fsCtx := sysCtx.FS()
 
 		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
@@ -240,7 +241,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 	}
 
 	t.Run("calls Context.Close()", func(t *testing.T) {
-		sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}})
+		sysCtx := sys.DefaultContext(sysfs.Adapt(testfs.FS{"foo": &testfs.File{}}))
 		fsCtx := sysCtx.FS()
 
 		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
@@ -268,7 +269,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 	t.Run("error closing", func(t *testing.T) {
 		// Right now, the only way to err closing the sys context is if a File.Close erred.
 		testFS := testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}}
-		sysCtx := sys.DefaultContext(testFS)
+		sysCtx := sys.DefaultContext(sysfs.Adapt(testFS))
 		fsCtx := sysCtx.FS()
 
 		path := "/foo"
diff --git a/testdata/index.html b/testdata/index.html
new file mode 100644
index 00000000..69e9da41
--- /dev/null
+++ b/testdata/index.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<html></html>