Repository: tetratelabs/wazero
Pull request #1607 (2023-07-31 23:29:53)

sysfs: Makes ReadFS and AdaptFS embeddable
This exports the experimental `sysfs.ReadFS` and `sysfs.AdaptFS` types, to developers can choose to embed them. This makes certain decoration use cases easier.

Note: We can't do the same without a risk of drift in `sysfs.DirFS`, but that isn't likely a problem as people are used to `os.DirFS` which also doesn't export its type.


Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index 7866a013..b92a910a 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -419,9 +419,9 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer) (rc int, rootPath s
 			fmt.Fprintf(stdErr, "invalid mount: path %q is not a directory\n", dir)
 		}
 
-		root := sysfs.NewDirFS(dir)
+		root := sysfs.DirFS(dir)
 		if readOnly {
-			root = sysfs.NewReadFS(root)
+			root = &sysfs.ReadFS{FS: root}
 		}
 
 		config = config.(sysfs.FSConfig).WithSysFSMount(root, guestPath)
diff --git a/experimental/sys/unimplemented.go b/experimental/sys/unimplemented.go
index 1099eb12..764fba6a 100644
--- a/experimental/sys/unimplemented.go
+++ b/experimental/sys/unimplemented.go
@@ -10,16 +10,6 @@ import (
 // This should be embedded to have forward compatible implementations.
 type UnimplementedFS struct{}
 
-// String implements fmt.Stringer
-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: ENOSYS}
-}
-
 // OpenFile implements FS.OpenFile
 func (UnimplementedFS) OpenFile(path string, flag Oflag, perm fs.FileMode) (File, Errno) {
 	return nil, ENOSYS
@@ -80,11 +70,6 @@ func (UnimplementedFS) Utimens(path string, atim, mtim int64) Errno {
 	return ENOSYS
 }
 
-// Truncate implements FS.Truncate
-func (UnimplementedFS) Truncate(string, int64) Errno {
-	return ENOSYS
-}
-
 // UnimplementedFile is a File that returns ENOSYS for all functions,
 // except where no-op are otherwise documented.
 //
diff --git a/experimental/sysfs/sysfs.go b/experimental/sysfs/sysfs.go
index c2a18fd5..a5d90f7e 100644
--- a/experimental/sysfs/sysfs.go
+++ b/experimental/sysfs/sysfs.go
@@ -8,32 +8,29 @@
 package sysfs
 
 import (
-	"io/fs"
-
 	experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 )
 
-// Adapt adapts the input to sys.FS unless it is already one. Use NewDirFS
-// instead of os.DirFS as it handles interop issues such as windows support.
+// AdaptFS adapts the input to sys.FS. Use DirFS instead of adapting an
+// os.DirFS as it handles interop issues such as windows support.
 //
-// Note: This performs no flag verification on OpenFile. fs.FS cannot read
-// flags as there is no parameter to pass them through with. Moreover, fs.FS
+// Note: This performs no flag verification on OpenFile. sys.FS cannot read
+// flags as there is no parameter to pass them through with. Moreover, sys.FS
 // documentation does not require the file to be present. In summary, we can't
 // enforce flag behavior.
-func Adapt(fs fs.FS) experimentalsys.FS {
-	return sysfs.Adapt(fs)
+type AdaptFS = sysfs.AdaptFS
+
+// DirFS is like os.DirFS except it returns sys.FS, which has more features.
+func DirFS(dir string) experimentalsys.FS {
+	return sysfs.DirFS(dir)
 }
 
-// NewReadFS is used to mask an existing sys.FS for reads. Notably, this allows
+// ReadFS is used to mask an existing sys.FS for reads. Notably, this allows
 // the CLI to do read-only mounts of directories the host user can write, but
 // doesn't want the guest wasm to. For example, Python libraries shouldn't be
 // written to at runtime by the python wasm file.
-func NewReadFS(fs experimentalsys.FS) experimentalsys.FS {
-	return sysfs.NewReadFS(fs)
-}
-
-// NewDirFS is like os.DirFS except it returns sys.FS, which has more features.
-func NewDirFS(dir string) experimentalsys.FS {
-	return sysfs.NewDirFS(dir)
-}
+//
+// Note: This implements read-only by returning sys.EROFS or sys.EBADF,
+// depending on the operation that require write access.
+type ReadFS = sysfs.ReadFS
diff --git a/fsconfig.go b/fsconfig.go
index 5576e7cc..c28909e1 100644
--- a/fsconfig.go
+++ b/fsconfig.go
@@ -165,17 +165,21 @@ func (c *fsConfig) clone() *fsConfig {
 
 // WithDirMount implements FSConfig.WithDirMount
 func (c *fsConfig) WithDirMount(dir, guestPath string) FSConfig {
-	return c.WithSysFSMount(sysfs.NewDirFS(dir), guestPath)
+	return c.WithSysFSMount(sysfs.DirFS(dir), guestPath)
 }
 
 // WithReadOnlyDirMount implements FSConfig.WithReadOnlyDirMount
 func (c *fsConfig) WithReadOnlyDirMount(dir, guestPath string) FSConfig {
-	return c.WithSysFSMount(sysfs.NewReadFS(sysfs.NewDirFS(dir)), guestPath)
+	return c.WithSysFSMount(&sysfs.ReadFS{FS: sysfs.DirFS(dir)}, guestPath)
 }
 
 // WithFSMount implements FSConfig.WithFSMount
 func (c *fsConfig) WithFSMount(fs fs.FS, guestPath string) FSConfig {
-	return c.WithSysFSMount(sysfs.Adapt(fs), guestPath)
+	var adapted experimentalsys.FS
+	if fs != nil {
+		adapted = &sysfs.AdaptFS{FS: fs}
+	}
+	return c.WithSysFSMount(adapted, guestPath)
 }
 
 // WithSysFSMount implements sysfs.FSConfig
@@ -188,7 +192,7 @@ func (c *fsConfig) WithSysFSMount(fs experimentalsys.FS, guestPath string) FSCon
 	if i, ok := ret.guestPathToFS[cleaned]; ok {
 		ret.fs[i] = fs
 		ret.guestPaths[i] = guestPath
-	} else {
+	} else if fs != nil {
 		ret.guestPathToFS[cleaned] = len(ret.fs)
 		ret.fs = append(ret.fs, fs)
 		ret.guestPaths = append(ret.guestPaths, guestPath)
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index bacc0b18..51a9a548 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -9,40 +9,35 @@ import (
 	"github.com/tetratelabs/wazero/sys"
 )
 
-// Adapt adapts the input to sys.FS unless it is already one. Use NewDirFS instead
-// of os.DirFS as it handles interop issues such as windows support.
-//
-// Note: This performs no flag verification on OpenFile. sys.FS cannot read
-// flags as there is no parameter to pass them through with. Moreover, sys.FS
-// documentation does not require the file to be present. In summary, we can't
-// enforce flag behavior.
-func Adapt(fs fs.FS) experimentalsys.FS {
-	if fs == nil {
-		return experimentalsys.UnimplementedFS{}
-	}
-	if sys, ok := fs.(experimentalsys.FS); ok {
-		return sys
-	}
-	return &adapter{fs: fs}
-}
-
-type adapter struct {
-	experimentalsys.UnimplementedFS
-	fs fs.FS
+type AdaptFS struct {
+	FS fs.FS
 }
 
 // String implements fmt.Stringer
-func (a *adapter) String() string {
-	return fmt.Sprintf("%v", a.fs)
+func (a *AdaptFS) String() string {
+	return fmt.Sprintf("%v", a.FS)
 }
 
 // OpenFile implements the same method as documented on sys.FS
-func (a *adapter) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
-	return OpenFSFile(a.fs, cleanPath(path), flag, perm)
+func (a *AdaptFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
+	return OpenFSFile(a.FS, cleanPath(path), flag, perm)
+}
+
+// Lstat implements the same method as documented on sys.FS
+func (a *AdaptFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) {
+	// At this time, we make the assumption sys.FS instances do not support
+	// symbolic links, therefore Lstat is the same as Stat. This is obviously
+	// not true, but until FS.FS has a solid story for how to handle symlinks,
+	// we are better off not making a decision that would be difficult to
+	// revert later on.
+	//
+	// For further discussions on the topic, see:
+	// https://github.com/golang/go/issues/49580
+	return a.Stat(path)
 }
 
 // Stat implements the same method as documented on sys.FS
-func (a *adapter) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
+func (a *AdaptFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
 	f, errno := a.OpenFile(path, experimentalsys.O_RDONLY, 0)
 	if errno != 0 {
 		return sys.Stat_t{}, errno
@@ -51,17 +46,49 @@ func (a *adapter) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
 	return f.Stat()
 }
 
-// Lstat implements the same method as documented on sys.FS
-func (a *adapter) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) {
-	// At this time, we make the assumption that sys.FS instances do not support
-	// symbolic links, therefore Lstat is the same as Stat. This is obviously
-	// not true but until sys.FS has a solid story for how to handle symlinks we
-	// are better off not making a decision that would be difficult to revert
-	// later on.
-	//
-	// For further discussions on the topic, see:
-	// https://github.com/golang/go/issues/49580
-	return a.Stat(path)
+// Readlink implements the same method as documented on sys.FS
+func (a *AdaptFS) Readlink(string) (string, experimentalsys.Errno) {
+	return "", experimentalsys.ENOSYS
+}
+
+// Mkdir implements the same method as documented on sys.FS
+func (a *AdaptFS) Mkdir(string, fs.FileMode) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Chmod implements the same method as documented on sys.FS
+func (a *AdaptFS) Chmod(string, fs.FileMode) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Rename implements the same method as documented on sys.FS
+func (a *AdaptFS) Rename(string, string) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Rmdir implements the same method as documented on sys.FS
+func (a *AdaptFS) Rmdir(string) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Link implements the same method as documented on sys.FS
+func (a *AdaptFS) Link(string, string) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Symlink implements the same method as documented on sys.FS
+func (a *AdaptFS) Symlink(string, string) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Unlink implements the same method as documented on sys.FS
+func (a *AdaptFS) Unlink(string) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
+}
+
+// Utimens implements the same method as documented on sys.FS
+func (a *AdaptFS) Utimens(string, int64, int64) experimentalsys.Errno {
+	return experimentalsys.ENOSYS
 }
 
 func cleanPath(name string) string {
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 3fc13d8e..05d5b647 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -9,7 +9,7 @@ import (
 	"github.com/tetratelabs/wazero/sys"
 )
 
-func NewDirFS(dir string) experimentalsys.FS {
+func DirFS(dir string) experimentalsys.FS {
 	return &dirFS{
 		dir:        dir,
 		cleanedDir: ensureTrailingPathSeparator(dir),
@@ -23,8 +23,11 @@ func ensureTrailingPathSeparator(dir string) string {
 	return dir
 }
 
+// dirFS is not exported because the input fields must be maintained together.
+// This is likely why os.DirFS doesn't, either!
 type dirFS struct {
 	experimentalsys.UnimplementedFS
+
 	dir string
 	// cleanedDir is for easier OS-specific concatenation, as it always has
 	// a trailing path separator.
diff --git a/internal/sysfs/file.go b/internal/sysfs/file.go
index dc686014..a2258c3f 100644
--- a/internal/sysfs/file.go
+++ b/internal/sysfs/file.go
@@ -60,7 +60,7 @@ func OpenFSFile(fs fs.FS, path string, flag experimentalsys.Oflag, perm fs.FileM
 		return nil, errno
 	}
 	// Don't return an os.File because the path is not absolute. osFile needs
-	// the path to be real and certain fs.File impls are subrooted.
+	// the path to be real and certain FS.File impls are subrooted.
 	return &fsFile{fs: fs, name: path, file: f}, 0
 }
 
@@ -261,7 +261,7 @@ func (f *fsFile) Seek(offset int64, whence int) (newOffset int64, errno experime
 // Notably, this uses readdirFile or fs.ReadDirFile if available. This does not
 // return inodes on windows.
 func (f *fsFile) Readdir(n int) (dirents []experimentalsys.Dirent, errno experimentalsys.Errno) {
-	// Windows lets you Readdir after close, fs.File also may not implement
+	// Windows lets you Readdir after close, FS.File also may not implement
 	// close in a meaningful way. read our closed field to return consistent
 	// results.
 	if f.closed {
@@ -286,8 +286,8 @@ func (f *fsFile) Readdir(n int) (dirents []experimentalsys.Dirent, errno experim
 		return
 	}
 
-	// Try with fs.ReadDirFile which is available on api.FS implementations
-	// like embed:fs.
+	// Try with FS.ReadDirFile which is available on api.FS implementations
+	// like embed:FS.
 	if rdf, ok := f.file.(fs.ReadDirFile); ok {
 		entries, e := rdf.ReadDir(n)
 		if errno = adjustReaddirErr(f, f.closed, e); errno != 0 {
diff --git a/internal/sysfs/ino_windows.go b/internal/sysfs/ino_windows.go
index b880e3b0..d163b360 100644
--- a/internal/sysfs/ino_windows.go
+++ b/internal/sysfs/ino_windows.go
@@ -14,7 +14,7 @@ func inoFromFileInfo(dirPath string, info fs.FileInfo) (ino sys.Inode, errno exp
 		return v.Ino, 0
 	}
 	if dirPath == "" {
-		// This is a fs.File backed implementation which doesn't have access to
+		// This is a FS.File backed implementation which doesn't have access to
 		// the original file path.
 		return
 	}
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index a81b64c0..59e331a2 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -6,51 +6,21 @@ import (
 	experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
 )
 
-// NewReadFS is used to mask an existing sys.FS for reads. Notably, this allows
-// the CLI to do read-only mounts of directories the host user can write, but
-// doesn't want the guest wasm to. For example, Python libraries shouldn't be
-// written to at runtime by the python wasm file.
-func NewReadFS(fs experimentalsys.FS) experimentalsys.FS {
-	if _, ok := fs.(*readFS); ok {
-		return fs
-	} else if _, ok = fs.(experimentalsys.UnimplementedFS); ok {
-		return fs // unimplemented is read-only
-	}
-	return &readFS{fs}
-}
-
-type readFS struct {
+type ReadFS struct {
 	experimentalsys.FS
 }
 
 // OpenFile implements the same method as documented on sys.FS
-func (r *readFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
-	// TODO: Once the real implementation is complete, move the below to
-	// /RATIONALE.md. Doing this while the type is unstable creates
-	// documentation drift as we expect a lot of reshaping meanwhile.
-	//
-	// Callers of this function expect to either open a valid file handle, or
-	// get an error, if they can't. We want to return ENOSYS if opened for
-	// anything except reads.
-	//
-	// Instead, we could return a fake no-op file on O_WRONLY. However, this
-	// hurts observability because a later write error to that file will be on
-	// a different source code line than the root cause which is opening with
-	// an unsupported flag.
-	//
-	// The tricky part is os.RD_ONLY is typically defined as zero, so while the
-	// parameter is named flag, the part about opening read vs write isn't a
-	// typical bitflag. We can't compare against zero anyway, because even if
-	// there isn't a current flag to OR in with that, there may be in the
-	// future. What we do instead is mask the flags about read/write mode and
-	// check if they are the opposite of read or not.
+func (r *ReadFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
+	// Mask the mutually exclusive bits as they determine write mode.
 	switch flag & (experimentalsys.O_RDONLY | experimentalsys.O_WRONLY | experimentalsys.O_RDWR) {
 	case experimentalsys.O_WRONLY, experimentalsys.O_RDWR:
+		// Return the correct error if a directory was opened for write.
 		if flag&experimentalsys.O_DIRECTORY != 0 {
 			return nil, experimentalsys.EISDIR
 		}
 		return nil, experimentalsys.ENOSYS
-	default: // sys.O_RDONLY (or no flag) so we are ok!
+	default: // sys.O_RDONLY (integer zero) so we are ok!
 	}
 
 	f, errno := r.FS.OpenFile(path, flag, perm)
@@ -61,42 +31,42 @@ func (r *readFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileM
 }
 
 // Mkdir implements the same method as documented on sys.FS
-func (r *readFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno {
+func (r *ReadFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Chmod implements the same method as documented on sys.FS
-func (r *readFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno {
+func (r *ReadFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Rename implements the same method as documented on sys.FS
-func (r *readFS) Rename(from, to string) experimentalsys.Errno {
+func (r *ReadFS) Rename(from, to string) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Rmdir implements the same method as documented on sys.FS
-func (r *readFS) Rmdir(path string) experimentalsys.Errno {
+func (r *ReadFS) Rmdir(path string) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Link implements the same method as documented on sys.FS
-func (r *readFS) Link(_, _ string) experimentalsys.Errno {
+func (r *ReadFS) Link(_, _ string) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Symlink implements the same method as documented on sys.FS
-func (r *readFS) Symlink(_, _ string) experimentalsys.Errno {
+func (r *ReadFS) Symlink(_, _ string) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Unlink implements the same method as documented on sys.FS
-func (r *readFS) Unlink(path string) experimentalsys.Errno {
+func (r *ReadFS) Unlink(path string) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 
 // Utimens implements the same method as documented on sys.FS
-func (r *readFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno {
+func (r *ReadFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno {
 	return experimentalsys.EROFS
 }
 


Test patch:
diff --git a/config_test.go b/config_test.go
index c8f2419e..8b73dd7d 100644
--- a/config_test.go
+++ b/config_test.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/api"
+	experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
@@ -325,7 +326,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				config := base.WithFS(testFS)
 				return config, func(t *testing.T, sys *internalsys.Context) {
 					rootfs := sys.FS().RootFS()
-					require.Equal(t, sysfs.Adapt(testFS), rootfs)
+					require.Equal(t, &sysfs.AdaptFS{FS: testFS}, rootfs)
 				}
 			},
 		},
@@ -336,7 +337,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				config := base.WithFS(testFS).WithFS(testFS2)
 				return config, func(t *testing.T, sys *internalsys.Context) {
 					rootfs := sys.FS().RootFS()
-					require.Equal(t, sysfs.Adapt(testFS2), rootfs)
+					require.Equal(t, &sysfs.AdaptFS{FS: testFS2}, rootfs)
 				}
 			},
 		},
@@ -346,7 +347,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				config := base.WithFS(nil)
 				return config, func(t *testing.T, sys *internalsys.Context) {
 					rootfs := sys.FS().RootFS()
-					require.Equal(t, sysfs.Adapt(nil), rootfs)
+					require.Equal(t, experimentalsys.UnimplementedFS{}, rootfs)
 				}
 			},
 		},
diff --git a/experimental/sysfs/config_example_test.go b/experimental/sysfs/config_example_test.go
index 91ce29a4..6c54a449 100644
--- a/experimental/sysfs/config_example_test.go
+++ b/experimental/sysfs/config_example_test.go
@@ -10,31 +10,31 @@ import (
 
 var moduleConfig wazero.ModuleConfig
 
-// This example shows how to configure a sysfs.NewDirFS
-func ExampleNewDirFS() {
-	root := sysfs.NewDirFS(".")
+// This example shows how to adapt a fs.FS to a sys.FS
+func ExampleAdaptFS() {
+	m := fstest.MapFS{
+		"a/b.txt": &fstest.MapFile{Mode: 0o666},
+		".":       &fstest.MapFile{Mode: 0o777 | fs.ModeDir},
+	}
+	root := &sysfs.AdaptFS{FS: m}
 
 	moduleConfig = wazero.NewModuleConfig().
 		WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(root, "/"))
 }
 
-// This example shows how to configure a sysfs.NewReadFS
-func ExampleNewReadFS() {
-	root := sysfs.NewDirFS(".")
-	readOnly := sysfs.NewReadFS(root)
+// This example shows how to configure a sysfs.DirFS
+func ExampleDirFS() {
+	root := sysfs.DirFS(".")
 
 	moduleConfig = wazero.NewModuleConfig().
-		WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(readOnly, "/"))
+		WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(root, "/"))
 }
 
-// This example shows how to adapt a fs.FS as a sys.FS
-func ExampleAdapt() {
-	m := fstest.MapFS{
-		"a/b.txt": &fstest.MapFile{Mode: 0o666},
-		".":       &fstest.MapFile{Mode: 0o777 | fs.ModeDir},
-	}
-	root := sysfs.Adapt(m)
+// This example shows how to configure a sysfs.ReadFS
+func ExampleReadFS() {
+	root := sysfs.DirFS(".")
+	readOnly := &sysfs.ReadFS{FS: root}
 
 	moduleConfig = wazero.NewModuleConfig().
-		WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(root, "/"))
+		WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(readOnly, "/"))
 }
diff --git a/fsconfig_test.go b/fsconfig_test.go
index 71612009..d4b6c767 100644
--- a/fsconfig_test.go
+++ b/fsconfig_test.go
@@ -29,13 +29,13 @@ func TestFSConfig(t *testing.T) {
 		{
 			name:               "WithFSMount",
 			input:              base.WithFSMount(testFS, "/"),
-			expectedFS:         []sys.FS{sysfs.Adapt(testFS)},
+			expectedFS:         []sys.FS{&sysfs.AdaptFS{FS: testFS}},
 			expectedGuestPaths: []string{"/"},
 		},
 		{
 			name:               "WithFSMount overwrites",
 			input:              base.WithFSMount(testFS, "/").WithFSMount(testFS2, "/"),
-			expectedFS:         []sys.FS{sysfs.Adapt(testFS2)},
+			expectedFS:         []sys.FS{&sysfs.AdaptFS{FS: testFS2}},
 			expectedGuestPaths: []string{"/"},
 		},
 		{
@@ -45,13 +45,13 @@ func TestFSConfig(t *testing.T) {
 		{
 			name:               "WithDirMount overwrites",
 			input:              base.WithFSMount(testFS, "/").WithDirMount(".", "/"),
-			expectedFS:         []sys.FS{sysfs.NewDirFS(".")},
+			expectedFS:         []sys.FS{sysfs.DirFS(".")},
 			expectedGuestPaths: []string{"/"},
 		},
 		{
 			name:               "multiple",
 			input:              base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"),
-			expectedFS:         []sys.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
+			expectedFS:         []sys.FS{&sysfs.ReadFS{FS: sysfs.DirFS(".")}, sysfs.DirFS("/tmp")},
 			expectedGuestPaths: []string{"/", "/tmp"},
 		},
 	}
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index ee5ad2d7..e8883b21 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -242,9 +242,9 @@ func Test_fdFdstatGet(t *testing.T) {
 	// replace stdin with a fake TTY file.
 	// TODO: Make this easier once we have in-memory sys.File
 	stdin, _ := fsc.LookupFile(sys.FdStdin)
-	stdinFile, errno := sysfs.Adapt(&gofstest.MapFS{"stdin": &gofstest.MapFile{
+	stdinFile, errno := (&sysfs.AdaptFS{FS: &gofstest.MapFS{"stdin": &gofstest.MapFile{
 		Mode: fs.ModeDevice | fs.ModeCharDevice | 0o600,
-	}}).OpenFile("stdin", 0, 0)
+	}}}).OpenFile("stdin", 0, 0)
 	require.EqualErrno(t, 0, errno)
 
 	stdin.File = stdinFile
@@ -3648,8 +3648,8 @@ func Test_pathLink(t *testing.T) {
 
 func Test_pathOpen(t *testing.T) {
 	dir := t.TempDir() // open before loop to ensure no locking problems.
-	writeFS := sysfs.NewDirFS(dir)
-	readFS := sysfs.NewReadFS(writeFS)
+	writeFS := sysfs.DirFS(dir)
+	readFS := &sysfs.ReadFS{FS: writeFS}
 
 	fileName := "file"
 	fileContents := []byte("012")
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 33031b90..1719dae3 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -24,7 +24,7 @@ func TestNewFSContext(t *testing.T) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
-	dirfs := sysfs.NewDirFS(".")
+	dirfs := sysfs.DirFS(".")
 
 	// Test various usual configuration for the file system.
 	tests := []struct {
@@ -33,21 +33,21 @@ func TestNewFSContext(t *testing.T) {
 	}{
 		{
 			name: "embed.FS",
-			fs:   sysfs.Adapt(embedFS),
+			fs:   &sysfs.AdaptFS{FS: embedFS},
 		},
 		{
-			name: "NewDirFS",
+			name: "DirFS",
 			// Don't use "testdata" because it may not be present in
 			// cross-architecture (a.k.a. scratch) build containers.
 			fs: dirfs,
 		},
 		{
-			name: "NewReadFS",
-			fs:   sysfs.NewReadFS(dirfs),
+			name: "ReadFS",
+			fs:   &sysfs.ReadFS{FS: dirfs},
 		},
 		{
 			name: "fstest.MapFS",
-			fs:   sysfs.Adapt(gofstest.MapFS{}),
+			fs:   &sysfs.AdaptFS{FS: gofstest.MapFS{}},
 		},
 	}
 
@@ -96,7 +96,7 @@ func TestNewFSContext(t *testing.T) {
 func TestFSContext_CloseFile(t *testing.T) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
-	testFS := sysfs.Adapt(embedFS)
+	testFS := &sysfs.AdaptFS{FS: embedFS}
 
 	c := Context{}
 	err = c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
@@ -154,7 +154,7 @@ func TestFSContext_noPreopens(t *testing.T) {
 }
 
 func TestContext_Close(t *testing.T) {
-	testFS := sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})
+	testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{}}}
 
 	c := Context{}
 	err := c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
@@ -181,7 +181,7 @@ func TestContext_Close(t *testing.T) {
 func TestContext_Close_Error(t *testing.T) {
 	file := &testfs.File{CloseErr: errors.New("error closing")}
 
-	testFS := sysfs.Adapt(testfs.FS{"foo": file})
+	testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": file}}
 
 	c := Context{}
 	err := c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
@@ -201,7 +201,7 @@ func TestContext_Close_Error(t *testing.T) {
 
 func TestFSContext_Renumber(t *testing.T) {
 	tmpDir := t.TempDir()
-	dirFS := sysfs.NewDirFS(tmpDir)
+	dirFS := sysfs.DirFS(tmpDir)
 
 	const dirName = "dir"
 	errno := dirFS.Mkdir(dirName, 0o700)
@@ -252,7 +252,7 @@ func TestFSContext_Renumber(t *testing.T) {
 
 func TestDirentCache_Read(t *testing.T) {
 	c := Context{}
-	err := c.InitFSContext(nil, nil, nil, []sys.FS{sysfs.Adapt(fstest.FS)}, []string{"/"}, nil)
+	err := c.InitFSContext(nil, nil, nil, []sys.FS{&sysfs.AdaptFS{FS: fstest.FS}}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 	defer fsc.Close()
@@ -430,7 +430,7 @@ func TestDirentCache_ReadNewFile(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	c := Context{}
-	err := c.InitFSContext(nil, nil, nil, []sys.FS{sysfs.NewDirFS(tmpDir)}, []string{"/"}, nil)
+	err := c.InitFSContext(nil, nil, nil, []sys.FS{sysfs.DirFS(tmpDir)}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 	defer fsc.Close()
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index ae6270a1..5949d27b 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -20,7 +20,7 @@ func TestContext_WalltimeNanos(t *testing.T) {
 }
 
 func TestDefaultSysContext(t *testing.T) {
-	testFS := sysfs.Adapt(fstest.FS)
+	testFS := &sysfs.AdaptFS{FS: fstest.FS}
 
 	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, []experimentalsys.FS{testFS}, []string{"/"}, nil)
 	require.NoError(t, err)
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index fe3d4916..75b4f3b1 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -17,29 +17,23 @@ import (
 	"github.com/tetratelabs/wazero/sys"
 )
 
-func TestAdapt_nil(t *testing.T) {
-	testFS := Adapt(nil)
-	_, ok := testFS.(experimentalsys.UnimplementedFS)
-	require.True(t, ok)
-}
-
-func TestAdapt_MkDir(t *testing.T) {
-	testFS := Adapt(os.DirFS(t.TempDir()))
+func TestAdaptFS_MkDir(t *testing.T) {
+	testFS := &AdaptFS{FS: os.DirFS(t.TempDir())}
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_Chmod(t *testing.T) {
-	testFS := Adapt(os.DirFS(t.TempDir()))
+func TestAdaptFS_Chmod(t *testing.T) {
+	testFS := &AdaptFS{FS: os.DirFS(t.TempDir())}
 
 	err := testFS.Chmod("chmod", fs.ModeDir)
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_Rename(t *testing.T) {
+func TestAdaptFS_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 
 	file1 := "file1"
 	file1Path := joinPath(tmpDir, file1)
@@ -57,9 +51,9 @@ func TestAdapt_Rename(t *testing.T) {
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_Rmdir(t *testing.T) {
+func TestAdaptFS_Rmdir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 
 	path := "rmdir"
 	realPath := joinPath(tmpDir, path)
@@ -69,9 +63,9 @@ func TestAdapt_Rmdir(t *testing.T) {
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_Unlink(t *testing.T) {
+func TestAdaptFS_Unlink(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 
 	path := "unlink"
 	realPath := joinPath(tmpDir, path)
@@ -81,9 +75,9 @@ func TestAdapt_Unlink(t *testing.T) {
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_UtimesNano(t *testing.T) {
+func TestAdaptFS_UtimesNano(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 
 	path := "utimes"
 	realPath := joinPath(tmpDir, path)
@@ -93,13 +87,13 @@ func TestAdapt_UtimesNano(t *testing.T) {
 	require.EqualErrno(t, experimentalsys.ENOSYS, err)
 }
 
-func TestAdapt_Open_Read(t *testing.T) {
-	// Create a subdirectory, so we can test reads outside the sys.FS root.
+func TestAdaptFS_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
+	// Create a subdirectory, so we can test reads outside the sys.FS root.
 	tmpDir = joinPath(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-	testFS := Adapt(os.DirFS(tmpDir))
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 
 	// We can't correct operating system portability issues with os.DirFS on
 	// windows. Use syscall.DirFS instead!
@@ -113,10 +107,10 @@ func TestAdapt_Open_Read(t *testing.T) {
 	})
 }
 
-func TestAdapt_Lstat(t *testing.T) {
+func TestAdaptFS_Lstat(t *testing.T) {
 	tmpDir := t.TempDir()
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-	testFS := Adapt(os.DirFS(tmpDir))
 
 	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
 		fullPath := joinPath(tmpDir, path)
@@ -128,15 +122,15 @@ func TestAdapt_Lstat(t *testing.T) {
 	}
 }
 
-func TestAdapt_Stat(t *testing.T) {
+func TestAdaptFS_Stat(t *testing.T) {
 	tmpDir := t.TempDir()
+	testFS := &AdaptFS{FS: os.DirFS(tmpDir)}
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := Adapt(os.DirFS(tmpDir))
 	testStat(t, testFS)
 }
 
-// hackFS cheats the api.FS contract by opening for write (sys.O_RDWR).
+// hackFS cheats the fs.FS contract by opening for write (sys.O_RDWR).
 //
 // Until we have an alternate public interface for filesystems, some users will
 // rely on this. Via testing, we ensure we don't accidentally break them.
@@ -156,11 +150,11 @@ func (dir hackFS) Open(name string) (fs.File, error) {
 	}
 }
 
-// TestAdapt_HackedWrites ensures we allow writes even if they violate the
+// TestAdaptFS_HackedWrites ensures we allow writes even if they violate the
 // api.FS contract.
-func TestAdapt_HackedWrites(t *testing.T) {
+func TestAdaptFS_HackedWrites(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := Adapt(hackFS(tmpDir))
+	testFS := &AdaptFS{FS: hackFS(tmpDir)}
 
 	testOpen_O_RDWR(t, tmpDir, testFS)
 }
@@ -237,7 +231,7 @@ func (i *sysFileInfo) Sys() any {
 // to zero).
 //
 // A fs.File implementing this should be functionally equivalent to an os.File,
-// even if both are less ideal than using NewDirFS directly, especially on
+// even if both are less ideal than using DirFS directly, especially on
 // Windows.
 //
 // For example, on Windows, we cannot reliably read the inode for a
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 08b7a782..d2826898 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -16,8 +16,8 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func TestNewDirFS(t *testing.T) {
-	testFS := NewDirFS(".")
+func TestDirFS(t *testing.T) {
+	testFS := DirFS(".")
 
 	// Guest can look up /
 	f, errno := testFS.OpenFile("/", sys.O_RDONLY, 0)
@@ -25,14 +25,14 @@ func TestNewDirFS(t *testing.T) {
 	require.EqualErrno(t, 0, f.Close())
 
 	t.Run("host path not found", func(t *testing.T) {
-		testFS := NewDirFS("a")
+		testFS := DirFS("a")
 		_, errno = testFS.OpenFile(".", sys.O_RDONLY, 0)
 		require.EqualErrno(t, sys.ENOENT, errno)
 	})
 	t.Run("host path not a directory", func(t *testing.T) {
 		arg0 := os.Args[0] // should be safe in scratch tests which don't have the source mounted.
 
-		testFS := NewDirFS(arg0)
+		testFS := DirFS(arg0)
 		d, errno := testFS.OpenFile(".", sys.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 		_, errno = d.Readdir(-1)
@@ -41,13 +41,13 @@ func TestNewDirFS(t *testing.T) {
 }
 
 func TestDirFS_join(t *testing.T) {
-	testFS := NewDirFS("/").(*dirFS)
+	testFS := DirFS("/").(*dirFS)
 	require.Equal(t, "/", testFS.join(""))
 	require.Equal(t, "/", testFS.join("."))
 	require.Equal(t, "/", testFS.join("/"))
 	require.Equal(t, "/tmp", testFS.join("tmp"))
 
-	testFS = NewDirFS(".").(*dirFS)
+	testFS = DirFS(".").(*dirFS)
 	require.Equal(t, ".", testFS.join(""))
 	require.Equal(t, ".", testFS.join("."))
 	require.Equal(t, ".", testFS.join("/"))
@@ -55,7 +55,7 @@ func TestDirFS_join(t *testing.T) {
 }
 
 func TestDirFS_String(t *testing.T) {
-	testFS := NewDirFS(".")
+	testFS := DirFS(".")
 
 	// String has the name of the path entered
 	require.Equal(t, ".", testFS.(fmt.Stringer).String())
@@ -65,7 +65,7 @@ func TestDirFS_Lstat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
 		require.EqualErrno(t, 0, testFS.Symlink(path, path+"-link"))
 	}
@@ -75,7 +75,7 @@ func TestDirFS_Lstat(t *testing.T) {
 
 func TestDirFS_MkDir(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 
 	name := "mkdir"
 	realPath := path.Join(tmpDir, name)
@@ -152,7 +152,7 @@ func requireMode(t *testing.T, testFS sys.FS, path string, mode fs.FileMode) {
 func TestDirFS_Rename(t *testing.T) {
 	t.Run("from doesn't exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		file1 := "file1"
 		file1Path := path.Join(tmpDir, file1)
@@ -164,7 +164,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		file1 := "file1"
 		file1Path := path.Join(tmpDir, file1)
@@ -187,7 +187,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir1 := "dir1"
 		dir1Path := path.Join(tmpDir, dir1)
@@ -208,7 +208,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir1 := "dir1"
 		dir1Path := path.Join(tmpDir, dir1)
@@ -227,7 +227,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		file1 := "file1"
 		file1Path := path.Join(tmpDir, file1)
@@ -246,7 +246,7 @@ func TestDirFS_Rename(t *testing.T) {
 	// Similar to https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs/test.zig#L567-L582
 	t.Run("dir to empty dir should be fine", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir1 := "dir1"
 		dir1Path := path.Join(tmpDir, dir1)
@@ -279,7 +279,7 @@ func TestDirFS_Rename(t *testing.T) {
 	// Similar to https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs/test.zig#L584-L604
 	t.Run("dir to non empty dir should be EXIST", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir1 := "dir1"
 		dir1Path := path.Join(tmpDir, dir1)
@@ -306,7 +306,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 	t.Run("file to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		file1 := "file1"
 		file1Path := path.Join(tmpDir, file1)
@@ -334,7 +334,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir1 := "dir1"
 		dir1Path := path.Join(tmpDir, dir1)
@@ -349,7 +349,7 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		file1 := "file1"
 		file1Path := path.Join(tmpDir, file1)
@@ -369,7 +369,7 @@ func TestDirFS_Rename(t *testing.T) {
 func TestDirFS_Rmdir(t *testing.T) {
 	t.Run("doesn't exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 
@@ -379,7 +379,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("dir not empty", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 		realPath := path.Join(tmpDir, name)
@@ -396,7 +396,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("dir previously not empty", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 		realPath := path.Join(tmpDir, name)
@@ -414,7 +414,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("dir empty", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 		realPath := path.Join(tmpDir, name)
@@ -426,7 +426,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("dir empty while opening", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 		realPath := path.Join(tmpDir, name)
@@ -443,7 +443,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("not directory", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "rmdir"
 		realPath := path.Join(tmpDir, name)
@@ -460,7 +460,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 func TestDirFS_Unlink(t *testing.T) {
 	t.Run("doesn't exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 		name := "unlink"
 
 		err := testFS.Unlink(name)
@@ -469,7 +469,7 @@ func TestDirFS_Unlink(t *testing.T) {
 
 	t.Run("target: dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		dir := "dir"
 		realPath := path.Join(tmpDir, dir)
@@ -484,7 +484,7 @@ func TestDirFS_Unlink(t *testing.T) {
 
 	t.Run("target: symlink to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		// Create link target dir.
 		subDirName := "subdir"
@@ -502,7 +502,7 @@ func TestDirFS_Unlink(t *testing.T) {
 
 	t.Run("file exists", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "unlink"
 		realPath := path.Join(tmpDir, name)
@@ -518,7 +518,7 @@ func TestDirFS_Unlink(t *testing.T) {
 
 func TestDirFS_Utimesns(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 
 	file := "file"
 	err := os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700)
@@ -574,7 +574,7 @@ func TestDirFS_Utimesns(t *testing.T) {
 
 			t.Run(name, func(t *testing.T) {
 				tmpDir := t.TempDir()
-				testFS := NewDirFS(tmpDir)
+				testFS := DirFS(tmpDir)
 
 				file := path.Join(tmpDir, "file")
 				errno := os.WriteFile(file, []byte{}, 0o700)
@@ -638,7 +638,7 @@ func TestDirFS_OpenFile(t *testing.T) {
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 
 	testOpen_Read(t, testFS, statSetsIno(), true)
 
@@ -656,7 +656,7 @@ func TestDirFS_Stat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 	testStat(t, testFS)
 
 	// from os.TestDirFSPathsValid
@@ -673,7 +673,7 @@ func TestDirFS_Stat(t *testing.T) {
 
 func TestDirFS_Readdir(t *testing.T) {
 	root := t.TempDir()
-	testFS := NewDirFS(root)
+	testFS := DirFS(root)
 
 	const readDirTarget = "dir"
 	errno := testFS.Mkdir(readDirTarget, 0o700)
@@ -728,7 +728,7 @@ func TestDirFS_Link(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 
 	require.EqualErrno(t, testFS.Link("cat", ""), sys.ENOENT)
 	require.EqualErrno(t, testFS.Link("sub/test.txt", "sub/test.txt"), sys.EEXIST)
@@ -745,7 +745,7 @@ func TestDirFS_Symlink(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 
 	require.EqualErrno(t, sys.EEXIST, testFS.Symlink("sub/test.txt", "sub/test.txt"))
 	// Non-existing old name is allowed.
@@ -767,6 +767,6 @@ func TestDirFS_Readlink(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	testFS := NewDirFS(tmpDir)
+	testFS := DirFS(tmpDir)
 	testReadlink(t, testFS, testFS)
 }
diff --git a/internal/sysfs/file_test.go b/internal/sysfs/file_test.go
index d5009330..00501154 100644
--- a/internal/sysfs/file_test.go
+++ b/internal/sysfs/file_test.go
@@ -832,7 +832,7 @@ func testSync_NoError(t *testing.T, sync func(experimentalsys.File) experimental
 		f    experimentalsys.File
 	}{
 		{name: "UnimplementedFile", f: experimentalsys.UnimplementedFile{}},
-		{name: "File of read-only fs.File", f: ro},
+		{name: "File of read-only FS.File", f: ro},
 		{name: "File of os.File", f: rw},
 	}
 
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 25a47fa3..a8e21f1d 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -14,16 +14,13 @@ import (
 func TestNewReadFS(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	// Doesn't double-wrap file systems that are already read-only
-	require.Equal(t, sys.UnimplementedFS{}, NewReadFS(sys.UnimplementedFS{}))
-
 	// Wraps a sys.FS because it allows access to Write
-	adapted := Adapt(os.DirFS(tmpDir))
-	require.NotEqual(t, adapted, NewReadFS(adapted))
+	adapted := &AdaptFS{FS: os.DirFS(tmpDir)}
+	require.NotEqual(t, adapted, &ReadFS{FS: adapted})
 
 	// Wraps a writeable file system
-	writeable := NewDirFS(tmpDir)
-	readFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	readFS := &ReadFS{FS: writeable}
 	require.NotEqual(t, writeable, readFS)
 }
 
@@ -31,27 +28,27 @@ func TestReadFS_Lstat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	writeable := NewDirFS(tmpDir)
+	writeable := DirFS(tmpDir)
 	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
 		require.EqualErrno(t, 0, writeable.Symlink(path, path+"-link"))
 	}
 
-	testFS := NewReadFS(writeable)
+	testFS := &ReadFS{FS: writeable}
 
 	testLstat(t, testFS)
 }
 
 func TestReadFS_MkDir(t *testing.T) {
-	writeable := NewDirFS(t.TempDir())
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(t.TempDir())
+	testFS := &ReadFS{FS: writeable}
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.EqualErrno(t, sys.EROFS, err)
 }
 
 func TestReadFS_Chmod(t *testing.T) {
-	writeable := NewDirFS(t.TempDir())
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(t.TempDir())
+	testFS := &ReadFS{FS: writeable}
 
 	err := testFS.Chmod("chmod", fs.ModeDir)
 	require.EqualErrno(t, sys.EROFS, err)
@@ -59,8 +56,8 @@ func TestReadFS_Chmod(t *testing.T) {
 
 func TestReadFS_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 
 	file1 := "file1"
 	file1Path := joinPath(tmpDir, file1)
@@ -80,8 +77,8 @@ func TestReadFS_Rename(t *testing.T) {
 
 func TestReadFS_Rmdir(t *testing.T) {
 	tmpDir := t.TempDir()
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 
 	path := "rmdir"
 	realPath := joinPath(tmpDir, path)
@@ -93,8 +90,8 @@ func TestReadFS_Rmdir(t *testing.T) {
 
 func TestReadFS_Unlink(t *testing.T) {
 	tmpDir := t.TempDir()
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 
 	path := "unlink"
 	realPath := joinPath(tmpDir, path)
@@ -106,8 +103,8 @@ func TestReadFS_Unlink(t *testing.T) {
 
 func TestReadFS_UtimesNano(t *testing.T) {
 	tmpDir := t.TempDir()
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 
 	path := "utimes"
 	realPath := joinPath(tmpDir, path)
@@ -129,7 +126,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 		{
 			name: "DirFS",
 			fs: func(tmpDir string) sys.FS {
-				return NewDirFS(tmpDir)
+				return DirFS(tmpDir)
 			},
 			expectFileIno: true,
 			expectDirIno:  true,
@@ -137,7 +134,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 		{
 			name: "fstest.MapFS",
 			fs: func(tmpDir string) sys.FS {
-				return Adapt(fstest.FS)
+				return &AdaptFS{FS: fstest.FS}
 			},
 			expectFileIno: false,
 			expectDirIno:  false,
@@ -145,7 +142,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 		{
 			name: "os.DirFS",
 			fs: func(tmpDir string) sys.FS {
-				return Adapt(os.DirFS(tmpDir))
+				return &AdaptFS{FS: os.DirFS(tmpDir)}
 			},
 			expectFileIno: statSetsIno(),
 			expectDirIno:  runtime.GOOS != "windows",
@@ -153,7 +150,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 		{
 			name: "mask(os.DirFS)",
 			fs: func(tmpDir string) sys.FS {
-				return Adapt(&MaskOsFS{Fs: os.DirFS(tmpDir)})
+				return &AdaptFS{FS: &MaskOsFS{Fs: os.DirFS(tmpDir)}}
 			},
 			expectFileIno: statSetsIno(),
 			expectDirIno:  runtime.GOOS != "windows",
@@ -161,7 +158,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 		{
 			name: "mask(os.DirFS) ZeroIno",
 			fs: func(tmpDir string) sys.FS {
-				return Adapt(&MaskOsFS{Fs: os.DirFS(tmpDir), ZeroIno: true})
+				return &AdaptFS{FS: &MaskOsFS{Fs: os.DirFS(tmpDir), ZeroIno: true}}
 			},
 			expectFileIno: false,
 			expectDirIno:  false,
@@ -178,7 +175,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 			tmpDir := t.TempDir()
 			require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-			testOpen_Read(t, NewReadFS(tc.fs(tmpDir)), tc.expectFileIno, tc.expectDirIno)
+			testOpen_Read(t, &ReadFS{FS: tc.fs(tmpDir)}, tc.expectFileIno, tc.expectDirIno)
 		})
 	}
 }
@@ -187,8 +184,8 @@ func TestReadFS_Stat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 	testStat(t, testFS)
 }
 
@@ -196,7 +193,7 @@ func TestReadFS_Readlink(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	writeable := DirFS(tmpDir)
+	testFS := &ReadFS{FS: writeable}
 	testReadlink(t, testFS, writeable)
 }
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 0411b8dc..75526e56 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -70,7 +70,7 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS experimentalsys.FS) {
 
 	t.Run("O_TRUNC", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := NewDirFS(tmpDir)
+		testFS := DirFS(tmpDir)
 
 		name := "truncate"
 		realPath := path.Join(tmpDir, name)
diff --git a/internal/wasm/module_instance_test.go b/internal/wasm/module_instance_test.go
index 7be77854..4fdb4cc0 100644
--- a/internal/wasm/module_instance_test.go
+++ b/internal/wasm/module_instance_test.go
@@ -112,7 +112,7 @@ func TestModuleInstance_Close(t *testing.T) {
 	}
 
 	t.Run("calls Context.Close()", func(t *testing.T) {
-		testFS := sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})
+		testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{}}}
 		sysCtx := internalsys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
@@ -147,7 +147,7 @@ func TestModuleInstance_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 := sysfs.Adapt(testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}})
+		testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}}}
 		sysCtx := internalsys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
@@ -217,7 +217,7 @@ func TestModuleInstance_CallDynamic(t *testing.T) {
 	}
 
 	t.Run("calls Context.Close()", func(t *testing.T) {
-		testFS := sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})
+		testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{}}}
 		sysCtx := internalsys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
@@ -245,7 +245,7 @@ func TestModuleInstance_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 := sysfs.Adapt(testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}})
+		testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}}}
 		sysCtx := internalsys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()