Repository: tetratelabs/wazero
Pull request #1477 (2023-05-16 08:14:34)

extracts FS interfaces into fsapi package and consolidates impls
Before, we had FS impl in the sysfs package and most of File in the platform package. This moves the base types into a new fsapi package, to help with dependency cycles.

This also consolidates file logic from the platform package into the sysfs package. Further refactorings will be simpler after this phase.

codefromthecrypt: @evacchi can you do me a favor and take a look and try to resolve the `make check` thing? I am not able to reproduce it and need to go board ;)
evacchi: it seems reproducible on a Linux host

Patch:
diff --git a/config.go b/config.go
index 5172967f..ea5a9e7e 100644
--- a/config.go
+++ b/config.go
@@ -13,10 +13,10 @@ import (
 	"github.com/tetratelabs/wazero/internal/engine/compiler"
 	"github.com/tetratelabs/wazero/internal/engine/interpreter"
 	"github.com/tetratelabs/wazero/internal/filecache"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/internalapi"
 	"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"
 )
@@ -835,7 +835,7 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) {
 		environ = append(environ, result)
 	}
 
-	var fs sysfs.FS
+	var fs fsapi.FS
 	if f, ok := c.fsConfig.(*fsConfig); ok {
 		if fs, err = f.toFS(); err != nil {
 			return
diff --git a/fsconfig.go b/fsconfig.go
index deeb7d7d..099a7917 100644
--- a/fsconfig.go
+++ b/fsconfig.go
@@ -3,6 +3,7 @@ package wazero
 import (
 	"io/fs"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 )
 
@@ -122,7 +123,7 @@ type FSConfig interface {
 
 type fsConfig struct {
 	// fs are the currently configured filesystems.
-	fs []sysfs.FS
+	fs []fsapi.FS
 	// guestPaths are the user-supplied names of the filesystems, retained for
 	// error messages and fmt.Stringer.
 	guestPaths []string
@@ -139,7 +140,7 @@ func NewFSConfig() FSConfig {
 // 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 = make([]fsapi.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...)
@@ -165,7 +166,7 @@ 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 {
+func (c *fsConfig) withMount(fs fsapi.FS, guestPath string) FSConfig {
 	cleaned := sysfs.StripPrefixesAndTrailingSlash(guestPath)
 	ret := c.clone()
 	if i, ok := ret.guestPathToFS[cleaned]; ok {
@@ -179,6 +180,6 @@ func (c *fsConfig) withMount(fs sysfs.FS, guestPath string) FSConfig {
 	return ret
 }
 
-func (c *fsConfig) toFS() (sysfs.FS, error) {
+func (c *fsConfig) toFS() (fsapi.FS, error) {
 	return sysfs.NewRootFS(c.fs, c.guestPaths)
 }
diff --git a/imports/assemblyscript/assemblyscript.go b/imports/assemblyscript/assemblyscript.go
index 7c96bdc8..4cb1b3db 100644
--- a/imports/assemblyscript/assemblyscript.go
+++ b/imports/assemblyscript/assemblyscript.go
@@ -36,7 +36,7 @@ import (
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
 	. "github.com/tetratelabs/wazero/internal/assemblyscript"
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/sys"
@@ -225,7 +225,7 @@ var traceStderr = traceStdout.WithGoModuleFunc(func(_ context.Context, mod api.M
 //	(import "env" "trace" (func $~lib/builtins/trace (param i32 i32 f64 f64 f64 f64 f64)))
 //
 // See https://github.com/AssemblyScript/assemblyscript/blob/fa14b3b03bd4607efa52aaff3132bea0c03a7989/std/assembly/wasi/index.ts#L61
-func traceTo(mod api.Module, params []uint64, file platform.File) {
+func traceTo(mod api.Module, params []uint64, file fsapi.File) {
 	message := uint32(params[0])
 	nArgs := uint32(params[1])
 	arg0 := api.DecodeF64(params[2])
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 8520d663..f4b40594 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -11,7 +11,7 @@ import (
 	"unsafe"
 
 	"github.com/tetratelabs/wazero/api"
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/wasip1"
@@ -195,7 +195,7 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) syscall.E
 	}
 
 	var fdflags uint16
-	var st platform.Stat_t
+	var st fsapi.Stat_t
 	var errno syscall.Errno
 	if f, ok := fsc.LookupFile(fd); !ok {
 		return syscall.EBADF
@@ -424,7 +424,7 @@ func getWasiFiletype(fm fs.FileMode) uint8 {
 	}
 }
 
-func writeFilestat(buf []byte, st *platform.Stat_t) (errno syscall.Errno) {
+func writeFilestat(buf []byte, st *fsapi.Stat_t) (errno syscall.Errno) {
 	le.PutUint64(buf, st.Dev)
 	le.PutUint64(buf[8:], st.Ino)
 	le.PutUint64(buf[16:], uint64(getWasiFiletype(st.Mode)))
@@ -507,9 +507,9 @@ func toTimes(atim, mtime int64, fstFlags uint16) (times [2]syscall.Timespec, err
 	} else if set {
 		times[0] = syscall.NsecToTimespec(atim)
 	} else if now {
-		times[0].Nsec = platform.UTIME_NOW
+		times[0].Nsec = sysfs.UTIME_NOW
 	} else {
-		times[0].Nsec = platform.UTIME_OMIT
+		times[0].Nsec = sysfs.UTIME_OMIT
 	}
 
 	// coerce mtim into a timespec
@@ -519,9 +519,9 @@ func toTimes(atim, mtime int64, fstFlags uint16) (times [2]syscall.Timespec, err
 	} else if set {
 		times[1] = syscall.NsecToTimespec(mtime)
 	} else if now {
-		times[1].Nsec = platform.UTIME_NOW
+		times[1].Nsec = sysfs.UTIME_NOW
 	} else {
-		times[1].Nsec = platform.UTIME_OMIT
+		times[1].Nsec = sysfs.UTIME_OMIT
 	}
 	return
 }
@@ -722,11 +722,11 @@ var fdRead = newHostFunc(
 
 // preader tracks an offset across multiple reads.
 type preader struct {
-	f      platform.File
+	f      fsapi.File
 	offset int64
 }
 
-// Read implements the same function as documented on platform.File.
+// Read implements the same function as documented on internalapi.File.
 func (w *preader) Read(buf []byte) (n int, errno syscall.Errno) {
 	if len(buf) == 0 {
 		return 0, 0 // less overhead on zero-length reads.
@@ -925,7 +925,7 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 
 // dotDirents returns "." and "..", where "." because wasi-testsuite does inode
 // validation.
-func dotDirents(f *sys.FileEntry) ([]platform.Dirent, syscall.Errno) {
+func dotDirents(f *sys.FileEntry) ([]fsapi.Dirent, syscall.Errno) {
 	if isDir, errno := f.File.IsDir(); errno != 0 {
 		return nil, errno
 	} else if !isDir {
@@ -943,7 +943,7 @@ func dotDirents(f *sys.FileEntry) ([]platform.Dirent, syscall.Errno) {
 			dotDotIno = st.Ino
 		}
 	}
-	return []platform.Dirent{
+	return []fsapi.Dirent{
 		{Name: ".", Ino: dotIno, Type: fs.ModeDir},
 		{Name: "..", Ino: dotDotIno, Type: fs.ModeDir},
 	}, 0
@@ -952,7 +952,7 @@ func dotDirents(f *sys.FileEntry) ([]platform.Dirent, syscall.Errno) {
 const largestDirent = int64(math.MaxUint32 - wasip1.DirentSize)
 
 // lastDirents is broken out from fdReaddirFn for testability.
-func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []platform.Dirent, errno syscall.Errno) {
+func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	if cookie < 0 {
 		errno = syscall.EINVAL // invalid as we will never send a negative cookie.
 		return
@@ -995,7 +995,7 @@ func lastDirents(dir *sys.ReadDir, cookie int64) (dirents []platform.Dirent, err
 //
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_readdir
 // See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/cloudlibc/src/libc/dirent/readdir.c#L44
-func maxDirents(dirents []platform.Dirent, bufLen uint32) (bufused, direntCount uint32, writeTruncatedEntry bool) {
+func maxDirents(dirents []fsapi.Dirent, bufLen uint32) (bufused, direntCount uint32, writeTruncatedEntry bool) {
 	lenRemaining := bufLen
 	for i := range dirents {
 		d := dirents[i]
@@ -1053,7 +1053,7 @@ func maxDirents(dirents []platform.Dirent, bufLen uint32) (bufused, direntCount
 // based on maxDirents.	truncatedEntryLen means write one past entryCount,
 // without its name. See maxDirents for why
 func writeDirents(
-	dirents []platform.Dirent,
+	dirents []fsapi.Dirent,
 	direntCount uint32,
 	writeTruncatedEntry bool,
 	buf []byte,
@@ -1301,11 +1301,11 @@ func fdWriteFn(_ context.Context, mod api.Module, params []uint64) syscall.Errno
 
 // pwriter tracks an offset across multiple writes.
 type pwriter struct {
-	f      platform.File
+	f      fsapi.File
 	offset int64
 }
 
-// Write implements the same function as documented on platform.File.
+// Write implements the same function as documented on internalapi.File.
 func (w *pwriter) Write(buf []byte) (n int, errno syscall.Errno) {
 	if len(buf) == 0 {
 		return 0, 0 // less overhead on zero-length writes.
@@ -1461,7 +1461,7 @@ func pathFilestatGetFn(_ context.Context, mod api.Module, params []uint64) sysca
 	}
 
 	// Stat the file without allocating a file descriptor.
-	var st platform.Stat_t
+	var st fsapi.Stat_t
 
 	if (flags & wasip1.LOOKUP_SYMLINK_FOLLOW) == 0 {
 		st, errno = preopen.Lstat(pathName)
@@ -1645,7 +1645,7 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) syscall.Errn
 	}
 
 	fileOpenFlags := openFlags(dirflags, oflags, fdflags, rights)
-	isDir := fileOpenFlags&platform.O_DIRECTORY != 0
+	isDir := fileOpenFlags&fsapi.O_DIRECTORY != 0
 
 	if isDir && oflags&wasip1.O_CREAT != 0 {
 		return syscall.EINVAL // use pathCreateDirectory!
@@ -1692,7 +1692,7 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) syscall.Errn
 //
 // See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/sources/at_fdcwd.c
 // See https://linux.die.net/man/2/openat
-func atPath(fsc *sys.FSContext, mem api.Memory, fd int32, p, pathLen uint32) (sysfs.FS, string, syscall.Errno) {
+func atPath(fsc *sys.FSContext, mem api.Memory, fd int32, p, pathLen uint32) (fsapi.FS, string, syscall.Errno) {
 	b, ok := mem.Read(p, pathLen)
 	if !ok {
 		return nil, "", syscall.EFAULT
@@ -1748,10 +1748,10 @@ func preopenPath(fsc *sys.FSContext, fd int32) (string, syscall.Errno) {
 
 func openFlags(dirflags, oflags, fdflags uint16, rights uint32) (openFlags int) {
 	if dirflags&wasip1.LOOKUP_SYMLINK_FOLLOW == 0 {
-		openFlags |= platform.O_NOFOLLOW
+		openFlags |= fsapi.O_NOFOLLOW
 	}
 	if oflags&wasip1.O_DIRECTORY != 0 {
-		openFlags |= platform.O_DIRECTORY
+		openFlags |= fsapi.O_DIRECTORY
 		return // Early return for directories as the rest of flags doesn't make sense for it.
 	} else if oflags&wasip1.O_EXCL != 0 {
 		openFlags |= syscall.O_EXCL
diff --git a/internal/fsapi/constants.go b/internal/fsapi/constants.go
new file mode 100644
index 00000000..868b9c16
--- /dev/null
+++ b/internal/fsapi/constants.go
@@ -0,0 +1,13 @@
+//go:build !windows && !js && !illumos && !solaris
+
+package fsapi
+
+import "syscall"
+
+// Simple aliases to constants in the syscall package for portability with
+// platforms which do not have them (e.g. windows)
+const (
+	O_DIRECTORY = syscall.O_DIRECTORY
+	O_NOFOLLOW  = syscall.O_NOFOLLOW
+	O_NONBLOCK  = syscall.O_NONBLOCK
+)
diff --git a/internal/fsapi/constants_js.go b/internal/fsapi/constants_js.go
new file mode 100644
index 00000000..af73ddb6
--- /dev/null
+++ b/internal/fsapi/constants_js.go
@@ -0,0 +1,8 @@
+package fsapi
+
+// See the comments on the same constants in constants_windows.go
+const (
+	O_DIRECTORY = 1 << 29
+	O_NOFOLLOW  = 1 << 30
+	O_NONBLOCK  = 1 << 31
+)
diff --git a/internal/fsapi/constants_sun.go b/internal/fsapi/constants_sun.go
new file mode 100644
index 00000000..a0de49b7
--- /dev/null
+++ b/internal/fsapi/constants_sun.go
@@ -0,0 +1,12 @@
+//go:build illumos || solaris
+
+package fsapi
+
+import "syscall"
+
+// See https://github.com/illumos/illumos-gate/blob/edd580643f2cf1434e252cd7779e83182ea84945/usr/src/uts/common/sys/fcntl.h#L90
+const (
+	O_DIRECTORY = 0x1000000
+	O_NOFOLLOW  = syscall.O_NOFOLLOW
+	O_NONBLOCK  = syscall.O_NONBLOCK
+)
diff --git a/internal/fsapi/constants_windows.go b/internal/fsapi/constants_windows.go
new file mode 100644
index 00000000..33aed870
--- /dev/null
+++ b/internal/fsapi/constants_windows.go
@@ -0,0 +1,24 @@
+package fsapi
+
+import "syscall"
+
+// Windows does not have these constants, we declare placeholders which should
+// not conflict with other open flags. These placeholders are not declared as
+// value zero so code written in a way which expects them to be bit flags still
+// works as expected.
+//
+// Since those placeholder are not interpreted by the open function, the unix
+// features they represent are also not implemented on windows:
+//
+//   - O_DIRECTORY allows programs to ensure that the opened file is a directory.
+//     This could be emulated by doing a stat call on the file after opening it
+//     to verify that it is in fact a directory, then closing it and returning an
+//     error if it is not.
+//
+//   - O_NOFOLLOW allows programs to ensure that if the opened file is a symbolic
+//     link, the link itself is opened instead of its target.
+const (
+	O_DIRECTORY = 1 << 29
+	O_NOFOLLOW  = 1 << 30
+	O_NONBLOCK  = syscall.O_NONBLOCK
+)
diff --git a/internal/platform/dir.go b/internal/fsapi/dir.go
similarity index 81%
rename from internal/platform/dir.go
rename to internal/fsapi/dir.go
index 994c951b..c28783b4 100644
--- a/internal/platform/dir.go
+++ b/internal/fsapi/dir.go
@@ -1,8 +1,7 @@
-package platform
+package fsapi
 
 import (
 	"fmt"
-	"io"
 	"io/fs"
 	"syscall"
 	"time"
@@ -36,23 +35,6 @@ func (d *Dirent) IsDir() bool {
 	return d.Type == fs.ModeDir
 }
 
-func adjustReaddirErr(f File, isClosed bool, err error) syscall.Errno {
-	if err == io.EOF {
-		return 0 // e.g. Readdir on darwin returns io.EOF, but linux doesn't.
-	} else if errno := UnwrapOSError(err); errno != 0 {
-		errno = dirError(f, isClosed, errno)
-		// Ignore errors when the file was closed or removed.
-		switch errno {
-		case syscall.EIO, syscall.EBADF: // closed while open
-			return 0
-		case syscall.ENOENT: // Linux error when removed while open
-			return 0
-		}
-		return errno
-	}
-	return 0
-}
-
 // DirFile is embeddable to reduce the amount of functions to implement a file.
 type DirFile struct{}
 
diff --git a/internal/platform/file.go b/internal/fsapi/file.go
similarity index 50%
rename from internal/platform/file.go
rename to internal/fsapi/file.go
index eed20ef7..2d6ad037 100644
--- a/internal/platform/file.go
+++ b/internal/fsapi/file.go
@@ -1,9 +1,7 @@
-package platform
+package fsapi
 
 import (
-	"io"
 	"io/fs"
-	"os"
 	"syscall"
 	"time"
 )
@@ -382,530 +380,3 @@ type File interface {
 	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/close.html
 	Close() syscall.Errno
 }
-
-// UnimplementedFile is a File that returns syscall.ENOSYS for all functions,
-// This should be embedded to have forward compatible implementations.
-type UnimplementedFile struct{}
-
-// Ino implements File.Ino
-func (UnimplementedFile) Ino() (uint64, syscall.Errno) {
-	return 0, 0
-}
-
-// IsAppend implements File.IsAppend
-func (UnimplementedFile) IsAppend() bool {
-	return false
-}
-
-// SetAppend implements File.SetAppend
-func (UnimplementedFile) SetAppend(bool) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// IsNonblock implements File.IsNonblock
-func (UnimplementedFile) IsNonblock() bool {
-	return false
-}
-
-// SetNonblock implements File.SetNonblock
-func (UnimplementedFile) SetNonblock(bool) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Stat implements File.Stat
-func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
-	return Stat_t{}, syscall.ENOSYS
-}
-
-// IsDir implements File.IsDir
-func (UnimplementedFile) IsDir() (bool, syscall.Errno) {
-	return false, syscall.ENOSYS
-}
-
-// Read implements File.Read
-func (UnimplementedFile) Read([]byte) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Pread implements File.Pread
-func (UnimplementedFile) Pread([]byte, int64) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Seek implements File.Seek
-func (UnimplementedFile) Seek(int64, int) (int64, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Readdir implements File.Readdir
-func (UnimplementedFile) Readdir(int) (dirents []Dirent, errno syscall.Errno) {
-	return nil, syscall.ENOSYS
-}
-
-// PollRead implements File.PollRead
-func (UnimplementedFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
-	return false, syscall.ENOSYS
-}
-
-// Write implements File.Write
-func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Pwrite implements File.Pwrite
-func (UnimplementedFile) Pwrite([]byte, int64) (int, syscall.Errno) {
-	return 0, syscall.ENOSYS
-}
-
-// Truncate implements File.Truncate
-func (UnimplementedFile) Truncate(int64) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Sync implements File.Sync
-func (UnimplementedFile) Sync() syscall.Errno {
-	return 0 // not syscall.ENOSYS
-}
-
-// Datasync implements File.Datasync
-func (UnimplementedFile) Datasync() syscall.Errno {
-	return 0 // not syscall.ENOSYS
-}
-
-// Chmod implements File.Chmod
-func (UnimplementedFile) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chown implements File.Chown
-func (UnimplementedFile) Chown(int, int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Utimens implements File.Utimens
-func (UnimplementedFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-func NewStdioFile(stdin bool, f fs.File) (File, error) {
-	// Return constant stat, which has fake times, but keep the underlying
-	// file mode. Fake times are needed to pass wasi-testsuite.
-	// https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
-	var mode fs.FileMode
-	if st, err := f.Stat(); err != nil {
-		return nil, err
-	} else {
-		mode = st.Mode()
-	}
-	var flag int
-	if stdin {
-		flag = syscall.O_RDONLY
-	} else {
-		flag = syscall.O_WRONLY
-	}
-	var file File
-	if of, ok := f.(*os.File); ok {
-		// This is ok because functions that need path aren't used by stdioFile
-		file = newOsFile("", flag, 0, of)
-	} else {
-		file = &fsFile{file: f}
-	}
-	return &stdioFile{File: file, st: Stat_t{Mode: mode, Nlink: 1}}, nil
-}
-
-func OpenFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
-	if flag&O_DIRECTORY != 0 && flag&(syscall.O_WRONLY|syscall.O_RDWR) != 0 {
-		return nil, syscall.EISDIR // invalid to open a directory writeable
-	}
-	return openFile(path, flag, perm)
-}
-
-func OpenOSFile(path string, flag int, perm fs.FileMode) (File, syscall.Errno) {
-	f, errno := OpenFile(path, flag, perm)
-	if errno != 0 {
-		return nil, errno
-	}
-	return newOsFile(path, flag, perm, f), 0
-}
-
-func OpenFSFile(fs fs.FS, path string, flag int, perm fs.FileMode) (File, syscall.Errno) {
-	if flag&O_DIRECTORY != 0 && flag&(syscall.O_WRONLY|syscall.O_RDWR) != 0 {
-		return nil, syscall.EISDIR // invalid to open a directory writeable
-	}
-	f, err := fs.Open(path)
-	if errno := UnwrapOSError(err); errno != 0 {
-		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.
-	return &fsFile{fs: fs, name: path, file: f}, 0
-}
-
-type stdioFile struct {
-	File
-	st Stat_t
-}
-
-// IsDir implements File.IsDir
-func (f *stdioFile) IsDir() (bool, syscall.Errno) {
-	return false, 0
-}
-
-// Stat implements File.Stat
-func (f *stdioFile) Stat() (Stat_t, syscall.Errno) {
-	return f.st, 0
-}
-
-// Close implements File.Close
-func (f *stdioFile) Close() syscall.Errno {
-	return 0
-}
-
-// fsFile is used for wrapped os.File, like os.Stdin or any fs.File
-// implementation. Notably, this does not have access to the full file path.
-// so certain operations can't be supported, such as inode lookups on Windows.
-type fsFile struct {
-	UnimplementedFile
-
-	// fs is the file-system that opened the file, or nil when wrapped for
-	// pre-opens like stdio.
-	fs fs.FS
-
-	// name is what was used in fs for Open, so it may not be the actual path.
-	name string
-
-	// file is always set, possibly an os.File like os.Stdin.
-	file fs.File
-
-	// closed is true when closed was called. This ensures proper syscall.EBADF
-	closed bool
-
-	// cachedStat includes fields that won't change while a file is open.
-	cachedSt *cachedStat
-}
-
-type cachedStat struct {
-	// fileType is the same as what's documented on Dirent.
-	fileType fs.FileMode
-
-	// ino is the same as what's documented on Dirent.
-	ino uint64
-}
-
-// cachedStat returns the cacheable parts of platform.Stat_t or an error if
-// they couldn't be retrieved.
-func (f *fsFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.Errno) {
-	if f.cachedSt == nil {
-		if _, errno = f.Stat(); errno != 0 {
-			return
-		}
-	}
-	return f.cachedSt.fileType, f.cachedSt.ino, 0
-}
-
-// Ino implements File.Ino
-func (f *fsFile) Ino() (uint64, syscall.Errno) {
-	if _, ino, errno := f.cachedStat(); errno != 0 {
-		return 0, errno
-	} else {
-		return ino, 0
-	}
-}
-
-// IsAppend implements File.IsAppend
-func (f *fsFile) IsAppend() bool {
-	return false
-}
-
-// SetAppend implements File.SetAppend
-func (f *fsFile) SetAppend(bool) (errno syscall.Errno) {
-	return fileError(f, f.closed, syscall.ENOSYS)
-}
-
-// IsDir implements File.IsDir
-func (f *fsFile) IsDir() (bool, syscall.Errno) {
-	if ft, _, errno := f.cachedStat(); errno != 0 {
-		return false, errno
-	} else if ft.Type() == fs.ModeDir {
-		return true, 0
-	}
-	return false, 0
-}
-
-// Stat implements File.Stat
-func (f *fsFile) Stat() (st Stat_t, errno syscall.Errno) {
-	if f.closed {
-		errno = syscall.EBADF
-		return
-	}
-
-	// While some functions in platform.File need the full path, especially in
-	// Windows, stat does not. Casting here allows os.DirFS to return inode
-	// information.
-	if of, ok := f.file.(*os.File); ok {
-		if st, errno = statFile(of); errno != 0 {
-			return
-		}
-		return f.cacheStat(st)
-	} else if t, err := f.file.Stat(); err != nil {
-		errno = UnwrapOSError(err)
-		return
-	} else {
-		st = statFromDefaultFileInfo(t)
-		return f.cacheStat(st)
-	}
-}
-
-func (f *fsFile) cacheStat(st Stat_t) (Stat_t, syscall.Errno) {
-	f.cachedSt = &cachedStat{fileType: st.Mode & fs.ModeType, ino: st.Ino}
-	return st, 0
-}
-
-// Read implements File.Read
-func (f *fsFile) Read(buf []byte) (n int, errno syscall.Errno) {
-	if n, errno = read(f.file, buf); errno != 0 {
-		// Defer validation overhead until we've already had an error.
-		errno = fileError(f, f.closed, errno)
-	}
-	return
-}
-
-// Pread implements File.Pread
-func (f *fsFile) Pread(buf []byte, off int64) (n int, errno syscall.Errno) {
-	if ra, ok := f.file.(io.ReaderAt); ok {
-		if n, errno = pread(ra, buf, off); errno != 0 {
-			// Defer validation overhead until we've already had an error.
-			errno = fileError(f, f.closed, errno)
-		}
-		return
-	}
-
-	// See /RATIONALE.md "fd_pread: io.Seeker fallback when io.ReaderAt is not supported"
-	if rs, ok := f.file.(io.ReadSeeker); ok {
-		// Determine the current position in the file, as we need to revert it.
-		currentOffset, err := rs.Seek(0, io.SeekCurrent)
-		if err != nil {
-			return 0, fileError(f, f.closed, UnwrapOSError(err))
-		}
-
-		// Put the read position back when complete.
-		defer func() { _, _ = rs.Seek(currentOffset, io.SeekStart) }()
-
-		// If the current offset isn't in sync with this reader, move it.
-		if off != currentOffset {
-			if _, err = rs.Seek(off, io.SeekStart); err != nil {
-				return 0, fileError(f, f.closed, UnwrapOSError(err))
-			}
-		}
-
-		n, err = rs.Read(buf)
-		if errno = UnwrapOSError(err); errno != 0 {
-			// Defer validation overhead until we've already had an error.
-			errno = fileError(f, f.closed, errno)
-		}
-	} else {
-		errno = syscall.ENOSYS // unsupported
-	}
-	return
-}
-
-// Seek implements File.Seek.
-func (f *fsFile) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
-	// If this is a directory, and we're attempting to seek to position zero,
-	// we have to re-open the file to ensure the directory state is reset.
-	var isDir bool
-	if offset == 0 && whence == io.SeekStart {
-		if isDir, errno = f.IsDir(); errno != 0 {
-			return
-		} else if isDir {
-			return 0, f.reopen()
-		}
-	}
-
-	if s, ok := f.file.(io.Seeker); ok {
-		if newOffset, errno = seek(s, offset, whence); errno != 0 {
-			// Defer validation overhead until we've already had an error.
-			errno = fileError(f, f.closed, errno)
-		}
-	} else {
-		errno = syscall.ENOSYS // unsupported
-	}
-	return
-}
-
-func (f *fsFile) reopen() syscall.Errno {
-	_ = f.close()
-	var err error
-	f.file, err = f.fs.Open(f.name)
-	return UnwrapOSError(err)
-}
-
-// Readdir implements File.Readdir. Notably, this uses fs.ReadDirFile if
-// available.
-func (f *fsFile) Readdir(n int) (dirents []Dirent, errno syscall.Errno) {
-	if of, ok := f.file.(*os.File); ok {
-		// We can't use f.name here because it is the path up to the fs.FS, not
-		// necessarily the real path. For this reason, Windows may not be able
-		// to populate inodes. However, Darwin and Linux will.
-		if dirents, errno = readdir(of, "", n); errno != 0 {
-			errno = adjustReaddirErr(f, f.closed, errno)
-		}
-		return
-	}
-
-	// Try with fs.ReadDirFile which is available on fs.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 {
-			return
-		}
-		dirents = make([]Dirent, 0, len(entries))
-		for _, e := range entries {
-			// By default, we don't attempt to read inode data
-			dirents = append(dirents, Dirent{Name: e.Name(), Type: e.Type()})
-		}
-	} else {
-		errno = syscall.ENOTDIR
-	}
-	return
-}
-
-// Write implements File.Write
-func (f *fsFile) Write(buf []byte) (n int, errno syscall.Errno) {
-	if w, ok := f.file.(io.Writer); ok {
-		if n, errno = write(w, buf); errno != 0 {
-			// Defer validation overhead until we've already had an error.
-			errno = fileError(f, f.closed, errno)
-		}
-	} else {
-		errno = syscall.ENOSYS // unsupported
-	}
-	return
-}
-
-// Pwrite implements File.Pwrite
-func (f *fsFile) Pwrite(buf []byte, off int64) (n int, errno syscall.Errno) {
-	if wa, ok := f.file.(io.WriterAt); ok {
-		if n, errno = pwrite(wa, buf, off); errno != 0 {
-			// Defer validation overhead until we've already had an error.
-			errno = fileError(f, f.closed, errno)
-		}
-	} else {
-		errno = syscall.ENOSYS // unsupported
-	}
-	return
-}
-
-// Close implements File.Close
-func (f *fsFile) Close() syscall.Errno {
-	if f.closed {
-		return 0
-	}
-	f.closed = true
-	return f.close()
-}
-
-func (f *fsFile) close() syscall.Errno {
-	return UnwrapOSError(f.file.Close())
-}
-
-// dirError is used for commands that work against a directory, but not a file.
-func dirError(f File, isClosed bool, errno syscall.Errno) syscall.Errno {
-	if vErrno := validate(f, isClosed, false, true); vErrno != 0 {
-		return vErrno
-	}
-	return errno
-}
-
-// fileError is used for commands that work against a file, but not a directory.
-func fileError(f File, isClosed bool, errno syscall.Errno) syscall.Errno {
-	if vErrno := validate(f, isClosed, true, false); vErrno != 0 {
-		return vErrno
-	}
-	return errno
-}
-
-// validate is used to making syscalls which will fail.
-func validate(f File, isClosed, wantFile, wantDir bool) syscall.Errno {
-	if isClosed {
-		return syscall.EBADF
-	}
-
-	isDir, errno := f.IsDir()
-	if errno != 0 {
-		return errno
-	}
-
-	if wantFile && isDir {
-		return syscall.EISDIR
-	} else if wantDir && !isDir {
-		return syscall.ENOTDIR
-	}
-	return 0
-}
-
-func read(r io.Reader, buf []byte) (n int, errno syscall.Errno) {
-	if len(buf) == 0 {
-		return 0, 0 // less overhead on zero-length reads.
-	}
-
-	n, err := r.Read(buf)
-	return n, UnwrapOSError(err)
-}
-
-func pread(ra io.ReaderAt, buf []byte, off int64) (n int, errno syscall.Errno) {
-	if len(buf) == 0 {
-		return 0, 0 // less overhead on zero-length reads.
-	}
-
-	n, err := ra.ReadAt(buf, off)
-	return n, UnwrapOSError(err)
-}
-
-func seek(s io.Seeker, offset int64, whence int) (int64, syscall.Errno) {
-	if uint(whence) > io.SeekEnd {
-		return 0, syscall.EINVAL // negative or exceeds the largest valid whence
-	}
-
-	newOffset, err := s.Seek(offset, whence)
-	return newOffset, UnwrapOSError(err)
-}
-
-func readdir(f *os.File, path string, n int) (dirents []Dirent, errno syscall.Errno) {
-	fis, e := f.Readdir(n)
-	if errno = UnwrapOSError(e); errno != 0 {
-		return
-	}
-
-	dirents = make([]Dirent, 0, len(fis))
-
-	// linux/darwin won't have to fan out to lstat, but windows will.
-	var ino uint64
-	for fi := range fis {
-		t := fis[fi]
-		if ino, errno = inoFromFileInfo(path, t); errno != 0 {
-			return
-		}
-		dirents = append(dirents, Dirent{Name: t.Name(), Ino: ino, Type: t.Mode().Type()})
-	}
-	return
-}
-
-func write(w io.Writer, buf []byte) (n int, errno syscall.Errno) {
-	if len(buf) == 0 {
-		return 0, 0 // less overhead on zero-length writes.
-	}
-
-	n, err := w.Write(buf)
-	return n, UnwrapOSError(err)
-}
-
-func pwrite(w io.WriterAt, buf []byte, off int64) (n int, errno syscall.Errno) {
-	if len(buf) == 0 {
-		return 0, 0 // less overhead on zero-length writes.
-	}
-
-	n, err := w.WriteAt(buf, off)
-	return n, UnwrapOSError(err)
-}
diff --git a/internal/fsapi/fs.go b/internal/fsapi/fs.go
new file mode 100644
index 00000000..9a46bfe6
--- /dev/null
+++ b/internal/fsapi/fs.go
@@ -0,0 +1,365 @@
+package fsapi
+
+import (
+	"io/fs"
+	"syscall"
+)
+
+// FS is a writeable fs.FS bridge backed by syscall functions needed for ABI
+// including WASI and runtime.GOOS=js.
+//
+// Implementations should embed UnimplementedFS for forward compatability. Any
+// unsupported method or parameter should return syscall.ENO
+//
+// # Errors
+//
+// All methods that can return an error return a syscall.Errno, which is zero
+// on success.
+//
+// Restricting to syscall.Errno matches current WebAssembly host functions,
+// which are constrained to well-known error codes. For example, `GOOS=js` maps
+// hard coded values and panics otherwise. More commonly, WASI maps syscall
+// errors to u32 numeric values.
+//
+// # Notes
+//
+// A writable filesystem abstraction is not yet implemented as of Go 1.20. See
+// https://github.com/golang/go/issues/45757
+type FS interface {
+	// String should return a human-readable format of the filesystem
+	//
+	// For example, if this filesystem is backed by the real directory
+	// "/tmp/wasm", the expected value is "/tmp/wasm".
+	//
+	// When the host filesystem isn't a real filesystem, substitute a symbolic,
+	// human-readable name. e.g. "virtual"
+	String() string
+
+	// OpenFile opens a file. It should be closed via Close on File.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` or `flag` is invalid.
+	//   - syscall.EISDIR: the path was a directory, but flag included
+	//     syscall.O_RDWR or syscall.O_WRONLY
+	//   - syscall.ENOENT: `path` doesn't exist and `flag` doesn't contain
+	//     os.O_CREATE.
+	//
+	// # Constraints on the returned file
+	//
+	// Implementations that can read flags should enforce them regardless of
+	// the type returned. For example, while os.File implements io.Writer,
+	// attempts to write to a directory or a file opened with os.O_RDONLY fail
+	// with a syscall.EBADF.
+	//
+	// Some implementations choose whether to enforce read-only opens, namely
+	// fs.FS. While fs.FS is supported (Adapt), wazero cannot runtime enforce
+	// open flags. Instead, we encourage good behavior and test our built-in
+	// implementations.
+	//
+	// # Notes
+	//
+	//   - This is like os.OpenFile, except the path is relative to this file
+	//     system, and syscall.Errno is returned instead of os.PathError.
+	//   - flag are the same as os.OpenFile, for example, os.O_CREATE.
+	//   - Implications of permissions when os.O_CREATE are described in Chmod
+	//     notes.
+	//   - This is like `open` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html
+	OpenFile(path string, flag int, perm fs.FileMode) (File, syscall.Errno)
+	// ^^ TODO: Consider syscall.Open, though this implies defining and
+	// coercing flags and perms similar to what is done in os.OpenFile.
+
+	// Lstat gets file status without following symbolic links.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Lstat, except the `path` is relative to this
+	//     file system.
+	//   - This is like `lstat` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/lstat.html
+	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
+	//     same value.
+	//   - When the path is a symbolic link, the stat returned is for the link,
+	//     not the file it refers to.
+	Lstat(path string) (Stat_t, syscall.Errno)
+
+	// Stat gets file status.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Stat, except the `path` is relative to this
+	//     file system.
+	//   - This is like `stat` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/stat.html
+	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
+	//     same value.
+	//   - When the path is a symbolic link, the stat returned is for the file
+	//     it refers to.
+	Stat(path string) (Stat_t, syscall.Errno)
+
+	// Mkdir makes a directory.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.EEXIST: `path` exists and is a directory.
+	//   - syscall.ENOTDIR: `path` exists and is a file.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Mkdir, except the `path` is relative to this
+	//     file system.
+	//   - This is like `mkdir` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/mkdir.html
+	//   - Implications of permissions are described in Chmod notes.
+	Mkdir(path string, perm fs.FileMode) syscall.Errno
+	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
+	// coercing flags and perms similar to what is done in os.Mkdir.
+
+	// Chmod changes the mode of the file.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` does not exist.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Chmod, except the `path` is relative to this
+	//     file system.
+	//   - This is like `chmod` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/chmod.html
+	//   - Windows ignores the execute bit, and any permissions come back as
+	//     group and world. For example, chmod of 0400 reads back as 0444, and
+	//     0700 0666. Also, permissions on directories aren't supported at all.
+	Chmod(path string, perm fs.FileMode) syscall.Errno
+
+	// Chown changes the owner and group of a file.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` does not exist.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Chown, except the `path` is relative to this
+	//     file system.
+	//   - This is like `chown` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/chown.html
+	//   - This always returns syscall.ENOSYS on windows.
+	Chown(path string, uid, gid int) syscall.Errno
+
+	// Lchown changes the owner and group of a symbolic link.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` does not exist.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Lchown, except the `path` is relative to this
+	//     file system.
+	//   - This is like `lchown` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/lchown.html
+	//   - Windows will always return syscall.ENOSYS
+	Lchown(path string, uid, gid int) syscall.Errno
+
+	// Rename renames file or directory.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `from` or `to` is invalid.
+	//   - syscall.ENOENT: `from` or `to` don't exist.
+	//   - syscall.ENOTDIR: `from` is a directory and `to` exists as a file.
+	//   - syscall.EISDIR: `from` is a file and `to` exists as a directory.
+	//   - syscall.ENOTEMPTY: `both from` and `to` are existing directory, but
+	//    `to` is not empty.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Rename, except the paths are relative to this
+	//     file system.
+	//   - This is like `rename` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html
+	//   -  Windows doesn't let you overwrite an existing directory.
+	Rename(from, to string) syscall.Errno
+
+	// Rmdir removes a directory.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//   - syscall.ENOTDIR: `path` exists, but isn't a directory.
+	//   - syscall.ENOTEMPTY: `path` exists, but isn't empty.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Rmdir, except the `path` is relative to this
+	//     file system.
+	//   - This is like `rmdir` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/rmdir.html
+	//   - As of Go 1.19, Windows maps syscall.ENOTDIR to syscall.ENOENT.
+	Rmdir(path string) syscall.Errno
+
+	// Unlink removes a directory entry.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//   - syscall.EISDIR: `path` exists, but is a directory.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Unlink, except the `path` is relative to this
+	//     file system.
+	//   - This is like `unlink` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/unlink.html
+	//   - On Windows, syscall.Unlink doesn't delete symlink to directory unlike other platforms. Implementations might
+	//     want to combine syscall.RemoveDirectory with syscall.Unlink in order to delete such links on Windows.
+	//     See https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-removedirectorya
+	Unlink(path string) syscall.Errno
+
+	// Link creates a "hard" link from oldPath to newPath, in contrast to a
+	// soft link (via Symlink).
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EPERM: `oldPath` is invalid.
+	//   - syscall.ENOENT: `oldPath` doesn't exist.
+	//   - syscall.EISDIR: `newPath` exists, but is a directory.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Link, except the `oldPath` is relative to this
+	//     file system.
+	//   - This is like `link` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/link.html
+	Link(oldPath, newPath string) syscall.Errno
+
+	// Symlink creates a "soft" link from oldPath to newPath, in contrast to a
+	// hard link (via Link).
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EPERM: `oldPath` or `newPath` is invalid.
+	//   - syscall.EEXIST: `newPath` exists.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Symlink, except the `oldPath` is relative to
+	//     this file system.
+	//   - This is like `symlink` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
+	//   - Only `newPath` is relative to this file system and `oldPath` is kept
+	//     as-is. That is because the link is only resolved relative to the
+	//     directory when dereferencing it (e.g. ReadLink).
+	//     See https://github.com/bytecodealliance/cap-std/blob/v1.0.4/cap-std/src/fs/dir.rs#L404-L409
+	//     for how others implement this.
+	//   - Symlinks in Windows requires `SeCreateSymbolicLinkPrivilege`.
+	//     Otherwise, syscall.EPERM results.
+	//     See https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
+	Symlink(oldPath, linkName string) syscall.Errno
+
+	// Readlink reads the contents of a symbolic link.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Readlink, except the path is relative to this
+	//     filesystem.
+	//   - This is like `readlink` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/readlink.html
+	//   - On Windows, the path separator is different from other platforms,
+	//     but to provide consistent results to Wasm, this normalizes to a "/"
+	//     separator.
+	Readlink(path string) (string, syscall.Errno)
+
+	// Truncate truncates a file to a specified length.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid or size is negative.
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//   - syscall.EISDIR: `path` is a directory.
+	//   - syscall.EACCES: `path` doesn't have write access.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.Truncate, except the path is relative to this
+	//     filesystem.
+	//   - This is like `truncate` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/truncate.html
+	Truncate(path string, size int64) syscall.Errno
+
+	// Utimens set file access and modification times on a path relative to
+	// this file system, at nanosecond precision.
+	//
+	// # Parameters
+	//
+	// The `times` parameter includes the access and modification timestamps to
+	// assign. Special syscall.Timespec NSec values platform.UTIME_NOW and
+	// platform.UTIME_OMIT may be specified instead of real timestamps. A nil
+	// `times` parameter behaves the same as if both were set to
+	// platform.UTIME_NOW.
+	//
+	// When the `symlinkFollow` parameter is true and the path is a symbolic link,
+	// the target of expanding that link is updated.
+	//
+	// # Errors
+	//
+	// A zero syscall.Errno is success. The below are expected otherwise:
+	//   - syscall.ENOSYS: the implementation does not support this function.
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.EEXIST: `path` exists and is a directory.
+	//   - syscall.ENOTDIR: `path` exists and is a file.
+	//
+	// # Notes
+	//
+	//   - This is like syscall.UtimesNano and `utimensat` with `AT_FDCWD` in
+	//     POSIX. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+	Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno
+}
diff --git a/internal/platform/stat.go b/internal/fsapi/stat.go
similarity index 57%
rename from internal/platform/stat.go
rename to internal/fsapi/stat.go
index 038f77a8..3901a6af 100644
--- a/internal/platform/stat.go
+++ b/internal/fsapi/stat.go
@@ -1,10 +1,6 @@
-package platform
+package fsapi
 
-import (
-	"io/fs"
-	"os"
-	"syscall"
-)
+import "io/fs"
 
 // Stat_t is similar to syscall.Stat_t, and fields frequently used by
 // WebAssembly ABI including WASI snapshot-01, GOOS=js and wasi-filesystem.
@@ -50,43 +46,3 @@ type Stat_t struct {
 	// Ctim is the last file status change timestamp in epoch nanoseconds.
 	Ctim int64
 }
-
-// Lstat is like syscall.Lstat. This returns syscall.ENOENT if the path doesn't
-// exist.
-//
-// # Notes
-//
-// The primary difference between this and Stat is, when the path is a
-// symbolic link, the stat is about the link, not its target, such as directory
-// listings.
-func Lstat(path string) (Stat_t, syscall.Errno) {
-	return lstat(path) // extracted to override more expensively in windows
-}
-
-// Stat is like syscall.Stat. This returns syscall.ENOENT if the path doesn't
-// exist.
-func Stat(path string) (Stat_t, syscall.Errno) {
-	return stat(path) // extracted to override more expensively in windows
-}
-
-func defaultStatFile(f *os.File) (Stat_t, syscall.Errno) {
-	if t, err := f.Stat(); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
-	} else {
-		return statFromFileInfo(t), 0
-	}
-}
-
-func statFromDefaultFileInfo(t fs.FileInfo) Stat_t {
-	st := Stat_t{}
-	st.Ino = 0
-	st.Dev = 0
-	st.Mode = t.Mode()
-	st.Nlink = 1
-	st.Size = t.Size()
-	mtim := t.ModTime().UnixNano() // Set all times to the mod time
-	st.Atim = mtim
-	st.Mtim = mtim
-	st.Ctim = mtim
-	return st
-}
diff --git a/internal/fsapi/unimplemented.go b/internal/fsapi/unimplemented.go
new file mode 100644
index 00000000..7a57468b
--- /dev/null
+++ b/internal/fsapi/unimplemented.go
@@ -0,0 +1,205 @@
+package fsapi
+
+import (
+	"io/fs"
+	"syscall"
+	"time"
+)
+
+// UnimplementedFS is an FS that returns syscall.ENOSYS for all functions,
+// 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: syscall.ENOSYS}
+}
+
+// OpenFile implements FS.OpenFile
+func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (File, syscall.Errno) {
+	return nil, syscall.ENOSYS
+}
+
+// Lstat implements FS.Lstat
+func (UnimplementedFS) Lstat(path string) (Stat_t, syscall.Errno) {
+	return Stat_t{}, syscall.ENOSYS
+}
+
+// Stat implements FS.Stat
+func (UnimplementedFS) Stat(path string) (Stat_t, syscall.Errno) {
+	return Stat_t{}, syscall.ENOSYS
+}
+
+// Readlink implements FS.Readlink
+func (UnimplementedFS) Readlink(path string) (string, syscall.Errno) {
+	return "", syscall.ENOSYS
+}
+
+// Mkdir implements FS.Mkdir
+func (UnimplementedFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Chmod implements FS.Chmod
+func (UnimplementedFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Chown implements FS.Chown
+func (UnimplementedFS) Chown(path string, uid, gid int) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Lchown implements FS.Lchown
+func (UnimplementedFS) Lchown(path string, uid, gid int) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Rename implements FS.Rename
+func (UnimplementedFS) Rename(from, to string) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Rmdir implements FS.Rmdir
+func (UnimplementedFS) Rmdir(path string) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Link implements FS.Link
+func (UnimplementedFS) Link(_, _ string) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Symlink implements FS.Symlink
+func (UnimplementedFS) Symlink(_, _ string) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Unlink implements FS.Unlink
+func (UnimplementedFS) Unlink(path string) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Utimens implements FS.Utimens
+func (UnimplementedFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Truncate implements FS.Truncate
+func (UnimplementedFS) Truncate(string, int64) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// UnimplementedFile is a File that returns syscall.ENOSYS for all functions,
+// except where no-op are otherwise documented.
+//
+// This should be embedded to have forward compatible implementations.
+type UnimplementedFile struct{}
+
+// Ino implements File.Ino
+func (UnimplementedFile) Ino() (uint64, syscall.Errno) {
+	return 0, 0
+}
+
+// IsAppend implements File.IsAppend
+func (UnimplementedFile) IsAppend() bool {
+	return false
+}
+
+// SetAppend implements File.SetAppend
+func (UnimplementedFile) SetAppend(bool) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// IsNonblock implements File.IsNonblock
+func (UnimplementedFile) IsNonblock() bool {
+	return false
+}
+
+// SetNonblock implements File.SetNonblock
+func (UnimplementedFile) SetNonblock(bool) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Stat implements File.Stat
+func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
+	return Stat_t{}, syscall.ENOSYS
+}
+
+// IsDir implements File.IsDir
+func (UnimplementedFile) IsDir() (bool, syscall.Errno) {
+	return false, syscall.ENOSYS
+}
+
+// Read implements File.Read
+func (UnimplementedFile) Read([]byte) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Pread implements File.Pread
+func (UnimplementedFile) Pread([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Seek implements File.Seek
+func (UnimplementedFile) Seek(int64, int) (int64, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Readdir implements File.Readdir
+func (UnimplementedFile) Readdir(int) (dirents []Dirent, errno syscall.Errno) {
+	return nil, syscall.ENOSYS
+}
+
+// PollRead implements File.PollRead
+func (UnimplementedFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return false, syscall.ENOSYS
+}
+
+// Write implements File.Write
+func (UnimplementedFile) Write([]byte) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Pwrite implements File.Pwrite
+func (UnimplementedFile) Pwrite([]byte, int64) (int, syscall.Errno) {
+	return 0, syscall.ENOSYS
+}
+
+// Truncate implements File.Truncate
+func (UnimplementedFile) Truncate(int64) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Sync implements File.Sync
+func (UnimplementedFile) Sync() syscall.Errno {
+	return 0 // not syscall.ENOSYS
+}
+
+// Datasync implements File.Datasync
+func (UnimplementedFile) Datasync() syscall.Errno {
+	return 0 // not syscall.ENOSYS
+}
+
+// Chmod implements File.Chmod
+func (UnimplementedFile) Chmod(fs.FileMode) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Chown implements File.Chown
+func (UnimplementedFile) Chown(int, int) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Utimens implements File.Utimens
+func (UnimplementedFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
+	return syscall.ENOSYS
+}
+
+// Close implements File.Close
+func (UnimplementedFile) Close() (errno syscall.Errno) { return }
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index cc7c66a4..3ecd5d0c 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -7,10 +7,10 @@ import (
 	"syscall"
 
 	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
 	"github.com/tetratelabs/wazero/internal/gojs/goos"
 	"github.com/tetratelabs/wazero/internal/gojs/util"
-	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -184,7 +184,7 @@ func syscallFstat(fsc *internalsys.FSContext, fd int32) (*jsSt, error) {
 	}
 }
 
-func newJsSt(st platform.Stat_t) *jsSt {
+func newJsSt(st fsapi.Stat_t) *jsSt {
 	ret := &jsSt{}
 	ret.isDir = st.Mode.IsDir()
 	ret.dev = st.Dev
diff --git a/internal/platform/chown_unix.go b/internal/platform/chown_unix.go
deleted file mode 100644
index 5e0753da..00000000
--- a/internal/platform/chown_unix.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !windows
-
-package platform
-
-import "syscall"
-
-func fchown(fd uintptr, uid, gid int) syscall.Errno {
-	return UnwrapOSError(syscall.Fchown(int(fd), uid, gid))
-}
diff --git a/internal/platform/datasync_linux.go b/internal/platform/datasync_linux.go
deleted file mode 100644
index 515664e3..00000000
--- a/internal/platform/datasync_linux.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build linux
-
-package platform
-
-import (
-	"os"
-	"syscall"
-)
-
-func datasync(f *os.File) syscall.Errno {
-	return UnwrapOSError(syscall.Fdatasync(int(f.Fd())))
-}
diff --git a/internal/platform/rename.go b/internal/platform/rename.go
deleted file mode 100644
index 2b537fae..00000000
--- a/internal/platform/rename.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build !windows
-
-package platform
-
-import "syscall"
-
-func Rename(from, to string) syscall.Errno {
-	if from == to {
-		return 0
-	}
-	return UnwrapOSError(syscall.Rename(from, to))
-}
diff --git a/internal/platform/select_unsupported.go b/internal/platform/select_unsupported.go
deleted file mode 100644
index 672ca400..00000000
--- a/internal/platform/select_unsupported.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build !darwin && !linux && !windows
-
-package platform
-
-import (
-	"syscall"
-	"time"
-)
-
-func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
-	return -1, syscall.ENOSYS
-}
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
deleted file mode 100644
index f3bb313d..00000000
--- a/internal/platform/stat_unsupported.go
+++ /dev/null
@@ -1,39 +0,0 @@
-//go:build (!((amd64 || arm64 || riscv64) && linux) && !((amd64 || arm64) && (darwin || freebsd)) && !((amd64 || arm64) && windows)) || js
-
-package platform
-
-import (
-	"io/fs"
-	"os"
-	"syscall"
-)
-
-func lstat(path string) (Stat_t, syscall.Errno) {
-	t, err := os.Lstat(path)
-	if errno := UnwrapOSError(err); errno == 0 {
-		return statFromFileInfo(t), 0
-	} else {
-		return Stat_t{}, errno
-	}
-}
-
-func stat(path string) (Stat_t, syscall.Errno) {
-	t, err := os.Stat(path)
-	if errno := UnwrapOSError(err); errno == 0 {
-		return statFromFileInfo(t), 0
-	} else {
-		return Stat_t{}, errno
-	}
-}
-
-func statFile(f *os.File) (Stat_t, syscall.Errno) {
-	return defaultStatFile(f)
-}
-
-func inoFromFileInfo(_ string, t fs.FileInfo) (ino uint64, err syscall.Errno) {
-	return
-}
-
-func statFromFileInfo(t fs.FileInfo) Stat_t {
-	return statFromDefaultFileInfo(t)
-}
diff --git a/internal/platform/sync.go b/internal/platform/sync.go
deleted file mode 100644
index b9c080b9..00000000
--- a/internal/platform/sync.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build !windows
-
-package platform
-
-import (
-	"os"
-	"syscall"
-)
-
-func sync(f *os.File) syscall.Errno {
-	return UnwrapOSError(f.Sync())
-}
diff --git a/internal/platform/unlink.go b/internal/platform/unlink.go
deleted file mode 100644
index 9555da51..00000000
--- a/internal/platform/unlink.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build !windows
-
-package platform
-
-import "syscall"
-
-func Unlink(name string) (errno syscall.Errno) {
-	err := syscall.Unlink(name)
-	if errno = UnwrapOSError(err); errno == syscall.EPERM {
-		errno = syscall.EISDIR
-	}
-	return errno
-}
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index a09687c2..f151f90f 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -3,11 +3,10 @@ package sys
 import (
 	"io"
 	"io/fs"
-	"os"
 	"syscall"
-	"time"
 
 	"github.com/tetratelabs/wazero/internal/descriptor"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 )
@@ -32,214 +31,6 @@ const (
 
 const modeDevice = fs.ModeDevice | 0o640
 
-// StdinFile is a fs.ModeDevice file for use implementing FdStdin.
-// This is safer than reading from os.DevNull as it can never overrun
-// operating system file descriptors.
-type StdinFile struct {
-	noopStdinFile
-	io.Reader
-}
-
-// Read implements the same method as documented on platform.File
-func (f *StdinFile) Read(buf []byte) (int, syscall.Errno) {
-	n, err := f.Reader.Read(buf)
-	return n, platform.UnwrapOSError(err)
-}
-
-type writerFile struct {
-	noopStdoutFile
-
-	w io.Writer
-}
-
-// Write implements the same method as documented on platform.File
-func (f *writerFile) Write(buf []byte) (int, syscall.Errno) {
-	n, err := f.w.Write(buf)
-	return n, platform.UnwrapOSError(err)
-}
-
-// noopStdinFile is a fs.ModeDevice file for use implementing FdStdin. This is
-// safer than reading from os.DevNull as it can never overrun operating system
-// file descriptors.
-type noopStdinFile struct {
-	noopStdioFile
-}
-
-// AccessMode implements the same method as documented on platform.File
-func (noopStdinFile) AccessMode() int {
-	return syscall.O_RDONLY
-}
-
-// Read implements the same method as documented on platform.File
-func (noopStdinFile) Read([]byte) (int, syscall.Errno) {
-	return 0, 0 // Always EOF
-}
-
-// PollRead implements the same method as documented on platform.File
-func (noopStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
-	return true, 0 // always ready to read nothing
-}
-
-// noopStdoutFile is a fs.ModeDevice file for use implementing FdStdout and
-// FdStderr.
-type noopStdoutFile struct {
-	noopStdioFile
-}
-
-// AccessMode implements the same method as documented on platform.File
-func (noopStdoutFile) AccessMode() int {
-	return syscall.O_WRONLY
-}
-
-// Write implements the same method as documented on platform.File
-func (noopStdoutFile) Write(buf []byte) (int, syscall.Errno) {
-	return len(buf), 0 // same as io.Discard
-}
-
-type noopStdioFile struct {
-	platform.UnimplementedFile
-}
-
-// Stat implements the same method as documented on platform.File
-func (noopStdioFile) Stat() (platform.Stat_t, syscall.Errno) {
-	return platform.Stat_t{Mode: modeDevice, Nlink: 1}, 0
-}
-
-// IsDir implements the same method as documented on platform.File
-func (noopStdioFile) IsDir() (bool, syscall.Errno) {
-	return false, 0
-}
-
-// Close implements the same method as documented on platform.File
-func (noopStdioFile) Close() (errno syscall.Errno) { return }
-
-// compile-time check to ensure lazyDir implements platform.File.
-var _ platform.File = (*lazyDir)(nil)
-
-type lazyDir struct {
-	platform.DirFile
-
-	fs sysfs.FS
-	f  platform.File
-}
-
-// Ino implements the same method as documented on platform.File
-func (r *lazyDir) Ino() (uint64, syscall.Errno) {
-	if f, ok := r.file(); !ok {
-		return 0, syscall.EBADF
-	} else {
-		return f.Ino()
-	}
-}
-
-// IsAppend implements the same method as documented on platform.File
-func (r *lazyDir) IsAppend() bool {
-	return false
-}
-
-// SetAppend implements the same method as documented on platform.File
-func (r *lazyDir) SetAppend(bool) syscall.Errno {
-	return syscall.EISDIR
-}
-
-// Seek implements the same method as documented on platform.File
-func (r *lazyDir) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
-	if f, ok := r.file(); !ok {
-		return 0, syscall.EBADF
-	} else {
-		return f.Seek(offset, whence)
-	}
-}
-
-// Stat implements the same method as documented on platform.File
-func (r *lazyDir) Stat() (platform.Stat_t, syscall.Errno) {
-	if f, ok := r.file(); !ok {
-		return platform.Stat_t{}, syscall.EBADF
-	} else {
-		return f.Stat()
-	}
-}
-
-// Readdir implements the same method as documented on platform.File
-func (r *lazyDir) Readdir(n int) (dirents []platform.Dirent, errno syscall.Errno) {
-	if f, ok := r.file(); !ok {
-		return nil, syscall.EBADF
-	} else {
-		return f.Readdir(n)
-	}
-}
-
-// Sync implements the same method as documented on platform.File
-func (r *lazyDir) Sync() syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Sync()
-	}
-}
-
-// Datasync implements the same method as documented on platform.File
-func (r *lazyDir) Datasync() syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Datasync()
-	}
-}
-
-// Chmod implements the same method as documented on platform.File
-func (r *lazyDir) Chmod(mode fs.FileMode) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chmod(mode)
-	}
-}
-
-// Chown implements the same method as documented on platform.File
-func (r *lazyDir) Chown(uid, gid int) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chown(uid, gid)
-	}
-}
-
-// Utimens implements the same method as documented on platform.File
-func (r *lazyDir) Utimens(times *[2]syscall.Timespec) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Utimens(times)
-	}
-}
-
-// file returns the underlying file or false if it doesn't exist.
-func (r *lazyDir) file() (platform.File, bool) {
-	if f := r.f; r.f != nil {
-		return f, true
-	}
-	var errno syscall.Errno
-	r.f, errno = r.fs.OpenFile(".", os.O_RDONLY, 0)
-	switch errno {
-	case 0:
-		return r.f, true
-	case syscall.ENOENT:
-		return nil, false
-	default:
-		panic(errno) // unexpected
-	}
-}
-
-// Close implements fs.File
-func (r *lazyDir) Close() syscall.Errno {
-	f := r.f
-	if f == nil {
-		return 0 // never opened
-	}
-	return f.Close()
-}
-
 // FileEntry maps a path to an open file in a file system.
 type FileEntry struct {
 	// Name is the name of the directory up to its pre-open, or the pre-open
@@ -256,10 +47,10 @@ type FileEntry struct {
 	IsPreopen bool
 
 	// FS is the filesystem associated with the pre-open.
-	FS sysfs.FS
+	FS fsapi.FS
 
 	// File is always non-nil.
-	File platform.File
+	File fsapi.File
 
 	// ReadDir is present when this File is a fs.ReadDirFile and `ReadDir`
 	// was called.
@@ -279,12 +70,12 @@ type ReadDir struct {
 	// In wasi preview1, dot and dot-dot entries are required to exist, but the
 	// reverse is true for preview2. More importantly, preview2 holds separate
 	// stateful dir-entry-streams per file.
-	Dirents []platform.Dirent
+	Dirents []fsapi.Dirent
 }
 
 type FSContext struct {
 	// rootFS is the root ("/") mount.
-	rootFS sysfs.FS
+	rootFS fsapi.FS
 
 	// openedFiles is a map of file descriptor numbers (>=FdPreopen) to open files
 	// (or directories) and defaults to empty.
@@ -296,92 +87,15 @@ type FSContext struct {
 // descriptors to file entries.
 type FileTable = descriptor.Table[int32, *FileEntry]
 
-// NewFSContext creates a FSContext with stdio streams and an optional
-// pre-opened filesystem.
-//
-// If `preopened` is not sysfs.UnimplementedFS, it is inserted into
-// the file descriptor table as FdPreopen.
-func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS sysfs.FS) (err error) {
-	c.fsc.rootFS = rootFS
-	inFile, err := stdinFile(stdin)
-	if err != nil {
-		return err
-	}
-	c.fsc.openedFiles.Insert(inFile)
-	outWriter, err := stdioWriterFile("stdout", stdout)
-	if err != nil {
-		return err
-	}
-	c.fsc.openedFiles.Insert(outWriter)
-	errWriter, err := stdioWriterFile("stderr", stderr)
-	if err != nil {
-		return err
-	}
-	c.fsc.openedFiles.Insert(errWriter)
-
-	if _, ok := rootFS.(sysfs.UnimplementedFS); ok {
-		return nil
-	}
-
-	if comp, ok := rootFS.(*sysfs.CompositeFS); ok {
-		preopens := comp.FS()
-		for i, p := range comp.GuestPaths() {
-			c.fsc.openedFiles.Insert(&FileEntry{
-				FS:        preopens[i],
-				Name:      p,
-				IsPreopen: true,
-				File:      &lazyDir{fs: rootFS},
-			})
-		}
-	} else {
-		c.fsc.openedFiles.Insert(&FileEntry{
-			FS:        rootFS,
-			Name:      "/",
-			IsPreopen: true,
-			File:      &lazyDir{fs: rootFS},
-		})
-	}
-
-	return nil
-}
-
-func stdinFile(r io.Reader) (*FileEntry, error) {
-	if r == nil {
-		return &FileEntry{Name: "stdin", IsPreopen: true, File: &noopStdinFile{}}, nil
-	} else if f, ok := r.(*os.File); ok {
-		if f, err := platform.NewStdioFile(true, f); err != nil {
-			return nil, err
-		} else {
-			return &FileEntry{Name: "stdin", IsPreopen: true, File: f}, nil
-		}
-	} else {
-		return &FileEntry{Name: "stdin", IsPreopen: true, File: &StdinFile{Reader: r}}, nil
-	}
-}
-
-func stdioWriterFile(name string, w io.Writer) (*FileEntry, error) {
-	if w == nil {
-		return &FileEntry{Name: name, IsPreopen: true, File: &noopStdoutFile{}}, nil
-	} else if f, ok := w.(*os.File); ok {
-		if f, err := platform.NewStdioFile(false, f); err != nil {
-			return nil, err
-		} else {
-			return &FileEntry{Name: name, IsPreopen: true, File: f}, nil
-		}
-	} else {
-		return &FileEntry{Name: name, IsPreopen: true, File: &writerFile{w: w}}, nil
-	}
-}
-
 // RootFS returns the underlying filesystem. Any files that should be added to
 // the table should be inserted via InsertFile.
-func (c *FSContext) RootFS() sysfs.FS {
+func (c *FSContext) RootFS() fsapi.FS {
 	return c.rootFS
 }
 
 // OpenFile opens the file into the table and returns its file descriptor.
 // The result must be closed by CloseFile or Close.
-func (c *FSContext) OpenFile(fs sysfs.FS, path string, flag int, perm fs.FileMode) (int32, syscall.Errno) {
+func (c *FSContext) OpenFile(fs fsapi.FS, path string, flag int, perm fs.FileMode) (int32, syscall.Errno) {
 	if f, errno := fs.OpenFile(path, flag, perm); errno != 0 {
 		return 0, errno
 	} else {
@@ -456,3 +170,52 @@ func (c *FSContext) Close() (err error) {
 	c.openedFiles = FileTable{}
 	return
 }
+
+// NewFSContext creates a FSContext with stdio streams and an optional
+// pre-opened filesystem.
+//
+// If `preopened` is not UnimplementedFS, it is inserted into
+// the file descriptor table as FdPreopen.
+func (c *Context) NewFSContext(stdin io.Reader, stdout, stderr io.Writer, rootFS fsapi.FS) (err error) {
+	c.fsc.rootFS = rootFS
+	inFile, err := stdinFile(stdin)
+	if err != nil {
+		return err
+	}
+	c.fsc.openedFiles.Insert(inFile)
+	outWriter, err := stdioWriterFile("stdout", stdout)
+	if err != nil {
+		return err
+	}
+	c.fsc.openedFiles.Insert(outWriter)
+	errWriter, err := stdioWriterFile("stderr", stderr)
+	if err != nil {
+		return err
+	}
+	c.fsc.openedFiles.Insert(errWriter)
+
+	if _, ok := rootFS.(fsapi.UnimplementedFS); ok {
+		return nil
+	}
+
+	if comp, ok := rootFS.(*sysfs.CompositeFS); ok {
+		preopens := comp.FS()
+		for i, p := range comp.GuestPaths() {
+			c.fsc.openedFiles.Insert(&FileEntry{
+				FS:        preopens[i],
+				Name:      p,
+				IsPreopen: true,
+				File:      &lazyDir{fs: rootFS},
+			})
+		}
+	} else {
+		c.fsc.openedFiles.Insert(&FileEntry{
+			FS:        rootFS,
+			Name:      "/",
+			IsPreopen: true,
+			File:      &lazyDir{fs: rootFS},
+		})
+	}
+
+	return nil
+}
diff --git a/internal/sys/lazy.go b/internal/sys/lazy.go
new file mode 100644
index 00000000..df68b51b
--- /dev/null
+++ b/internal/sys/lazy.go
@@ -0,0 +1,136 @@
+package sys
+
+import (
+	"io/fs"
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+)
+
+// compile-time check to ensure lazyDir implements internalapi.File.
+var _ fsapi.File = (*lazyDir)(nil)
+
+type lazyDir struct {
+	fsapi.DirFile
+
+	fs fsapi.FS
+	f  fsapi.File
+}
+
+// Ino implements the same method as documented on internalapi.File
+func (r *lazyDir) Ino() (uint64, syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return 0, syscall.EBADF
+	} else {
+		return f.Ino()
+	}
+}
+
+// IsAppend implements the same method as documented on internalapi.File
+func (r *lazyDir) IsAppend() bool {
+	return false
+}
+
+// SetAppend implements the same method as documented on internalapi.File
+func (r *lazyDir) SetAppend(bool) syscall.Errno {
+	return syscall.EISDIR
+}
+
+// Seek implements the same method as documented on internalapi.File
+func (r *lazyDir) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return 0, syscall.EBADF
+	} else {
+		return f.Seek(offset, whence)
+	}
+}
+
+// Stat implements the same method as documented on internalapi.File
+func (r *lazyDir) Stat() (fsapi.Stat_t, syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return fsapi.Stat_t{}, syscall.EBADF
+	} else {
+		return f.Stat()
+	}
+}
+
+// Readdir implements the same method as documented on internalapi.File
+func (r *lazyDir) Readdir(n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return nil, syscall.EBADF
+	} else {
+		return f.Readdir(n)
+	}
+}
+
+// Sync implements the same method as documented on internalapi.File
+func (r *lazyDir) Sync() syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Sync()
+	}
+}
+
+// Datasync implements the same method as documented on internalapi.File
+func (r *lazyDir) Datasync() syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Datasync()
+	}
+}
+
+// Chmod implements the same method as documented on internalapi.File
+func (r *lazyDir) Chmod(mode fs.FileMode) syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Chmod(mode)
+	}
+}
+
+// Chown implements the same method as documented on internalapi.File
+func (r *lazyDir) Chown(uid, gid int) syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Chown(uid, gid)
+	}
+}
+
+// Utimens implements the same method as documented on internalapi.File
+func (r *lazyDir) Utimens(times *[2]syscall.Timespec) syscall.Errno {
+	if f, ok := r.file(); !ok {
+		return syscall.EBADF
+	} else {
+		return f.Utimens(times)
+	}
+}
+
+// file returns the underlying file or false if it doesn't exist.
+func (r *lazyDir) file() (fsapi.File, bool) {
+	if f := r.f; r.f != nil {
+		return f, true
+	}
+	var errno syscall.Errno
+	r.f, errno = r.fs.OpenFile(".", os.O_RDONLY, 0)
+	switch errno {
+	case 0:
+		return r.f, true
+	case syscall.ENOENT:
+		return nil, false
+	default:
+		panic(errno) // unexpected
+	}
+}
+
+// Close implements fs.File
+func (r *lazyDir) Close() syscall.Errno {
+	f := r.f
+	if f == nil {
+		return 0 // never opened
+	}
+	return f.Close()
+}
diff --git a/internal/sys/stdio.go b/internal/sys/stdio.go
new file mode 100644
index 00000000..69bc8a49
--- /dev/null
+++ b/internal/sys/stdio.go
@@ -0,0 +1,121 @@
+package sys
+
+import (
+	"io"
+	"os"
+	"syscall"
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/sysfs"
+)
+
+// StdinFile is a fs.ModeDevice file for use implementing FdStdin.
+// This is safer than reading from os.DevNull as it can never overrun
+// operating system file descriptors.
+type StdinFile struct {
+	noopStdinFile
+	io.Reader
+}
+
+// Read implements the same method as documented on internalapi.File
+func (f *StdinFile) Read(buf []byte) (int, syscall.Errno) {
+	n, err := f.Reader.Read(buf)
+	return n, platform.UnwrapOSError(err)
+}
+
+type writerFile struct {
+	noopStdoutFile
+
+	w io.Writer
+}
+
+// Write implements the same method as documented on internalapi.File
+func (f *writerFile) Write(buf []byte) (int, syscall.Errno) {
+	n, err := f.w.Write(buf)
+	return n, platform.UnwrapOSError(err)
+}
+
+// noopStdinFile is a fs.ModeDevice file for use implementing FdStdin. This is
+// safer than reading from os.DevNull as it can never overrun operating system
+// file descriptors.
+type noopStdinFile struct {
+	noopStdioFile
+}
+
+// AccessMode implements the same method as documented on internalapi.File
+func (noopStdinFile) AccessMode() int {
+	return syscall.O_RDONLY
+}
+
+// Read implements the same method as documented on internalapi.File
+func (noopStdinFile) Read([]byte) (int, syscall.Errno) {
+	return 0, 0 // Always EOF
+}
+
+// PollRead implements the same method as documented on internalapi.File
+func (noopStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
+	return true, 0 // always ready to read nothing
+}
+
+// noopStdoutFile is a fs.ModeDevice file for use implementing FdStdout and
+// FdStderr.
+type noopStdoutFile struct {
+	noopStdioFile
+}
+
+// AccessMode implements the same method as documented on internalapi.File
+func (noopStdoutFile) AccessMode() int {
+	return syscall.O_WRONLY
+}
+
+// Write implements the same method as documented on internalapi.File
+func (noopStdoutFile) Write(buf []byte) (int, syscall.Errno) {
+	return len(buf), 0 // same as io.Discard
+}
+
+type noopStdioFile struct {
+	fsapi.UnimplementedFile
+}
+
+// Stat implements the same method as documented on internalapi.File
+func (noopStdioFile) Stat() (fsapi.Stat_t, syscall.Errno) {
+	return fsapi.Stat_t{Mode: modeDevice, Nlink: 1}, 0
+}
+
+// IsDir implements the same method as documented on internalapi.File
+func (noopStdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
+
+// Close implements the same method as documented on internalapi.File
+func (noopStdioFile) Close() (errno syscall.Errno) { return }
+
+func stdinFile(r io.Reader) (*FileEntry, error) {
+	if r == nil {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &noopStdinFile{}}, nil
+	} else if f, ok := r.(*os.File); ok {
+		if f, err := sysfs.NewStdioFile(true, f); err != nil {
+			return nil, err
+		} else {
+			return &FileEntry{Name: "stdin", IsPreopen: true, File: f}, nil
+		}
+	} else {
+		return &FileEntry{Name: "stdin", IsPreopen: true, File: &StdinFile{Reader: r}}, nil
+	}
+}
+
+func stdioWriterFile(name string, w io.Writer) (*FileEntry, error) {
+	if w == nil {
+		return &FileEntry{Name: name, IsPreopen: true, File: &noopStdoutFile{}}, nil
+	} else if f, ok := w.(*os.File); ok {
+		if f, err := sysfs.NewStdioFile(false, f); err != nil {
+			return nil, err
+		} else {
+			return &FileEntry{Name: name, IsPreopen: true, File: f}, nil
+		}
+	} else {
+		return &FileEntry{Name: name, IsPreopen: true, File: &writerFile{w: w}}, nil
+	}
+}
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index cdf05802..aaa9dabf 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -6,8 +6,8 @@ import (
 	"io"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/platform"
-	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/sys"
 )
 
@@ -97,7 +97,7 @@ func (c *Context) Osyield() {
 	c.osyield()
 }
 
-// FS returns the possibly empty (sysfs.UnimplementedFS) file system context.
+// FS returns the possibly empty (UnimplementedFS) file system context.
 func (c *Context) FS() *FSContext {
 	return &c.fsc
 }
@@ -111,7 +111,7 @@ func (c *Context) RandSource() io.Reader {
 // DefaultContext returns Context with no values set except a possible nil fs.FS
 //
 // This is only used for testing.
-func DefaultContext(fs sysfs.FS) *Context {
+func DefaultContext(fs fsapi.FS) *Context {
 	if sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, fs); err != nil {
 		panic(fmt.Errorf("BUG: DefaultContext should never error: %w", err))
 	} else {
@@ -133,7 +133,7 @@ func NewContext(
 	nanotimeResolution sys.ClockResolution,
 	nanosleep sys.Nanosleep,
 	osyield sys.Osyield,
-	rootFS sysfs.FS,
+	rootFS fsapi.FS,
 ) (sysCtx *Context, err error) {
 	sysCtx = &Context{args: args, environ: environ}
 
@@ -188,7 +188,7 @@ func NewContext(
 	if rootFS != nil {
 		err = sysCtx.NewFSContext(stdin, stdout, stderr, rootFS)
 	} else {
-		err = sysCtx.NewFSContext(stdin, stdout, stderr, sysfs.UnimplementedFS{})
+		err = sysCtx.NewFSContext(stdin, stdout, stderr, fsapi.UnimplementedFS{})
 	}
 
 	return
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index 87189aba..022ca697 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -6,28 +6,28 @@ import (
 	"path"
 	"syscall"
 
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 )
 
-// Adapt adapts the input to FS unless it is already one. Use NewDirFS instead
+// Adapt adapts the input to api.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 FS.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. fsapi.FS cannot read
+// flags as there is no parameter to pass them through with. Moreover, fsapi.FS
 // documentation does not require the file to be present. In summary, we can't
 // enforce flag behavior.
-func Adapt(fs fs.FS) FS {
+func Adapt(fs fs.FS) fsapi.FS {
 	if fs == nil {
-		return UnimplementedFS{}
+		return fsapi.UnimplementedFS{}
 	}
-	if sys, ok := fs.(FS); ok {
+	if sys, ok := fs.(fsapi.FS); ok {
 		return sys
 	}
 	return &adapter{fs: fs}
 }
 
 type adapter struct {
-	UnimplementedFS
+	fsapi.UnimplementedFS
 	fs fs.FS
 }
 
@@ -36,26 +36,26 @@ func (a *adapter) String() string {
 	return fmt.Sprintf("%v", a.fs)
 }
 
-// OpenFile implements FS.OpenFile
-func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
-	return platform.OpenFSFile(a.fs, cleanPath(path), flag, perm)
+// OpenFile implements the same method as documented on api.FS
+func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
+	return OpenFSFile(a.fs, cleanPath(path), flag, perm)
 }
 
-// Stat implements FS.Stat
-func (a *adapter) Stat(path string) (platform.Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.FS
+func (a *adapter) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	f, errno := a.OpenFile(path, syscall.O_RDONLY, 0)
 	if errno != 0 {
-		return platform.Stat_t{}, errno
+		return fsapi.Stat_t{}, errno
 	}
 	defer f.Close()
 	return f.Stat()
 }
 
-// Lstat implements FS.Lstat
-func (a *adapter) Lstat(path string) (platform.Stat_t, syscall.Errno) {
-	// At this time, we make the assumption that fs.FS instances do not support
+// Lstat implements the same method as documented on api.FS
+func (a *adapter) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
+	// At this time, we make the assumption that api.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
+	// not true but until api.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.
 	//
diff --git a/internal/platform/chown.go b/internal/sysfs/chown.go
similarity index 85%
rename from internal/platform/chown.go
rename to internal/sysfs/chown.go
index dfbf6bdb..93c774c9 100644
--- a/internal/platform/chown.go
+++ b/internal/sysfs/chown.go
@@ -1,8 +1,10 @@
-package platform
+package sysfs
 
 import (
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // Chown is like os.Chown, except it returns a syscall.Errno, not a
@@ -13,7 +15,7 @@ import (
 // See https://linux.die.net/man/3/chown
 func Chown(path string, uid, gid int) syscall.Errno {
 	err := os.Chown(path, uid, gid)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Lchown is like os.Lchown, except it returns a syscall.Errno, not a
@@ -24,5 +26,5 @@ func Chown(path string, uid, gid int) syscall.Errno {
 // See https://linux.die.net/man/3/lchown
 func Lchown(path string, uid, gid int) syscall.Errno {
 	err := os.Lchown(path, uid, gid)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
diff --git a/internal/sysfs/chown_unix.go b/internal/sysfs/chown_unix.go
new file mode 100644
index 00000000..5907a9d9
--- /dev/null
+++ b/internal/sysfs/chown_unix.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package sysfs
+
+import (
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func fchown(fd uintptr, uid, gid int) syscall.Errno {
+	return platform.UnwrapOSError(syscall.Fchown(int(fd), uid, gid))
+}
diff --git a/internal/platform/chown_unsupported.go b/internal/sysfs/chown_unsupported.go
similarity index 93%
rename from internal/platform/chown_unsupported.go
rename to internal/sysfs/chown_unsupported.go
index 38b7187d..5e7fd79b 100644
--- a/internal/platform/chown_unsupported.go
+++ b/internal/sysfs/chown_unsupported.go
@@ -1,6 +1,6 @@
 //go:build windows
 
-package platform
+package sysfs
 
 import "syscall"
 
diff --git a/internal/sysfs/datasync_linux.go b/internal/sysfs/datasync_linux.go
new file mode 100644
index 00000000..715a952d
--- /dev/null
+++ b/internal/sysfs/datasync_linux.go
@@ -0,0 +1,14 @@
+//go:build linux
+
+package sysfs
+
+import (
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func datasync(f *os.File) syscall.Errno {
+	return platform.UnwrapOSError(syscall.Fdatasync(int(f.Fd())))
+}
diff --git a/internal/platform/datasync_unsupported.go b/internal/sysfs/datasync_unsupported.go
similarity index 91%
rename from internal/platform/datasync_unsupported.go
rename to internal/sysfs/datasync_unsupported.go
index 9d3127fc..91a2acf2 100644
--- a/internal/platform/datasync_unsupported.go
+++ b/internal/sysfs/datasync_unsupported.go
@@ -1,6 +1,6 @@
 //go:build !linux
 
-package platform
+package sysfs
 
 import (
 	"os"
diff --git a/internal/sysfs/dir.go b/internal/sysfs/dir.go
new file mode 100644
index 00000000..8462e846
--- /dev/null
+++ b/internal/sysfs/dir.go
@@ -0,0 +1,26 @@
+package sysfs
+
+import (
+	"io"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func adjustReaddirErr(f fsapi.File, isClosed bool, err error) syscall.Errno {
+	if err == io.EOF {
+		return 0 // e.g. Readdir on darwin returns io.EOF, but linux doesn't.
+	} else if errno := platform.UnwrapOSError(err); errno != 0 {
+		errno = dirError(f, isClosed, errno)
+		// Ignore errors when the file was closed or removed.
+		switch errno {
+		case syscall.EIO, syscall.EBADF: // closed while open
+			return 0
+		case syscall.ENOENT: // Linux error when removed while open
+			return 0
+		}
+		return errno
+	}
+	return 0
+}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index ab2203ef..5f2645de 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -5,10 +5,11 @@ import (
 	"os"
 	"syscall"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func NewDirFS(dir string) FS {
+func NewDirFS(dir string) fsapi.FS {
 	return &dirFS{
 		dir:        dir,
 		cleanedDir: ensureTrailingPathSeparator(dir),
@@ -23,7 +24,7 @@ func ensureTrailingPathSeparator(dir string) string {
 }
 
 type dirFS struct {
-	UnimplementedFS
+	fsapi.UnimplementedFS
 	dir string
 	// cleanedDir is for easier OS-specific concatenation, as it always has
 	// a trailing path separator.
@@ -35,22 +36,22 @@ func (d *dirFS) String() string {
 	return d.dir
 }
 
-// OpenFile implements FS.OpenFile
-func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
-	return platform.OpenOSFile(d.join(path), flag, perm)
+// OpenFile implements the same method as documented on api.FS
+func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
+	return OpenOSFile(d.join(path), flag, perm)
 }
 
-// Lstat implements FS.Lstat
-func (d *dirFS) Lstat(path string) (platform.Stat_t, syscall.Errno) {
-	return platform.Lstat(d.join(path))
+// Lstat implements the same method as documented on api.FS
+func (d *dirFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
+	return lstat(d.join(path))
 }
 
-// Stat implements FS.Stat
-func (d *dirFS) Stat(path string) (platform.Stat_t, syscall.Errno) {
-	return platform.Stat(d.join(path))
+// Stat implements the same method as documented on api.FS
+func (d *dirFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
+	return stat(d.join(path))
 }
 
-// Mkdir implements FS.Mkdir
+// Mkdir implements the same method as documented on api.FS
 func (d *dirFS) Mkdir(path string, perm fs.FileMode) (errno syscall.Errno) {
 	err := os.Mkdir(d.join(path), perm)
 	if errno = platform.UnwrapOSError(err); errno == syscall.ENOTDIR {
@@ -59,29 +60,29 @@ func (d *dirFS) Mkdir(path string, perm fs.FileMode) (errno syscall.Errno) {
 	return
 }
 
-// Chmod implements FS.Chmod
+// Chmod implements the same method as documented on api.FS
 func (d *dirFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	err := os.Chmod(d.join(path), perm)
 	return platform.UnwrapOSError(err)
 }
 
-// Chown implements FS.Chown
+// Chown implements the same method as documented on api.FS
 func (d *dirFS) Chown(path string, uid, gid int) syscall.Errno {
-	return platform.Chown(d.join(path), uid, gid)
+	return Chown(d.join(path), uid, gid)
 }
 
-// Lchown implements FS.Lchown
+// Lchown implements the same method as documented on api.FS
 func (d *dirFS) Lchown(path string, uid, gid int) syscall.Errno {
-	return platform.Lchown(d.join(path), uid, gid)
+	return Lchown(d.join(path), uid, gid)
 }
 
-// Rename implements FS.Rename
+// Rename implements the same method as documented on api.FS
 func (d *dirFS) Rename(from, to string) syscall.Errno {
 	from, to = d.join(from), d.join(to)
-	return platform.Rename(from, to)
+	return Rename(from, to)
 }
 
-// Readlink implements FS.Readlink
+// Readlink implements the same method as documented on api.FS
 func (d *dirFS) Readlink(path string) (string, syscall.Errno) {
 	// Note: do not use syscall.Readlink as that causes race on Windows.
 	// In any case, syscall.Readlink does almost the same logic as os.Readlink.
@@ -92,24 +93,24 @@ func (d *dirFS) Readlink(path string) (string, syscall.Errno) {
 	return platform.ToPosixPath(dst), 0
 }
 
-// Link implements FS.Link.
+// Link implements the same method as documented on api.FS
 func (d *dirFS) Link(oldName, newName string) syscall.Errno {
 	err := os.Link(d.join(oldName), d.join(newName))
 	return platform.UnwrapOSError(err)
 }
 
-// Rmdir implements FS.Rmdir
+// Rmdir implements the same method as documented on api.FS
 func (d *dirFS) Rmdir(path string) syscall.Errno {
 	err := syscall.Rmdir(d.join(path))
 	return platform.UnwrapOSError(err)
 }
 
-// Unlink implements FS.Unlink
+// Unlink implements the same method as documented on api.FS
 func (d *dirFS) Unlink(path string) (err syscall.Errno) {
-	return platform.Unlink(d.join(path))
+	return Unlink(d.join(path))
 }
 
-// Symlink implements FS.Symlink
+// Symlink implements the same method as documented on api.FS
 func (d *dirFS) Symlink(oldName, link string) syscall.Errno {
 	// Note: do not resolve `oldName` relative to this dirFS. The link result is always resolved
 	// when dereference the `link` on its usage (e.g. readlink, read, etc).
@@ -118,12 +119,12 @@ func (d *dirFS) Symlink(oldName, link string) syscall.Errno {
 	return platform.UnwrapOSError(err)
 }
 
-// Utimens implements FS.Utimens
+// Utimens implements the same method as documented on api.FS
 func (d *dirFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
-	return platform.Utimens(d.join(path), times, symlinkFollow)
+	return Utimens(d.join(path), times, symlinkFollow)
 }
 
-// Truncate implements FS.Truncate
+// Truncate implements the same method as documented on api.FS
 func (d *dirFS) Truncate(path string, size int64) syscall.Errno {
 	// Use os.Truncate as syscall.Truncate doesn't exist on Windows.
 	err := os.Truncate(d.join(path), size)
diff --git a/internal/sysfs/file.go b/internal/sysfs/file.go
new file mode 100644
index 00000000..f4a7cbf8
--- /dev/null
+++ b/internal/sysfs/file.go
@@ -0,0 +1,434 @@
+package sysfs
+
+import (
+	"io"
+	"io/fs"
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func NewStdioFile(stdin bool, f fs.File) (fsapi.File, error) {
+	// Return constant stat, which has fake times, but keep the underlying
+	// file mode. Fake times are needed to pass wasi-testsuite.
+	// https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+	var mode fs.FileMode
+	if st, err := f.Stat(); err != nil {
+		return nil, err
+	} else {
+		mode = st.Mode()
+	}
+	var flag int
+	if stdin {
+		flag = syscall.O_RDONLY
+	} else {
+		flag = syscall.O_WRONLY
+	}
+	var file fsapi.File
+	if of, ok := f.(*os.File); ok {
+		// This is ok because functions that need path aren't used by stdioFile
+		file = newOsFile("", flag, 0, of)
+	} else {
+		file = &fsFile{file: f}
+	}
+	return &stdioFile{File: file, st: fsapi.Stat_t{Mode: mode, Nlink: 1}}, nil
+}
+
+func OpenFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
+	if flag&fsapi.O_DIRECTORY != 0 && flag&(syscall.O_WRONLY|syscall.O_RDWR) != 0 {
+		return nil, syscall.EISDIR // invalid to open a directory writeable
+	}
+	return openFile(path, flag, perm)
+}
+
+func OpenOSFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
+	f, errno := OpenFile(path, flag, perm)
+	if errno != 0 {
+		return nil, errno
+	}
+	return newOsFile(path, flag, perm, f), 0
+}
+
+func OpenFSFile(fs fs.FS, path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
+	if flag&fsapi.O_DIRECTORY != 0 && flag&(syscall.O_WRONLY|syscall.O_RDWR) != 0 {
+		return nil, syscall.EISDIR // invalid to open a directory writeable
+	}
+	f, err := fs.Open(path)
+	if errno := platform.UnwrapOSError(err); errno != 0 {
+		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.
+	return &fsFile{fs: fs, name: path, file: f}, 0
+}
+
+type stdioFile struct {
+	fsapi.File
+	st fsapi.Stat_t
+}
+
+// IsDir implements File.IsDir
+func (f *stdioFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
+
+// Stat implements File.Stat
+func (f *stdioFile) Stat() (fsapi.Stat_t, syscall.Errno) {
+	return f.st, 0
+}
+
+// Close implements File.Close
+func (f *stdioFile) Close() syscall.Errno {
+	return 0
+}
+
+// fsFile is used for wrapped os.File, like os.Stdin or any fs.File
+// implementation. Notably, this does not have access to the full file path.
+// so certain operations can't be supported, such as inode lookups on Windows.
+type fsFile struct {
+	fsapi.UnimplementedFile
+
+	// fs is the file-system that opened the file, or nil when wrapped for
+	// pre-opens like stdio.
+	fs fs.FS
+
+	// name is what was used in fs for Open, so it may not be the actual path.
+	name string
+
+	// file is always set, possibly an os.File like os.Stdin.
+	file fs.File
+
+	// closed is true when closed was called. This ensures proper syscall.EBADF
+	closed bool
+
+	// cachedStat includes fields that won't change while a file is open.
+	cachedSt *cachedStat
+}
+
+type cachedStat struct {
+	// fileType is the same as what's documented on Dirent.
+	fileType fs.FileMode
+
+	// ino is the same as what's documented on Dirent.
+	ino uint64
+}
+
+// cachedStat returns the cacheable parts of platform.sys.Stat_t or an error if
+// they couldn't be retrieved.
+func (f *fsFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.Errno) {
+	if f.cachedSt == nil {
+		if _, errno = f.Stat(); errno != 0 {
+			return
+		}
+	}
+	return f.cachedSt.fileType, f.cachedSt.ino, 0
+}
+
+// Ino implements File.Ino
+func (f *fsFile) Ino() (uint64, syscall.Errno) {
+	if _, ino, errno := f.cachedStat(); errno != 0 {
+		return 0, errno
+	} else {
+		return ino, 0
+	}
+}
+
+// IsAppend implements File.IsAppend
+func (f *fsFile) IsAppend() bool {
+	return false
+}
+
+// SetAppend implements File.SetAppend
+func (f *fsFile) SetAppend(bool) (errno syscall.Errno) {
+	return fileError(f, f.closed, syscall.ENOSYS)
+}
+
+// IsDir implements File.IsDir
+func (f *fsFile) IsDir() (bool, syscall.Errno) {
+	if ft, _, errno := f.cachedStat(); errno != 0 {
+		return false, errno
+	} else if ft.Type() == fs.ModeDir {
+		return true, 0
+	}
+	return false, 0
+}
+
+// Stat implements File.Stat
+func (f *fsFile) Stat() (st fsapi.Stat_t, errno syscall.Errno) {
+	if f.closed {
+		errno = syscall.EBADF
+		return
+	}
+
+	// While some functions in fsapi.File need the full path, especially in
+	// Windows, stat does not. Casting here allows os.DirFS to return inode
+	// information.
+	if of, ok := f.file.(*os.File); ok {
+		if st, errno = statFile(of); errno != 0 {
+			return
+		}
+		return f.cacheStat(st)
+	} else if t, err := f.file.Stat(); err != nil {
+		errno = platform.UnwrapOSError(err)
+		return
+	} else {
+		st = StatFromDefaultFileInfo(t)
+		return f.cacheStat(st)
+	}
+}
+
+func (f *fsFile) cacheStat(st fsapi.Stat_t) (fsapi.Stat_t, syscall.Errno) {
+	f.cachedSt = &cachedStat{fileType: st.Mode & fs.ModeType, ino: st.Ino}
+	return st, 0
+}
+
+// Read implements File.Read
+func (f *fsFile) Read(buf []byte) (n int, errno syscall.Errno) {
+	if n, errno = read(f.file, buf); errno != 0 {
+		// Defer validation overhead until we've already had an error.
+		errno = fileError(f, f.closed, errno)
+	}
+	return
+}
+
+// Pread implements File.Pread
+func (f *fsFile) Pread(buf []byte, off int64) (n int, errno syscall.Errno) {
+	if ra, ok := f.file.(io.ReaderAt); ok {
+		if n, errno = pread(ra, buf, off); errno != 0 {
+			// Defer validation overhead until we've already had an error.
+			errno = fileError(f, f.closed, errno)
+		}
+		return
+	}
+
+	// See /RATIONALE.md "fd_pread: io.Seeker fallback when io.ReaderAt is not supported"
+	if rs, ok := f.file.(io.ReadSeeker); ok {
+		// Determine the current position in the file, as we need to revert it.
+		currentOffset, err := rs.Seek(0, io.SeekCurrent)
+		if err != nil {
+			return 0, fileError(f, f.closed, platform.UnwrapOSError(err))
+		}
+
+		// Put the read position back when complete.
+		defer func() { _, _ = rs.Seek(currentOffset, io.SeekStart) }()
+
+		// If the current offset isn't in sync with this reader, move it.
+		if off != currentOffset {
+			if _, err = rs.Seek(off, io.SeekStart); err != nil {
+				return 0, fileError(f, f.closed, platform.UnwrapOSError(err))
+			}
+		}
+
+		n, err = rs.Read(buf)
+		if errno = platform.UnwrapOSError(err); errno != 0 {
+			// Defer validation overhead until we've already had an error.
+			errno = fileError(f, f.closed, errno)
+		}
+	} else {
+		errno = syscall.ENOSYS // unsupported
+	}
+	return
+}
+
+// Seek implements File.Seek.
+func (f *fsFile) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
+	// If this is a directory, and we're attempting to seek to position zero,
+	// we have to re-open the file to ensure the directory state is reset.
+	var isDir bool
+	if offset == 0 && whence == io.SeekStart {
+		if isDir, errno = f.IsDir(); errno != 0 {
+			return
+		} else if isDir {
+			return 0, f.reopen()
+		}
+	}
+
+	if s, ok := f.file.(io.Seeker); ok {
+		if newOffset, errno = seek(s, offset, whence); errno != 0 {
+			// Defer validation overhead until we've already had an error.
+			errno = fileError(f, f.closed, errno)
+		}
+	} else {
+		errno = syscall.ENOSYS // unsupported
+	}
+	return
+}
+
+func (f *fsFile) reopen() syscall.Errno {
+	_ = f.close()
+	var err error
+	f.file, err = f.fs.Open(f.name)
+	return platform.UnwrapOSError(err)
+}
+
+// Readdir implements File.Readdir. Notably, this uses fs.ReadDirFile if
+// available.
+func (f *fsFile) Readdir(n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
+	if of, ok := f.file.(*os.File); ok {
+		// We can't use f.name here because it is the path up to the fsapi.FS,
+		// not necessarily the real path. For this reason, Windows may not be
+		// able to populate inodes. However, Darwin and Linux will.
+		if dirents, errno = readdir(of, "", n); errno != 0 {
+			errno = adjustReaddirErr(f, f.closed, errno)
+		}
+		return
+	}
+
+	// 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 {
+			return
+		}
+		dirents = make([]fsapi.Dirent, 0, len(entries))
+		for _, e := range entries {
+			// By default, we don't attempt to read inode data
+			dirents = append(dirents, fsapi.Dirent{Name: e.Name(), Type: e.Type()})
+		}
+	} else {
+		errno = syscall.ENOTDIR
+	}
+	return
+}
+
+// Write implements File.Write
+func (f *fsFile) Write(buf []byte) (n int, errno syscall.Errno) {
+	if w, ok := f.file.(io.Writer); ok {
+		if n, errno = write(w, buf); errno != 0 {
+			// Defer validation overhead until we've already had an error.
+			errno = fileError(f, f.closed, errno)
+		}
+	} else {
+		errno = syscall.ENOSYS // unsupported
+	}
+	return
+}
+
+// Pwrite implements File.Pwrite
+func (f *fsFile) Pwrite(buf []byte, off int64) (n int, errno syscall.Errno) {
+	if wa, ok := f.file.(io.WriterAt); ok {
+		if n, errno = pwrite(wa, buf, off); errno != 0 {
+			// Defer validation overhead until we've already had an error.
+			errno = fileError(f, f.closed, errno)
+		}
+	} else {
+		errno = syscall.ENOSYS // unsupported
+	}
+	return
+}
+
+// Close implements File.Close
+func (f *fsFile) Close() syscall.Errno {
+	if f.closed {
+		return 0
+	}
+	f.closed = true
+	return f.close()
+}
+
+func (f *fsFile) close() syscall.Errno {
+	return platform.UnwrapOSError(f.file.Close())
+}
+
+// dirError is used for commands that work against a directory, but not a file.
+func dirError(f fsapi.File, isClosed bool, errno syscall.Errno) syscall.Errno {
+	if vErrno := validate(f, isClosed, false, true); vErrno != 0 {
+		return vErrno
+	}
+	return errno
+}
+
+// fileError is used for commands that work against a file, but not a directory.
+func fileError(f fsapi.File, isClosed bool, errno syscall.Errno) syscall.Errno {
+	if vErrno := validate(f, isClosed, true, false); vErrno != 0 {
+		return vErrno
+	}
+	return errno
+}
+
+// validate is used to making syscalls which will fail.
+func validate(f fsapi.File, isClosed, wantFile, wantDir bool) syscall.Errno {
+	if isClosed {
+		return syscall.EBADF
+	}
+
+	isDir, errno := f.IsDir()
+	if errno != 0 {
+		return errno
+	}
+
+	if wantFile && isDir {
+		return syscall.EISDIR
+	} else if wantDir && !isDir {
+		return syscall.ENOTDIR
+	}
+	return 0
+}
+
+func read(r io.Reader, buf []byte) (n int, errno syscall.Errno) {
+	if len(buf) == 0 {
+		return 0, 0 // less overhead on zero-length reads.
+	}
+
+	n, err := r.Read(buf)
+	return n, platform.UnwrapOSError(err)
+}
+
+func pread(ra io.ReaderAt, buf []byte, off int64) (n int, errno syscall.Errno) {
+	if len(buf) == 0 {
+		return 0, 0 // less overhead on zero-length reads.
+	}
+
+	n, err := ra.ReadAt(buf, off)
+	return n, platform.UnwrapOSError(err)
+}
+
+func seek(s io.Seeker, offset int64, whence int) (int64, syscall.Errno) {
+	if uint(whence) > io.SeekEnd {
+		return 0, syscall.EINVAL // negative or exceeds the largest valid whence
+	}
+
+	newOffset, err := s.Seek(offset, whence)
+	return newOffset, platform.UnwrapOSError(err)
+}
+
+func readdir(f *os.File, path string, n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
+	fis, e := f.Readdir(n)
+	if errno = platform.UnwrapOSError(e); errno != 0 {
+		return
+	}
+
+	dirents = make([]fsapi.Dirent, 0, len(fis))
+
+	// linux/darwin won't have to fan out to lstat, but windows will.
+	var ino uint64
+	for fi := range fis {
+		t := fis[fi]
+		if ino, errno = inoFromFileInfo(path, t); errno != 0 {
+			return
+		}
+		dirents = append(dirents, fsapi.Dirent{Name: t.Name(), Ino: ino, Type: t.Mode().Type()})
+	}
+	return
+}
+
+func write(w io.Writer, buf []byte) (n int, errno syscall.Errno) {
+	if len(buf) == 0 {
+		return 0, 0 // less overhead on zero-length writes.
+	}
+
+	n, err := w.Write(buf)
+	return n, platform.UnwrapOSError(err)
+}
+
+func pwrite(w io.WriterAt, buf []byte, off int64) (n int, errno syscall.Errno) {
+	if len(buf) == 0 {
+		return 0, 0 // less overhead on zero-length writes.
+	}
+
+	n, err := w.WriteAt(buf, off)
+	return n, platform.UnwrapOSError(err)
+}
diff --git a/internal/platform/futimens.go b/internal/sysfs/futimens.go
similarity index 94%
rename from internal/platform/futimens.go
rename to internal/sysfs/futimens.go
index 084b4836..1c2bcead 100644
--- a/internal/platform/futimens.go
+++ b/internal/sysfs/futimens.go
@@ -1,9 +1,12 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
 	"time"
 	"unsafe"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 const (
@@ -44,9 +47,11 @@ const (
 //     POSIX. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
 func Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
 	err := utimens(path, times, symlinkFollow)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
+var _zero uintptr //nolint:unused
+
 func timesToPtr(times *[2]syscall.Timespec) unsafe.Pointer { //nolint:unused
 	var _p0 unsafe.Pointer
 	if times != nil {
@@ -102,7 +107,7 @@ func normalizeTimespec(path string, times *[2]syscall.Timespec, i int) (ts sysca
 		// stat to read-back the value to re-apply.
 		// - https://github.com/golang/go/issues/32558.
 		// - https://go-review.googlesource.com/c/go/+/219638 (unmerged)
-		var st Stat_t
+		var st fsapi.Stat_t
 		if st, err = stat(path); err != 0 {
 			return
 		}
diff --git a/internal/platform/futimens_darwin.go b/internal/sysfs/futimens_darwin.go
similarity index 98%
rename from internal/platform/futimens_darwin.go
rename to internal/sysfs/futimens_darwin.go
index 1dfd1326..f4ede337 100644
--- a/internal/platform/futimens_darwin.go
+++ b/internal/sysfs/futimens_darwin.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
diff --git a/internal/platform/futimens_darwin.s b/internal/sysfs/futimens_darwin.s
similarity index 100%
rename from internal/platform/futimens_darwin.s
rename to internal/sysfs/futimens_darwin.s
diff --git a/internal/platform/futimens_linux.go b/internal/sysfs/futimens_linux.go
similarity index 98%
rename from internal/platform/futimens_linux.go
rename to internal/sysfs/futimens_linux.go
index 07ebf2e2..a7ae264d 100644
--- a/internal/platform/futimens_linux.go
+++ b/internal/sysfs/futimens_linux.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
diff --git a/internal/platform/futimens_unsupported.go b/internal/sysfs/futimens_unsupported.go
similarity index 97%
rename from internal/platform/futimens_unsupported.go
rename to internal/sysfs/futimens_unsupported.go
index 3fb3c92b..60860e6c 100644
--- a/internal/platform/futimens_unsupported.go
+++ b/internal/sysfs/futimens_unsupported.go
@@ -1,6 +1,6 @@
 //go:build !windows && !linux && !darwin
 
-package platform
+package sysfs
 
 import "syscall"
 
diff --git a/internal/platform/futimens_windows.go b/internal/sysfs/futimens_windows.go
similarity index 95%
rename from internal/platform/futimens_windows.go
rename to internal/sysfs/futimens_windows.go
index c0bc03fa..39696067 100644
--- a/internal/platform/futimens_windows.go
+++ b/internal/sysfs/futimens_windows.go
@@ -1,8 +1,10 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
 	"time"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // Define values even if not used except as sentinels.
@@ -19,7 +21,7 @@ func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error
 func futimens(fd uintptr, times *[2]syscall.Timespec) error {
 	// Before Go 1.20, ERROR_INVALID_HANDLE was returned for too many reasons.
 	// Kick out so that callers can use path-based operations instead.
-	if !IsGo120 {
+	if !platform.IsGo120 {
 		return syscall.ENOSYS
 	}
 
diff --git a/internal/platform/nonblock_unix.go b/internal/sysfs/nonblock_unix.go
similarity index 88%
rename from internal/platform/nonblock_unix.go
rename to internal/sysfs/nonblock_unix.go
index 0af1471b..1ac13e53 100644
--- a/internal/platform/nonblock_unix.go
+++ b/internal/sysfs/nonblock_unix.go
@@ -1,6 +1,6 @@
 //go:build !windows
 
-package platform
+package sysfs
 
 import "syscall"
 
diff --git a/internal/platform/nonblock_windows.go b/internal/sysfs/nonblock_windows.go
similarity index 89%
rename from internal/platform/nonblock_windows.go
rename to internal/sysfs/nonblock_windows.go
index 0bac6918..d4a29ac3 100644
--- a/internal/platform/nonblock_windows.go
+++ b/internal/sysfs/nonblock_windows.go
@@ -1,6 +1,6 @@
 //go:build windows
 
-package platform
+package sysfs
 
 import "syscall"
 
diff --git a/internal/platform/open_file.go b/internal/sysfs/open_file.go
similarity index 58%
rename from internal/platform/open_file.go
rename to internal/sysfs/open_file.go
index fc68986f..473f8ca6 100644
--- a/internal/platform/open_file.go
+++ b/internal/sysfs/open_file.go
@@ -1,22 +1,17 @@
 //go:build !windows && !js && !illumos && !solaris
 
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"syscall"
-)
 
-// Simple aliases to constants in the syscall package for portability with
-// platforms which do not have them (e.g. windows)
-const (
-	O_DIRECTORY = syscall.O_DIRECTORY
-	O_NOFOLLOW  = syscall.O_NOFOLLOW
-	O_NONBLOCK  = syscall.O_NONBLOCK
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) File {
+func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) fsapi.File {
 	return newDefaultOsFile(openPath, openFlag, openPerm, f)
 }
 
@@ -24,8 +19,8 @@ func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File)
 // syscall.Errno is success.
 func openFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
 	f, err := os.OpenFile(path, flag, perm)
-	// Note: This does not return a platform.File because sysfs.FS that returns
+	// Note: This does not return a fsapi.File because fsapi.FS that returns
 	// one may want to hide the real OS path. For example, this is needed for
 	// pre-opens.
-	return f, UnwrapOSError(err)
+	return f, platform.UnwrapOSError(err)
 }
diff --git a/internal/platform/open_file_js.go b/internal/sysfs/open_file_js.go
similarity index 66%
rename from internal/platform/open_file_js.go
rename to internal/sysfs/open_file_js.go
index e4389a13..e473acbe 100644
--- a/internal/platform/open_file_js.go
+++ b/internal/sysfs/open_file_js.go
@@ -1,16 +1,11 @@
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"syscall"
-)
 
-// See the comments on the same constants in open_file_windows.go
-const (
-	O_DIRECTORY = 1 << 29
-	O_NOFOLLOW  = 1 << 30
-	O_NONBLOCK  = 1 << 31
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) File {
@@ -20,5 +15,5 @@ func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File)
 func openFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
 	flag &= ^(O_DIRECTORY | O_NOFOLLOW) // erase placeholders
 	f, err := os.OpenFile(path, flag, perm)
-	return f, UnwrapOSError(err)
+	return f, platform.UnwrapOSError(err)
 }
diff --git a/internal/platform/open_file_sun.go b/internal/sysfs/open_file_sun.go
similarity index 51%
rename from internal/platform/open_file_sun.go
rename to internal/sysfs/open_file_sun.go
index 9714cc1b..e23b7185 100644
--- a/internal/platform/open_file_sun.go
+++ b/internal/sysfs/open_file_sun.go
@@ -1,25 +1,21 @@
 //go:build illumos || solaris
 
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"syscall"
-)
 
-const (
-	// See https://github.com/illumos/illumos-gate/blob/edd580643f2cf1434e252cd7779e83182ea84945/usr/src/uts/common/sys/fcntl.h#L90
-	O_DIRECTORY = 0x1000000
-	O_NOFOLLOW  = syscall.O_NOFOLLOW
-	O_NONBLOCK  = syscall.O_NONBLOCK
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) File {
+func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) fsapi.File {
 	return newDefaultOsFile(openPath, openFlag, openPerm, f)
 }
 
 func openFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
 	f, err := os.OpenFile(path, flag, perm)
-	return f, UnwrapOSError(err)
+	return f, platform.UnwrapOSError(err)
 }
diff --git a/internal/platform/open_file_windows.go b/internal/sysfs/open_file_windows.go
similarity index 82%
rename from internal/platform/open_file_windows.go
rename to internal/sysfs/open_file_windows.go
index 96ab2711..0800dc7b 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/sysfs/open_file_windows.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"io/fs"
@@ -6,38 +6,20 @@ import (
 	"strings"
 	"syscall"
 	"unsafe"
-)
 
-// Windows does not have these constants, we declare placeholders which should
-// not conflict with other open flags. These placeholders are not declared as
-// value zero so code written in a way which expects them to be bit flags still
-// works as expected.
-//
-// Since those placeholder are not interpreted by the open function, the unix
-// features they represent are also not implemented on windows:
-//
-//   - O_DIRECTORY allows programs to ensure that the opened file is a directory.
-//     This could be emulated by doing a stat call on the file after opening it
-//     to verify that it is in fact a directory, then closing it and returning an
-//     error if it is not.
-//
-//   - O_NOFOLLOW allows programs to ensure that if the opened file is a symbolic
-//     link, the link itself is opened instead of its target.
-const (
-	O_DIRECTORY = 1 << 29
-	O_NOFOLLOW  = 1 << 30
-	O_NONBLOCK  = syscall.O_NONBLOCK
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) File {
+func newOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) fsapi.File {
 	return &windowsOsFile{
 		osFile: osFile{path: openPath, flag: openFlag, perm: openPerm, file: f},
 	}
 }
 
 func openFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno) {
-	isDir := flag&O_DIRECTORY > 0
-	flag &= ^(O_DIRECTORY | O_NOFOLLOW) // erase placeholders
+	isDir := flag&fsapi.O_DIRECTORY > 0
+	flag &= ^(fsapi.O_DIRECTORY | fsapi.O_NOFOLLOW) // erase placeholders
 
 	// TODO: document why we are opening twice
 	fd, err := open(path, flag|syscall.O_CLOEXEC, uint32(perm))
@@ -47,7 +29,7 @@ func openFile(path string, flag int, perm fs.FileMode) (*os.File, syscall.Errno)
 
 	// TODO: Set FILE_SHARE_DELETE for directory as well.
 	f, err := os.OpenFile(path, flag, perm)
-	errno := UnwrapOSError(err)
+	errno := platform.UnwrapOSError(err)
 	if errno == 0 {
 		return f, 0
 	}
@@ -160,7 +142,7 @@ func open(path string, mode int, perm uint32) (fd syscall.Handle, err error) {
 		}
 	}
 
-	if IsGo120 {
+	if platform.IsGo120 {
 		// This shouldn't be included before 1.20 to have consistent behavior.
 		// https://github.com/golang/go/commit/0f0aa5d8a6a0253627d58b3aa083b24a1091933f
 		if createmode == syscall.OPEN_EXISTING && access == syscall.GENERIC_READ {
@@ -181,7 +163,7 @@ type windowsOsFile struct {
 }
 
 // Readdir implements File.Readdir
-func (f *windowsOsFile) Readdir(n int) (dirents []Dirent, errno syscall.Errno) {
+func (f *windowsOsFile) Readdir(n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	if errno = f.maybeInitDir(); errno != 0 {
 		return
 	}
diff --git a/internal/platform/osfile.go b/internal/sysfs/osfile.go
similarity index 71%
rename from internal/platform/osfile.go
rename to internal/sysfs/osfile.go
index 8560f52d..df39d16b 100644
--- a/internal/platform/osfile.go
+++ b/internal/sysfs/osfile.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"io"
@@ -6,14 +6,17 @@ import (
 	"os"
 	"syscall"
 	"time"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func newDefaultOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) File {
+func newDefaultOsFile(openPath string, openFlag int, openPerm fs.FileMode, f *os.File) fsapi.File {
 	return &osFile{path: openPath, flag: openFlag, perm: openPerm, file: f}
 }
 
 // osFile is a file opened with this package, and uses os.File or syscalls to
-// implement platform.File.
+// implement api.File.
 type osFile struct {
 	path string
 	flag int
@@ -27,7 +30,7 @@ type osFile struct {
 	cachedSt *cachedStat
 }
 
-// cachedStat returns the cacheable parts of platform.Stat_t or an error if
+// cachedStat returns the cacheable parts of platform.sys.Stat_t or an error if
 // they couldn't be retrieved.
 func (f *osFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.Errno) {
 	if f.cachedSt == nil {
@@ -38,7 +41,7 @@ func (f *osFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.E
 	return f.cachedSt.fileType, f.cachedSt.ino, 0
 }
 
-// Ino implements File.Ino
+// Ino implements the same method as documented on api.File
 func (f *osFile) Ino() (uint64, syscall.Errno) {
 	if _, ino, errno := f.cachedStat(); errno != 0 {
 		return 0, errno
@@ -52,7 +55,7 @@ func (f *osFile) IsAppend() bool {
 	return f.flag&syscall.O_APPEND == syscall.O_APPEND
 }
 
-// SetAppend implements File.SetAppend
+// SetAppend implements the same method as documented on api.File
 func (f *osFile) SetAppend(enable bool) (errno syscall.Errno) {
 	if enable {
 		f.flag |= syscall.O_APPEND
@@ -77,25 +80,25 @@ func (f *osFile) reopen() (errno syscall.Errno) {
 	return
 }
 
-// IsNonblock implements File.IsNonblock
+// IsNonblock implements the same method as documented on api.File
 func (f *osFile) IsNonblock() bool {
-	return f.flag&O_NONBLOCK == O_NONBLOCK
+	return f.flag&fsapi.O_NONBLOCK == fsapi.O_NONBLOCK
 }
 
-// SetNonblock implements File.SetNonblock
+// SetNonblock implements the same method as documented on api.File
 func (f *osFile) SetNonblock(enable bool) (errno syscall.Errno) {
 	if enable {
-		f.flag |= O_NONBLOCK
+		f.flag |= fsapi.O_NONBLOCK
 	} else {
-		f.flag &= ^O_NONBLOCK
+		f.flag &= ^fsapi.O_NONBLOCK
 	}
 	if err := setNonblock(f.file.Fd(), enable); err != nil {
-		return fileError(f, f.closed, UnwrapOSError(err))
+		return fileError(f, f.closed, platform.UnwrapOSError(err))
 	}
 	return 0
 }
 
-// IsDir implements File.IsDir
+// IsDir implements the same method as documented on api.File
 func (f *osFile) IsDir() (bool, syscall.Errno) {
 	if ft, _, errno := f.cachedStat(); errno != 0 {
 		return false, errno
@@ -105,10 +108,10 @@ func (f *osFile) IsDir() (bool, syscall.Errno) {
 	return false, 0
 }
 
-// Stat implements File.Stat
-func (f *osFile) Stat() (Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.File
+func (f *osFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	if f.closed {
-		return Stat_t{}, syscall.EBADF
+		return fsapi.Stat_t{}, syscall.EBADF
 	}
 
 	st, errno := statFile(f.file)
@@ -121,7 +124,7 @@ func (f *osFile) Stat() (Stat_t, syscall.Errno) {
 	return st, errno
 }
 
-// Read implements File.Read
+// Read implements the same method as documented on api.File
 func (f *osFile) Read(buf []byte) (n int, errno syscall.Errno) {
 	if n, errno = read(f.file, buf); errno != 0 {
 		// Defer validation overhead until we've already had an error.
@@ -130,7 +133,7 @@ func (f *osFile) Read(buf []byte) (n int, errno syscall.Errno) {
 	return
 }
 
-// Pread implements File.Pread
+// Pread implements the same method as documented on api.File
 func (f *osFile) Pread(buf []byte, off int64) (n int, errno syscall.Errno) {
 	if n, errno = pread(f.file, buf, off); errno != 0 {
 		// Defer validation overhead until we've already had an error.
@@ -139,7 +142,7 @@ func (f *osFile) Pread(buf []byte, off int64) (n int, errno syscall.Errno) {
 	return
 }
 
-// Seek implements File.Seek
+// Seek implements the same method as documented on api.File
 func (f *osFile) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
 	if newOffset, errno = seek(f.file, offset, whence); errno != 0 {
 		// Defer validation overhead until we've already had an error.
@@ -154,14 +157,14 @@ func (f *osFile) Seek(offset int64, whence int) (newOffset int64, errno syscall.
 	return
 }
 
-// PollRead implements File.PollRead
+// PollRead implements the same method as documented on api.File
 func (f *osFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
-	fdSet := FdSet{}
+	fdSet := platform.FdSet{}
 	fd := int(f.file.Fd())
 	fdSet.Set(fd)
 	nfds := fd + 1 // See https://man7.org/linux/man-pages/man2/select.2.html#:~:text=condition%20has%20occurred.-,nfds,-This%20argument%20should
 	count, err := _select(nfds, &fdSet, nil, nil, timeout)
-	if errno = UnwrapOSError(err); errno != 0 {
+	if errno = platform.UnwrapOSError(err); errno != 0 {
 		// Defer validation overhead until we've already had an error.
 		errno = fileError(f, f.closed, errno)
 	}
@@ -170,14 +173,14 @@ func (f *osFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Err
 
 // Readdir implements File.Readdir. Notably, this uses "Readdir", not
 // "ReadDir", from os.File.
-func (f *osFile) Readdir(n int) (dirents []Dirent, errno syscall.Errno) {
+func (f *osFile) Readdir(n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	if dirents, errno = readdir(f.file, f.path, n); errno != 0 {
 		errno = adjustReaddirErr(f, f.closed, errno)
 	}
 	return
 }
 
-// Write implements File.Write
+// Write implements the same method as documented on api.File
 func (f *osFile) Write(buf []byte) (n int, errno syscall.Errno) {
 	if n, errno = write(f.file, buf); errno != 0 {
 		// Defer validation overhead until we've already had an error.
@@ -186,7 +189,7 @@ func (f *osFile) Write(buf []byte) (n int, errno syscall.Errno) {
 	return
 }
 
-// Pwrite implements File.Pwrite
+// Pwrite implements the same method as documented on api.File
 func (f *osFile) Pwrite(buf []byte, off int64) (n int, errno syscall.Errno) {
 	if n, errno = pwrite(f.file, buf, off); errno != 0 {
 		// Defer validation overhead until we've already had an error.
@@ -195,35 +198,35 @@ func (f *osFile) Pwrite(buf []byte, off int64) (n int, errno syscall.Errno) {
 	return
 }
 
-// Truncate implements File.Truncate
+// Truncate implements the same method as documented on api.File
 func (f *osFile) Truncate(size int64) (errno syscall.Errno) {
-	if errno = UnwrapOSError(f.file.Truncate(size)); errno != 0 {
+	if errno = platform.UnwrapOSError(f.file.Truncate(size)); errno != 0 {
 		// Defer validation overhead until we've already had an error.
 		errno = fileError(f, f.closed, errno)
 	}
 	return
 }
 
-// Sync implements File.Sync
+// Sync implements the same method as documented on api.File
 func (f *osFile) Sync() syscall.Errno {
 	return sync(f.file)
 }
 
-// Datasync implements File.Datasync
+// Datasync implements the same method as documented on api.File
 func (f *osFile) Datasync() syscall.Errno {
 	return datasync(f.file)
 }
 
-// Chmod implements File.Chmod
+// Chmod implements the same method as documented on api.File
 func (f *osFile) Chmod(mode fs.FileMode) syscall.Errno {
 	if f.closed {
 		return syscall.EBADF
 	}
 
-	return UnwrapOSError(f.file.Chmod(mode))
+	return platform.UnwrapOSError(f.file.Chmod(mode))
 }
 
-// Chown implements File.Chown
+// Chown implements the same method as documented on api.File
 func (f *osFile) Chown(uid, gid int) syscall.Errno {
 	if f.closed {
 		return syscall.EBADF
@@ -232,17 +235,17 @@ func (f *osFile) Chown(uid, gid int) syscall.Errno {
 	return fchown(f.file.Fd(), uid, gid)
 }
 
-// Utimens implements File.Utimens
+// Utimens implements the same method as documented on api.File
 func (f *osFile) Utimens(times *[2]syscall.Timespec) syscall.Errno {
 	if f.closed {
 		return syscall.EBADF
 	}
 
 	err := futimens(f.file.Fd(), times)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
-// Close implements File.Close
+// Close implements the same method as documented on api.File
 func (f *osFile) Close() syscall.Errno {
 	if f.closed {
 		return 0
@@ -252,5 +255,5 @@ func (f *osFile) Close() syscall.Errno {
 }
 
 func (f *osFile) close() syscall.Errno {
-	return UnwrapOSError(f.file.Close())
+	return platform.UnwrapOSError(f.file.Close())
 }
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 0d6852a3..f4158dfd 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -6,24 +6,24 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 )
 
-// NewReadFS is used to mask an existing FS for reads. Notably, this allows
+// NewReadFS is used to mask an existing api.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 FS) FS {
+func NewReadFS(fs fsapi.FS) fsapi.FS {
 	if _, ok := fs.(*readFS); ok {
 		return fs
-	} else if _, ok = fs.(UnimplementedFS); ok {
+	} else if _, ok = fs.(fsapi.UnimplementedFS); ok {
 		return fs // unimplemented is read-only
 	}
 	return &readFS{fs: fs}
 }
 
 type readFS struct {
-	fs FS
+	fs fsapi.FS
 }
 
 // String implements fmt.Stringer
@@ -31,8 +31,8 @@ func (r *readFS) String() string {
 	return r.fs.String()
 }
 
-// OpenFile implements FS.OpenFile
-func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
+// OpenFile implements the same method as documented on api.FS
+func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.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.
@@ -54,7 +54,7 @@ func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.Fil
 	// check if they are the opposite of read or not.
 	switch flag & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
 	case os.O_WRONLY, os.O_RDWR:
-		if flag&platform.O_DIRECTORY != 0 {
+		if flag&fsapi.O_DIRECTORY != 0 {
 			return nil, syscall.EISDIR
 		}
 		return nil, syscall.ENOSYS
@@ -68,104 +68,104 @@ func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.Fil
 	return &readFile{f: f}, 0
 }
 
-// compile-time check to ensure readFile implements platform.File.
-var _ platform.File = (*readFile)(nil)
+// compile-time check to ensure readFile implements api.File.
+var _ fsapi.File = (*readFile)(nil)
 
 type readFile struct {
-	f platform.File
+	f fsapi.File
 }
 
-// Ino implements the same method as documented on platform.File.
+// Ino implements the same method as documented on api.File.
 func (r *readFile) Ino() (uint64, syscall.Errno) {
 	return r.f.Ino()
 }
 
-// IsNonblock implements the same method as documented on platform.File.
+// IsNonblock implements the same method as documented on api.File.
 func (r *readFile) IsNonblock() bool {
 	return r.f.IsNonblock()
 }
 
-// SetNonblock implements the same method as documented on platform.File.
+// SetNonblock implements the same method as documented on api.File.
 func (r *readFile) SetNonblock(enabled bool) syscall.Errno {
 	return r.f.SetNonblock(enabled)
 }
 
-// IsAppend implements the same method as documented on platform.File.
+// IsAppend implements the same method as documented on api.File.
 func (r *readFile) IsAppend() bool {
 	return r.f.IsAppend()
 }
 
-// SetAppend implements the same method as documented on platform.File.
+// SetAppend implements the same method as documented on api.File.
 func (r *readFile) SetAppend(enabled bool) syscall.Errno {
 	return r.f.SetAppend(enabled)
 }
 
-// Stat implements the same method as documented on platform.File.
-func (r *readFile) Stat() (platform.Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.File.
+func (r *readFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	return r.f.Stat()
 }
 
-// IsDir implements the same method as documented on platform.File.
+// IsDir implements the same method as documented on api.File.
 func (r *readFile) IsDir() (bool, syscall.Errno) {
 	return r.f.IsDir()
 }
 
-// Read implements the same method as documented on platform.File.
+// Read implements the same method as documented on api.File.
 func (r *readFile) Read(buf []byte) (int, syscall.Errno) {
 	return r.f.Read(buf)
 }
 
-// Pread implements the same method as documented on platform.File.
+// Pread implements the same method as documented on api.File.
 func (r *readFile) Pread(buf []byte, offset int64) (int, syscall.Errno) {
 	return r.f.Pread(buf, offset)
 }
 
-// Seek implements the same method as documented on platform.File.
+// Seek implements the same method as documented on api.File.
 func (r *readFile) Seek(offset int64, whence int) (int64, syscall.Errno) {
 	return r.f.Seek(offset, whence)
 }
 
-// Readdir implements the same method as documented on platform.File.
-func (r *readFile) Readdir(n int) (dirents []platform.Dirent, errno syscall.Errno) {
+// Readdir implements the same method as documented on api.File.
+func (r *readFile) Readdir(n int) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	return r.f.Readdir(n)
 }
 
-// Write implements the same method as documented on platform.File.
+// Write implements the same method as documented on api.File.
 func (r *readFile) Write([]byte) (int, syscall.Errno) {
 	return 0, r.writeErr()
 }
 
-// Pwrite implements the same method as documented on platform.File.
+// Pwrite implements the same method as documented on api.File.
 func (r *readFile) Pwrite([]byte, int64) (n int, errno syscall.Errno) {
 	return 0, r.writeErr()
 }
 
-// Truncate implements the same method as documented on platform.File.
+// Truncate implements the same method as documented on api.File.
 func (r *readFile) Truncate(int64) syscall.Errno {
 	return r.writeErr()
 }
 
-// Sync implements the same method as documented on platform.File.
+// Sync implements the same method as documented on api.File.
 func (r *readFile) Sync() syscall.Errno {
 	return syscall.EBADF
 }
 
-// Datasync implements the same method as documented on platform.File.
+// Datasync implements the same method as documented on api.File.
 func (r *readFile) Datasync() syscall.Errno {
 	return syscall.EBADF
 }
 
-// Chmod implements the same method as documented on platform.File.
+// Chmod implements the same method as documented on api.File.
 func (r *readFile) Chmod(fs.FileMode) syscall.Errno {
 	return syscall.EBADF
 }
 
-// Chown implements the same method as documented on platform.File.
+// Chown implements the same method as documented on api.File.
 func (r *readFile) Chown(int, int) syscall.Errno {
 	return syscall.EBADF
 }
 
-// Utimens implements the same method as documented on platform.File.
+// Utimens implements the same method as documented on api.File.
 func (r *readFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.EBADF
 }
@@ -179,7 +179,7 @@ func (r *readFile) writeErr() syscall.Errno {
 	return syscall.EBADF
 }
 
-// Close implements the same method as documented on platform.File.
+// Close implements the same method as documented on api.File.
 func (r *readFile) Close() syscall.Errno {
 	return r.f.Close()
 }
@@ -189,72 +189,72 @@ func (r *readFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.E
 	return r.f.PollRead(timeout)
 }
 
-// Lstat implements FS.Lstat
-func (r *readFS) Lstat(path string) (platform.Stat_t, syscall.Errno) {
+// Lstat implements the same method as documented on api.FS
+func (r *readFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return r.fs.Lstat(path)
 }
 
-// Stat implements FS.Stat
-func (r *readFS) Stat(path string) (platform.Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.FS
+func (r *readFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return r.fs.Stat(path)
 }
 
-// Readlink implements FS.Readlink
+// Readlink implements the same method as documented on api.FS
 func (r *readFS) Readlink(path string) (dst string, err syscall.Errno) {
 	return r.fs.Readlink(path)
 }
 
-// Mkdir implements FS.Mkdir
+// Mkdir implements the same method as documented on api.FS
 func (r *readFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Chmod implements FS.Chmod
+// Chmod implements the same method as documented on api.FS
 func (r *readFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Chown implements FS.Chown
+// Chown implements the same method as documented on api.FS
 func (r *readFS) Chown(path string, uid, gid int) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Lchown implements FS.Lchown
+// Lchown implements the same method as documented on api.FS
 func (r *readFS) Lchown(path string, uid, gid int) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Rename implements FS.Rename
+// Rename implements the same method as documented on api.FS
 func (r *readFS) Rename(from, to string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Rmdir implements FS.Rmdir
+// Rmdir implements the same method as documented on api.FS
 func (r *readFS) Rmdir(path string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Link implements FS.Link
+// Link implements the same method as documented on api.FS
 func (r *readFS) Link(_, _ string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Symlink implements FS.Symlink
+// Symlink implements the same method as documented on api.FS
 func (r *readFS) Symlink(_, _ string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Unlink implements FS.Unlink
+// Unlink implements the same method as documented on api.FS
 func (r *readFS) Unlink(path string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Utimens implements FS.Utimens
+// Utimens implements the same method as documented on api.FS
 func (r *readFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Truncate implements FS.Truncate
+// Truncate implements the same method as documented on api.FS
 func (r *readFS) Truncate(string, int64) syscall.Errno {
 	return syscall.EROFS
 }
diff --git a/internal/sysfs/rename.go b/internal/sysfs/rename.go
new file mode 100644
index 00000000..b107bc19
--- /dev/null
+++ b/internal/sysfs/rename.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package sysfs
+
+import (
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func Rename(from, to string) syscall.Errno {
+	if from == to {
+		return 0
+	}
+	return platform.UnwrapOSError(syscall.Rename(from, to))
+}
diff --git a/internal/platform/rename_windows.go b/internal/sysfs/rename_windows.go
similarity index 77%
rename from internal/platform/rename_windows.go
rename to internal/sysfs/rename_windows.go
index c3b406e5..25e53d4f 100644
--- a/internal/platform/rename_windows.go
+++ b/internal/sysfs/rename_windows.go
@@ -1,9 +1,11 @@
-package platform
+package sysfs
 
 import (
 	"errors"
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 func Rename(from, to string) syscall.Errno {
@@ -25,21 +27,21 @@ func Rename(from, to string) syscall.Errno {
 		} else if !fromIsDir && !toIsDir { // file to file
 			// Use os.Rename instead of syscall.Rename in order to allow the overrides of the existing file.
 			// Underneath os.Rename, it uses MoveFileEx instead of MoveFile (used by syscall.Rename).
-			return UnwrapOSError(os.Rename(from, to))
+			return platform.UnwrapOSError(os.Rename(from, to))
 		} else { // dir to dir
 			if dirs, _ := os.ReadDir(to); len(dirs) == 0 {
 				// On Windows, renaming to the empty dir will be rejected,
 				// so first we remove the empty dir, and then rename to it.
 				if err := os.Remove(to); err != nil {
-					return UnwrapOSError(err)
+					return platform.UnwrapOSError(err)
 				}
-				return UnwrapOSError(syscall.Rename(from, to))
+				return platform.UnwrapOSError(syscall.Rename(from, to))
 			}
 			return syscall.ENOTEMPTY
 		}
 	} else if !errors.Is(err, syscall.ENOENT) { // Failed to stat the destination.
-		return UnwrapOSError(err)
+		return platform.UnwrapOSError(err)
 	} else { // Destination not-exist.
-		return UnwrapOSError(syscall.Rename(from, to))
+		return platform.UnwrapOSError(syscall.Rename(from, to))
 	}
 }
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index fa0e621d..dcab08b7 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -7,13 +7,13 @@ import (
 	"strings"
 	"syscall"
 
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 )
 
-func NewRootFS(fs []FS, guestPaths []string) (FS, error) {
+func NewRootFS(fs []fsapi.FS, guestPaths []string) (fsapi.FS, error) {
 	switch len(fs) {
 	case 0:
-		return UnimplementedFS{}, nil
+		return fsapi.UnimplementedFS{}, nil
 	case 1:
 		if StripPrefixesAndTrailingSlash(guestPaths[0]) == "" {
 			return fs[0], nil
@@ -22,7 +22,7 @@ func NewRootFS(fs []FS, guestPaths []string) (FS, error) {
 
 	ret := &CompositeFS{
 		string:            stringFS(fs, guestPaths),
-		fs:                make([]FS, len(fs)),
+		fs:                make([]fsapi.FS, len(fs)),
 		guestPaths:        make([]string, len(fs)),
 		cleanedGuestPaths: make([]string, len(fs)),
 		rootGuestPaths:    map[string]int{},
@@ -61,11 +61,11 @@ func NewRootFS(fs []FS, guestPaths []string) (FS, error) {
 }
 
 type CompositeFS struct {
-	UnimplementedFS
+	fsapi.UnimplementedFS
 	// string is cached for convenience.
 	string string
 	// fs is index-correlated with cleanedGuestPaths
-	fs []FS
+	fs []fsapi.FS
 	// guestPaths are the original paths supplied by the end user, cleaned as
 	// cleanedGuestPaths.
 	guestPaths []string
@@ -83,7 +83,7 @@ func (c *CompositeFS) String() string {
 	return c.string
 }
 
-func stringFS(fs []FS, guestPaths []string) string {
+func stringFS(fs []fsapi.FS, guestPaths []string) string {
 	var ret strings.Builder
 	ret.WriteString("[")
 	writeMount(&ret, fs[0], guestPaths[0])
@@ -95,7 +95,7 @@ func stringFS(fs []FS, guestPaths []string) string {
 	return ret.String()
 }
 
-func writeMount(ret *strings.Builder, f FS, guestPath string) {
+func writeMount(ret *strings.Builder, f fsapi.FS, guestPath string) {
 	ret.WriteString(f.String())
 	ret.WriteString(":")
 	ret.WriteString(guestPath)
@@ -110,14 +110,14 @@ func (c *CompositeFS) GuestPaths() (guestPaths []string) {
 }
 
 // FS returns the underlying filesystems in original order.
-func (c *CompositeFS) FS() (fs []FS) {
-	fs = make([]FS, len(c.guestPaths))
+func (c *CompositeFS) FS() (fs []fsapi.FS) {
+	fs = make([]fsapi.FS, len(c.guestPaths))
 	copy(fs, c.fs)
 	return
 }
 
-// OpenFile implements FS.OpenFile
-func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f platform.File, err syscall.Errno) {
+// OpenFile implements the same method as documented on api.FS
+func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f fsapi.File, err syscall.Errno) {
 	matchIndex, relativePath := c.chooseFS(path)
 
 	f, err = c.fs[matchIndex].OpenFile(relativePath, flag, perm)
@@ -140,26 +140,26 @@ func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f platf
 // An openRootDir is a root directory open for reading, which has mounts inside
 // of it.
 type openRootDir struct {
-	platform.DirFile
+	fsapi.DirFile
 
 	path     string
 	c        *CompositeFS
-	f        platform.File     // the directory file itself
-	dirents  []platform.Dirent // the directory contents
-	direntsI int               // the read offset, an index into the files slice
+	f        fsapi.File     // the directory file itself
+	dirents  []fsapi.Dirent // the directory contents
+	direntsI int            // the read offset, an index into the files slice
 }
 
-// Ino implements the same method as documented on platform.File
+// Ino implements the same method as documented on api.File
 func (d *openRootDir) Ino() (uint64, syscall.Errno) {
 	return d.f.Ino()
 }
 
-// Stat implements the same method as documented on platform.File
-func (d *openRootDir) Stat() (platform.Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.File
+func (d *openRootDir) Stat() (fsapi.Stat_t, syscall.Errno) {
 	return d.f.Stat()
 }
 
-// Seek implements the same method as documented on platform.File
+// Seek implements the same method as documented on api.File
 func (d *openRootDir) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
 	if offset != 0 || whence != io.SeekStart {
 		errno = syscall.ENOSYS
@@ -170,8 +170,8 @@ func (d *openRootDir) Seek(offset int64, whence int) (newOffset int64, errno sys
 	return d.f.Seek(offset, whence)
 }
 
-// Readdir implements the same method as documented on platform.File
-func (d *openRootDir) Readdir(count int) (dirents []platform.Dirent, errno syscall.Errno) {
+// Readdir implements the same method as documented on api.File
+func (d *openRootDir) Readdir(count int) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	if d.dirents == nil {
 		if errno = d.readdir(); errno != 0 {
 			return
@@ -186,7 +186,7 @@ func (d *openRootDir) Readdir(count int) (dirents []platform.Dirent, errno sysca
 	if count > 0 && n > count {
 		n = count
 	}
-	dirents = make([]platform.Dirent, n)
+	dirents = make([]fsapi.Dirent, n)
 	for i := range dirents {
 		dirents[i] = d.dirents[d.direntsI+i]
 	}
@@ -216,7 +216,7 @@ func (d *openRootDir) readdir() (errno syscall.Errno) {
 		}
 	}
 
-	var di platform.Dirent
+	var di fsapi.Dirent
 	for n, fsI := range remaining {
 		if di, errno = d.rootEntry(n, fsI); errno != 0 {
 			return
@@ -226,27 +226,27 @@ func (d *openRootDir) readdir() (errno syscall.Errno) {
 	return
 }
 
-// Sync implements the same method as documented on platform.File
+// Sync implements the same method as documented on api.File
 func (d *openRootDir) Sync() syscall.Errno {
 	return d.f.Sync()
 }
 
-// Datasync implements the same method as documented on platform.File
+// Datasync implements the same method as documented on api.File
 func (d *openRootDir) Datasync() syscall.Errno {
 	return d.f.Datasync()
 }
 
-// Chmod implements the same method as documented on platform.File
+// Chmod implements the same method as documented on api.File
 func (d *openRootDir) Chmod(fs.FileMode) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Chown implements the same method as documented on platform.File
+// Chown implements the same method as documented on api.File
 func (d *openRootDir) Chown(int, int) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Utimens implements the same method as documented on platform.File
+// Utimens implements the same method as documented on api.File
 func (d *openRootDir) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.ENOSYS
 }
@@ -256,51 +256,51 @@ func (d *openRootDir) Close() syscall.Errno {
 	return d.f.Close()
 }
 
-func (d *openRootDir) rootEntry(name string, fsI int) (platform.Dirent, syscall.Errno) {
+func (d *openRootDir) rootEntry(name string, fsI int) (fsapi.Dirent, syscall.Errno) {
 	if st, errno := d.c.fs[fsI].Stat("."); errno != 0 {
-		return platform.Dirent{}, errno
+		return fsapi.Dirent{}, errno
 	} else {
-		return platform.Dirent{Name: name, Ino: st.Ino, Type: st.Mode.Type()}, 0
+		return fsapi.Dirent{Name: name, Ino: st.Ino, Type: st.Mode.Type()}, 0
 	}
 }
 
-// Lstat implements FS.Lstat
-func (c *CompositeFS) Lstat(path string) (platform.Stat_t, syscall.Errno) {
+// Lstat implements the same method as documented on api.FS
+func (c *CompositeFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Lstat(relativePath)
 }
 
-// Stat implements FS.Stat
-func (c *CompositeFS) Stat(path string) (platform.Stat_t, syscall.Errno) {
+// Stat implements the same method as documented on api.FS
+func (c *CompositeFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Stat(relativePath)
 }
 
-// Mkdir implements FS.Mkdir
+// Mkdir implements the same method as documented on api.FS
 func (c *CompositeFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Mkdir(relativePath, perm)
 }
 
-// Chmod implements FS.Chmod
+// Chmod implements the same method as documented on api.FS
 func (c *CompositeFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Chmod(relativePath, perm)
 }
 
-// Chown implements FS.Chown
+// Chown implements the same method as documented on api.FS
 func (c *CompositeFS) Chown(path string, uid, gid int) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Chown(relativePath, uid, gid)
 }
 
-// Lchown implements FS.Lchown
+// Lchown implements the same method as documented on api.FS
 func (c *CompositeFS) Lchown(path string, uid, gid int) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Lchown(relativePath, uid, gid)
 }
 
-// Rename implements FS.Rename
+// Rename implements the same method as documented on api.FS
 func (c *CompositeFS) Rename(from, to string) syscall.Errno {
 	fromFS, fromPath := c.chooseFS(from)
 	toFS, toPath := c.chooseFS(to)
@@ -310,13 +310,13 @@ func (c *CompositeFS) Rename(from, to string) syscall.Errno {
 	return c.fs[fromFS].Rename(fromPath, toPath)
 }
 
-// Readlink implements FS.Readlink
+// Readlink implements the same method as documented on api.FS
 func (c *CompositeFS) Readlink(path string) (string, syscall.Errno) {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Readlink(relativePath)
 }
 
-// Link implements FS.Link.
+// Link implements the same method as documented on api.FS
 func (c *CompositeFS) Link(oldName, newName string) syscall.Errno {
 	fromFS, oldNamePath := c.chooseFS(oldName)
 	toFS, newNamePath := c.chooseFS(newName)
@@ -326,13 +326,13 @@ func (c *CompositeFS) Link(oldName, newName string) syscall.Errno {
 	return c.fs[fromFS].Link(oldNamePath, newNamePath)
 }
 
-// Utimens implements FS.Utimens
+// Utimens implements the same method as documented on api.FS
 func (c *CompositeFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Utimens(relativePath, times, symlinkFollow)
 }
 
-// Symlink implements FS.Symlink
+// Symlink implements the same method as documented on api.FS
 func (c *CompositeFS) Symlink(oldName, link string) (err syscall.Errno) {
 	fromFS, oldNamePath := c.chooseFS(oldName)
 	toFS, linkPath := c.chooseFS(link)
@@ -342,19 +342,19 @@ func (c *CompositeFS) Symlink(oldName, link string) (err syscall.Errno) {
 	return c.fs[fromFS].Symlink(oldNamePath, linkPath)
 }
 
-// Truncate implements FS.Truncate
+// Truncate implements the same method as documented on api.FS
 func (c *CompositeFS) Truncate(path string, size int64) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Truncate(relativePath, size)
 }
 
-// Rmdir implements FS.Rmdir
+// Rmdir implements the same method as documented on api.FS
 func (c *CompositeFS) Rmdir(path string) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Rmdir(relativePath)
 }
 
-// Unlink implements FS.Unlink
+// Unlink implements the same method as documented on api.FS
 func (c *CompositeFS) Unlink(path string) syscall.Errno {
 	matchIndex, relativePath := c.chooseFS(path)
 	return c.fs[matchIndex].Unlink(relativePath)
@@ -372,7 +372,8 @@ func (c *CompositeFS) chooseFS(path string) (matchIndex int, relativePath string
 		prefix := c.cleanedGuestPaths[i]
 		if eq, match := hasPathPrefix(path, pathI, pathLen, prefix); eq {
 			// When the input equals the prefix, there cannot be a longer match
-			// later. The relative path is the FS root, so return empty string.
+			// later. The relative path is the fsapi.FS root, so return empty
+			// string.
 			matchIndex = i
 			relativePath = ""
 			return
@@ -496,11 +497,11 @@ loop:
 }
 
 type fakeRootFS struct {
-	UnimplementedFS
+	fsapi.UnimplementedFS
 }
 
-// OpenFile implements FS.OpenFile
-func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
+// OpenFile implements the same method as documented on api.FS
+func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
 	switch path {
 	case ".", "/", "":
 		return fakeRootDir{}, 0
@@ -509,50 +510,50 @@ func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.Fi
 }
 
 type fakeRootDir struct {
-	platform.DirFile
+	fsapi.DirFile
 }
 
-// Ino implements the same method as documented on platform.File
+// Ino implements the same method as documented on api.File
 func (fakeRootDir) Ino() (uint64, syscall.Errno) {
 	return 0, 0
 }
 
-// Stat implements the same method as documented on platform.File
-func (fakeRootDir) Stat() (platform.Stat_t, syscall.Errno) {
-	return platform.Stat_t{Mode: fs.ModeDir, Nlink: 1}, 0
+// Stat implements the same method as documented on api.File
+func (fakeRootDir) Stat() (fsapi.Stat_t, syscall.Errno) {
+	return fsapi.Stat_t{Mode: fs.ModeDir, Nlink: 1}, 0
 }
 
-// Readdir implements the same method as documented on platform.File
-func (fakeRootDir) Readdir(int) (dirents []platform.Dirent, errno syscall.Errno) {
+// Readdir implements the same method as documented on api.File
+func (fakeRootDir) Readdir(int) (dirents []fsapi.Dirent, errno syscall.Errno) {
 	return // empty
 }
 
-// Sync implements the same method as documented on platform.File
+// Sync implements the same method as documented on api.File
 func (fakeRootDir) Sync() syscall.Errno {
 	return 0
 }
 
-// Datasync implements the same method as documented on platform.File
+// Datasync implements the same method as documented on api.File
 func (fakeRootDir) Datasync() syscall.Errno {
 	return 0
 }
 
-// Chmod implements the same method as documented on platform.File
+// Chmod implements the same method as documented on api.File
 func (fakeRootDir) Chmod(fs.FileMode) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Chown implements the same method as documented on platform.File
+// Chown implements the same method as documented on api.File
 func (fakeRootDir) Chown(int, int) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Utimens implements the same method as documented on platform.File
+// Utimens implements the same method as documented on api.File
 func (fakeRootDir) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Close implements the same method as documented on platform.File
+// Close implements the same method as documented on api.File
 func (fakeRootDir) Close() syscall.Errno {
 	return 0
 }
diff --git a/internal/platform/select.go b/internal/sysfs/select.go
similarity index 89%
rename from internal/platform/select.go
rename to internal/sysfs/select.go
index 7325b4b0..ac0861fd 100644
--- a/internal/platform/select.go
+++ b/internal/sysfs/select.go
@@ -1,6 +1,10 @@
-package platform
+package sysfs
 
-import "time"
+import (
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
 
 // _select exposes the select(2) syscall. This is named as such to avoid
 // colliding with they keyword select while not exporting the function.
@@ -27,6 +31,6 @@ import "time"
 //	e.g. the read-end of a pipe or an eventfd on Linux.
 //	When the context is canceled, we may unblock a Select call by writing to the fd, causing it to return immediately.
 //	This however requires to do a bit of housekeeping to hide the "special" FD from the end-user.
-func _select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func _select(n int, r, w, e *platform.FdSet, timeout *time.Duration) (int, error) {
 	return syscall_select(n, r, w, e, timeout)
 }
diff --git a/internal/platform/select_darwin.go b/internal/sysfs/select_darwin.go
similarity index 88%
rename from internal/platform/select_darwin.go
rename to internal/sysfs/select_darwin.go
index 9e29fca7..eabf4f45 100644
--- a/internal/platform/select_darwin.go
+++ b/internal/sysfs/select_darwin.go
@@ -1,15 +1,17 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
 	"time"
 	"unsafe"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // syscall_select invokes select on Darwin, with the given timeout Duration.
 // We implement our own version instead of relying on syscall.Select because the latter
 // only returns the error and discards the result.
-func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func syscall_select(n int, r, w, e *platform.FdSet, timeout *time.Duration) (int, error) {
 	var t *syscall.Timeval
 	if timeout != nil {
 		tv := syscall.NsecToTimeval(timeout.Nanoseconds())
diff --git a/internal/platform/select_darwin.s b/internal/sysfs/select_darwin.s
similarity index 100%
rename from internal/platform/select_darwin.s
rename to internal/sysfs/select_darwin.s
diff --git a/internal/platform/select_linux.go b/internal/sysfs/select_linux.go
similarity index 67%
rename from internal/platform/select_linux.go
rename to internal/sysfs/select_linux.go
index 8a38bec0..aae5e48f 100644
--- a/internal/platform/select_linux.go
+++ b/internal/sysfs/select_linux.go
@@ -1,12 +1,14 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
 	"time"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // syscall_select invokes select on Unix (unless Darwin), with the given timeout Duration.
-func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func syscall_select(n int, r, w, e *platform.FdSet, timeout *time.Duration) (int, error) {
 	var t *syscall.Timeval
 	if timeout != nil {
 		tv := syscall.NsecToTimeval(timeout.Nanoseconds())
diff --git a/internal/sysfs/select_unsupported.go b/internal/sysfs/select_unsupported.go
new file mode 100644
index 00000000..5244374b
--- /dev/null
+++ b/internal/sysfs/select_unsupported.go
@@ -0,0 +1,14 @@
+//go:build !darwin && !linux && !windows
+
+package sysfs
+
+import (
+	"syscall"
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func syscall_select(n int, r, w, e *platform.FdSet, timeout *time.Duration) (int, error) {
+	return -1, syscall.ENOSYS
+}
diff --git a/internal/platform/select_windows.go b/internal/sysfs/select_windows.go
similarity index 95%
rename from internal/platform/select_windows.go
rename to internal/sysfs/select_windows.go
index cab24e5c..811c648d 100644
--- a/internal/platform/select_windows.go
+++ b/internal/sysfs/select_windows.go
@@ -1,10 +1,12 @@
-package platform
+package sysfs
 
 import (
 	"context"
 	"syscall"
 	"time"
 	"unsafe"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // wasiFdStdin is the constant value for stdin on Wasi.
@@ -14,6 +16,8 @@ const wasiFdStdin = 0
 // pollInterval is the interval between each calls to peekNamedPipe in pollNamedPipe
 const pollInterval = 100 * time.Millisecond
 
+var kernel32 = syscall.NewLazyDLL("kernel32.dll")
+
 // procPeekNamedPipe is the syscall.LazyProc in kernel32 for PeekNamedPipe
 var procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe")
 
@@ -29,7 +33,7 @@ var procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe")
 // PeekNamedPipe: https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-peeknamedpipe
 // "GetFileType can assist in determining what device type the handle refers to. A console handle presents as FILE_TYPE_CHAR."
 // https://learn.microsoft.com/en-us/windows/console/console-handles
-func syscall_select(n int, r, w, e *FdSet, timeout *time.Duration) (int, error) {
+func syscall_select(n int, r, w, e *platform.FdSet, timeout *time.Duration) (int, error) {
 	if n == 0 {
 		// Don't block indefinitely.
 		if timeout == nil {
diff --git a/internal/sysfs/stat.go b/internal/sysfs/stat.go
new file mode 100644
index 00000000..60690fd9
--- /dev/null
+++ b/internal/sysfs/stat.go
@@ -0,0 +1,32 @@
+package sysfs
+
+import (
+	"io/fs"
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func defaultStatFile(f *os.File) (fsapi.Stat_t, syscall.Errno) {
+	if t, err := f.Stat(); err != nil {
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
+	} else {
+		return statFromFileInfo(t), 0
+	}
+}
+
+func StatFromDefaultFileInfo(t fs.FileInfo) fsapi.Stat_t {
+	st := fsapi.Stat_t{}
+	st.Ino = 0
+	st.Dev = 0
+	st.Mode = t.Mode()
+	st.Nlink = 1
+	st.Size = t.Size()
+	mtim := t.ModTime().UnixNano() // Set all times to the mod time
+	st.Atim = mtim
+	st.Mtim = mtim
+	st.Ctim = mtim
+	return st
+}
diff --git a/internal/platform/stat_bsd.go b/internal/sysfs/stat_bsd.go
similarity index 62%
rename from internal/platform/stat_bsd.go
rename to internal/sysfs/stat_bsd.go
index e6f404d7..8297e850 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/sysfs/stat_bsd.go
@@ -1,30 +1,33 @@
 //go:build (amd64 || arm64) && (darwin || freebsd)
 
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func lstat(path string) (Stat_t, syscall.Errno) {
+func lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	if t, err := os.Lstat(path); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	} else {
 		return statFromFileInfo(t), 0
 	}
 }
 
-func stat(path string) (Stat_t, syscall.Errno) {
+func stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	if t, err := os.Stat(path); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	} else {
 		return statFromFileInfo(t), 0
 	}
 }
 
-func statFile(f *os.File) (Stat_t, syscall.Errno) {
+func statFile(f *os.File) (fsapi.Stat_t, syscall.Errno) {
 	return defaultStatFile(f)
 }
 
@@ -35,9 +38,9 @@ func inoFromFileInfo(_ string, t fs.FileInfo) (ino uint64, err syscall.Errno) {
 	return
 }
 
-func statFromFileInfo(t fs.FileInfo) Stat_t {
+func statFromFileInfo(t fs.FileInfo) fsapi.Stat_t {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
-		st := Stat_t{}
+		st := fsapi.Stat_t{}
 		st.Dev = uint64(d.Dev)
 		st.Ino = d.Ino
 		st.Uid = d.Uid
@@ -53,5 +56,5 @@ func statFromFileInfo(t fs.FileInfo) Stat_t {
 		st.Ctim = ctime.Sec*1e9 + ctime.Nsec
 		return st
 	}
-	return statFromDefaultFileInfo(t)
+	return StatFromDefaultFileInfo(t)
 }
diff --git a/internal/platform/stat_linux.go b/internal/sysfs/stat_linux.go
similarity index 66%
rename from internal/platform/stat_linux.go
rename to internal/sysfs/stat_linux.go
index 2fe256c2..e90f29b5 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/sysfs/stat_linux.go
@@ -3,31 +3,34 @@
 // Note: This expression is not the same as compiler support, even if it looks
 // similar. Platform functions here are used in interpreter mode as well.
 
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func lstat(path string) (Stat_t, syscall.Errno) {
+func lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	if t, err := os.Lstat(path); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	} else {
 		return statFromFileInfo(t), 0
 	}
 }
 
-func stat(path string) (Stat_t, syscall.Errno) {
+func stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	if t, err := os.Stat(path); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	} else {
 		return statFromFileInfo(t), 0
 	}
 }
 
-func statFile(f *os.File) (Stat_t, syscall.Errno) {
+func statFile(f *os.File) (fsapi.Stat_t, syscall.Errno) {
 	return defaultStatFile(f)
 }
 
@@ -38,9 +41,9 @@ func inoFromFileInfo(_ string, t fs.FileInfo) (ino uint64, err syscall.Errno) {
 	return
 }
 
-func statFromFileInfo(t fs.FileInfo) Stat_t {
+func statFromFileInfo(t fs.FileInfo) fsapi.Stat_t {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
-		st := Stat_t{}
+		st := fsapi.Stat_t{}
 		st.Dev = uint64(d.Dev)
 		st.Ino = uint64(d.Ino)
 		st.Uid = d.Uid
@@ -56,5 +59,5 @@ func statFromFileInfo(t fs.FileInfo) Stat_t {
 		st.Ctim = ctime.Sec*1e9 + ctime.Nsec
 		return st
 	}
-	return statFromDefaultFileInfo(t)
+	return StatFromDefaultFileInfo(t)
 }
diff --git a/internal/sysfs/stat_unsupported.go b/internal/sysfs/stat_unsupported.go
new file mode 100644
index 00000000..4c2b7055
--- /dev/null
+++ b/internal/sysfs/stat_unsupported.go
@@ -0,0 +1,42 @@
+//go:build (!((amd64 || arm64 || riscv64) && linux) && !((amd64 || arm64) && (darwin || freebsd)) && !((amd64 || arm64) && windows)) || js
+
+package sysfs
+
+import (
+	"io/fs"
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func lstat(path string) (fsapi.Stat_t, syscall.Errno) {
+	t, err := os.Lstat(path)
+	if errno := platform.UnwrapOSError(err); errno == 0 {
+		return statFromFileInfo(t), 0
+	} else {
+		return fsapi.Stat_t{}, errno
+	}
+}
+
+func stat(path string) (fsapi.Stat_t, syscall.Errno) {
+	t, err := os.Stat(path)
+	if errno := platform.UnwrapOSError(err); errno == 0 {
+		return statFromFileInfo(t), 0
+	} else {
+		return fsapi.Stat_t{}, errno
+	}
+}
+
+func statFile(f *os.File) (fsapi.Stat_t, syscall.Errno) {
+	return defaultStatFile(f)
+}
+
+func inoFromFileInfo(_ string, t fs.FileInfo) (ino uint64, err syscall.Errno) {
+	return
+}
+
+func statFromFileInfo(t fs.FileInfo) fsapi.Stat_t {
+	return StatFromDefaultFileInfo(t)
+}
diff --git a/internal/platform/stat_windows.go b/internal/sysfs/stat_windows.go
similarity index 79%
rename from internal/platform/stat_windows.go
rename to internal/sysfs/stat_windows.go
index 9a938f5f..4b05727e 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/sysfs/stat_windows.go
@@ -1,15 +1,18 @@
 //go:build (amd64 || arm64) && windows
 
-package platform
+package sysfs
 
 import (
 	"io/fs"
 	"os"
 	"path"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func lstat(path string) (Stat_t, syscall.Errno) {
+func lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	attrs := uint32(syscall.FILE_FLAG_BACKUP_SEMANTICS)
 	// Use FILE_FLAG_OPEN_REPARSE_POINT, otherwise CreateFile will follow symlink.
 	// See https://docs.microsoft.com/en-us/windows/desktop/FileIO/symbolic-link-effects-on-file-systems-functions#createfile-and-createfiletransacted
@@ -17,18 +20,18 @@ func lstat(path string) (Stat_t, syscall.Errno) {
 	return statPath(attrs, path)
 }
 
-func stat(path string) (Stat_t, syscall.Errno) {
+func stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	attrs := uint32(syscall.FILE_FLAG_BACKUP_SEMANTICS)
 	return statPath(attrs, path)
 }
 
-func statPath(createFileAttrs uint32, path string) (Stat_t, syscall.Errno) {
+func statPath(createFileAttrs uint32, path string) (fsapi.Stat_t, syscall.Errno) {
 	if len(path) == 0 {
-		return Stat_t{}, syscall.ENOENT
+		return fsapi.Stat_t{}, syscall.ENOENT
 	}
 	pathp, err := syscall.UTF16PtrFromString(path)
 	if err != nil {
-		return Stat_t{}, syscall.EINVAL
+		return fsapi.Stat_t{}, syscall.EINVAL
 	}
 
 	// open the file handle
@@ -40,14 +43,14 @@ func statPath(createFileAttrs uint32, path string) (Stat_t, syscall.Errno) {
 		if err == syscall.ENOTDIR {
 			err = syscall.ENOENT
 		}
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	}
 	defer syscall.CloseHandle(h)
 
 	return statHandle(h)
 }
 
-func statFile(f *os.File) (Stat_t, syscall.Errno) {
+func statFile(f *os.File) (fsapi.Stat_t, syscall.Errno) {
 	// Attempt to get the stat by handle, which works for normal files
 	st, err := statHandle(syscall.Handle(f.Fd()))
 
@@ -71,16 +74,16 @@ func inoFromFileInfo(filePath string, t fs.FileInfo) (ino uint64, errno syscall.
 	}
 	// ino is no not in Win32FileAttributeData
 	inoPath := path.Clean(path.Join(filePath, t.Name()))
-	var st Stat_t
-	if st, errno = Lstat(inoPath); errno == 0 {
+	var st fsapi.Stat_t
+	if st, errno = lstat(inoPath); errno == 0 {
 		ino = st.Ino
 	}
 	return
 }
 
-func statFromFileInfo(t fs.FileInfo) Stat_t {
+func statFromFileInfo(t fs.FileInfo) fsapi.Stat_t {
 	if d, ok := t.Sys().(*syscall.Win32FileAttributeData); ok {
-		st := Stat_t{}
+		st := fsapi.Stat_t{}
 		st.Ino = 0 // not in Win32FileAttributeData
 		st.Dev = 0 // not in Win32FileAttributeData
 		st.Mode = t.Mode()
@@ -91,19 +94,19 @@ func statFromFileInfo(t fs.FileInfo) Stat_t {
 		st.Ctim = d.CreationTime.Nanoseconds()
 		return st
 	} else {
-		return statFromDefaultFileInfo(t)
+		return StatFromDefaultFileInfo(t)
 	}
 }
 
-func statHandle(h syscall.Handle) (Stat_t, syscall.Errno) {
+func statHandle(h syscall.Handle) (fsapi.Stat_t, syscall.Errno) {
 	winFt, err := syscall.GetFileType(h)
 	if err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	}
 
 	var fi syscall.ByHandleFileInformation
 	if err = syscall.GetFileInformationByHandle(h, &fi); err != nil {
-		return Stat_t{}, UnwrapOSError(err)
+		return fsapi.Stat_t{}, platform.UnwrapOSError(err)
 	}
 
 	var m fs.FileMode
@@ -124,7 +127,7 @@ func statHandle(h syscall.Handle) (Stat_t, syscall.Errno) {
 		m |= fs.ModeDir | 0o111 // e.g. 0o444 -> 0o555
 	}
 
-	st := Stat_t{}
+	st := fsapi.Stat_t{}
 	// FileIndex{High,Low} can be combined and used as a unique identifier like inode.
 	// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/ns-fileapi-by_handle_file_information
 	st.Dev = uint64(fi.VolumeSerialNumber)
diff --git a/internal/sysfs/sync.go b/internal/sysfs/sync.go
new file mode 100644
index 00000000..edb673c1
--- /dev/null
+++ b/internal/sysfs/sync.go
@@ -0,0 +1,14 @@
+//go:build !windows
+
+package sysfs
+
+import (
+	"os"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func sync(f *os.File) syscall.Errno {
+	return platform.UnwrapOSError(f.Sync())
+}
diff --git a/internal/platform/sync_windows.go b/internal/sysfs/sync_windows.go
similarity index 74%
rename from internal/platform/sync_windows.go
rename to internal/sysfs/sync_windows.go
index 534f7936..6a30957a 100644
--- a/internal/platform/sync_windows.go
+++ b/internal/sysfs/sync_windows.go
@@ -1,12 +1,14 @@
-package platform
+package sysfs
 
 import (
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 func sync(f *os.File) syscall.Errno {
-	errno := UnwrapOSError(f.Sync())
+	errno := platform.UnwrapOSError(f.Sync())
 	// Coerce error performing stat on a directory to 0, as it won't work
 	// on Windows.
 	switch errno {
diff --git a/internal/platform/syscall6_darwin.go b/internal/sysfs/syscall6_darwin.go
similarity index 95%
rename from internal/platform/syscall6_darwin.go
rename to internal/sysfs/syscall6_darwin.go
index 273fb7dd..9fde5baa 100644
--- a/internal/platform/syscall6_darwin.go
+++ b/internal/sysfs/syscall6_darwin.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"syscall"
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index 2a697ba5..e0ebfe5b 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -4,369 +4,3 @@
 // The name sysfs was chosen because wazero's public API has a "sys" package,
 // which was named after https://github.com/golang/sys.
 package sysfs
-
-import (
-	"io/fs"
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/platform"
-)
-
-// FS is a writeable fs.FS bridge backed by syscall functions needed for ABI
-// including WASI and runtime.GOOS=js.
-//
-// Implementations should embed UnimplementedFS for forward compatability. Any
-// unsupported method or parameter should return syscall.ENOSYS.
-//
-// # Errors
-//
-// All methods that can return an error return a syscall.Errno, which is zero
-// on success.
-//
-// Restricting to syscall.Errno matches current WebAssembly host functions,
-// which are constrained to well-known error codes. For example, `GOOS=js` maps
-// hard coded values and panics otherwise. More commonly, WASI maps syscall
-// errors to u32 numeric values.
-//
-// # Notes
-//
-// A writable filesystem abstraction is not yet implemented as of Go 1.20. See
-// https://github.com/golang/go/issues/45757
-type FS interface {
-	// String should return a human-readable format of the filesystem
-	//
-	// For example, if this filesystem is backed by the real directory
-	// "/tmp/wasm", the expected value is "/tmp/wasm".
-	//
-	// When the host filesystem isn't a real filesystem, substitute a symbolic,
-	// human-readable name. e.g. "virtual"
-	String() string
-
-	// OpenFile opens a file. It should be closed via Close on platform.File.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` or `flag` is invalid.
-	//   - syscall.EISDIR: the path was a directory, but flag included
-	//     syscall.O_RDWR or syscall.O_WRONLY
-	//   - syscall.ENOENT: `path` doesn't exist and `flag` doesn't contain
-	//     os.O_CREATE.
-	//
-	// # Constraints on the returned file
-	//
-	// Implementations that can read flags should enforce them regardless of
-	// the type returned. For example, while os.File implements io.Writer,
-	// attempts to write to a directory or a file opened with os.O_RDONLY fail
-	// with a syscall.EBADF.
-	//
-	// Some implementations choose whether to enforce read-only opens, namely
-	// fs.FS. While fs.FS is supported (Adapt), wazero cannot runtime enforce
-	// open flags. Instead, we encourage good behavior and test our built-in
-	// implementations.
-	//
-	// # Notes
-	//
-	//   - This is like os.OpenFile, except the path is relative to this file
-	//     system, and syscall.Errno is returned instead of os.PathError.
-	//   - flag are the same as os.OpenFile, for example, os.O_CREATE.
-	//   - Implications of permissions when os.O_CREATE are described in Chmod
-	//     notes.
-	//   - This is like `open` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html
-	OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno)
-	// ^^ TODO: Consider syscall.Open, though this implies defining and
-	// coercing flags and perms similar to what is done in os.OpenFile.
-
-	// Lstat gets file status without following symbolic links.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Lstat, except the `path` is relative to this
-	//     file system.
-	//   - This is like `lstat` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/lstat.html
-	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
-	//     same value.
-	//   - When the path is a symbolic link, the stat returned is for the link,
-	//     not the file it refers to.
-	Lstat(path string) (platform.Stat_t, syscall.Errno)
-
-	// Stat gets file status.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Stat, except the `path` is relative to this
-	//     file system.
-	//   - This is like `stat` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/stat.html
-	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
-	//     same value.
-	//   - When the path is a symbolic link, the stat returned is for the file
-	//     it refers to.
-	Stat(path string) (platform.Stat_t, syscall.Errno)
-
-	// Mkdir makes a directory.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.EEXIST: `path` exists and is a directory.
-	//   - syscall.ENOTDIR: `path` exists and is a file.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Mkdir, except the `path` is relative to this
-	//     file system.
-	//   - This is like `mkdir` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/mkdir.html
-	//   - Implications of permissions are described in Chmod notes.
-	Mkdir(path string, perm fs.FileMode) syscall.Errno
-	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
-	// coercing flags and perms similar to what is done in os.Mkdir.
-
-	// Chmod changes the mode of the file.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` does not exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Chmod, except the `path` is relative to this
-	//     file system.
-	//   - This is like `chmod` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/chmod.html
-	//   - Windows ignores the execute bit, and any permissions come back as
-	//     group and world. For example, chmod of 0400 reads back as 0444, and
-	//     0700 0666. Also, permissions on directories aren't supported at all.
-	Chmod(path string, perm fs.FileMode) syscall.Errno
-
-	// Chown changes the owner and group of a file.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` does not exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Chown, except the `path` is relative to this
-	//     file system.
-	//   - This is like `chown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/chown.html
-	//   - This always returns syscall.ENOSYS on windows.
-	Chown(path string, uid, gid int) syscall.Errno
-
-	// Lchown changes the owner and group of a symbolic link.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` does not exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Lchown, except the `path` is relative to this
-	//     file system.
-	//   - This is like `lchown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/lchown.html
-	//   - Windows will always return syscall.ENOSYS
-	Lchown(path string, uid, gid int) syscall.Errno
-
-	// Rename renames file or directory.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `from` or `to` is invalid.
-	//   - syscall.ENOENT: `from` or `to` don't exist.
-	//   - syscall.ENOTDIR: `from` is a directory and `to` exists as a file.
-	//   - syscall.EISDIR: `from` is a file and `to` exists as a directory.
-	//   - syscall.ENOTEMPTY: `both from` and `to` are existing directory, but
-	//    `to` is not empty.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Rename, except the paths are relative to this
-	//     file system.
-	//   - This is like `rename` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html
-	//   -  Windows doesn't let you overwrite an existing directory.
-	Rename(from, to string) syscall.Errno
-
-	// Rmdir removes a directory.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//   - syscall.ENOTDIR: `path` exists, but isn't a directory.
-	//   - syscall.ENOTEMPTY: `path` exists, but isn't empty.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Rmdir, except the `path` is relative to this
-	//     file system.
-	//   - This is like `rmdir` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/rmdir.html
-	//   - As of Go 1.19, Windows maps syscall.ENOTDIR to syscall.ENOENT.
-	Rmdir(path string) syscall.Errno
-
-	// Unlink removes a directory entry.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//   - syscall.EISDIR: `path` exists, but is a directory.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Unlink, except the `path` is relative to this
-	//     file system.
-	//   - This is like `unlink` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/unlink.html
-	//   - On Windows, syscall.Unlink doesn't delete symlink to directory unlike other platforms. Implementations might
-	//     want to combine syscall.RemoveDirectory with syscall.Unlink in order to delete such links on Windows.
-	//     See https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-removedirectorya
-	Unlink(path string) syscall.Errno
-
-	// Link creates a "hard" link from oldPath to newPath, in contrast to a
-	// soft link (via Symlink).
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EPERM: `oldPath` is invalid.
-	//   - syscall.ENOENT: `oldPath` doesn't exist.
-	//   - syscall.EISDIR: `newPath` exists, but is a directory.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Link, except the `oldPath` is relative to this
-	//     file system.
-	//   - This is like `link` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/link.html
-	Link(oldPath, newPath string) syscall.Errno
-
-	// Symlink creates a "soft" link from oldPath to newPath, in contrast to a
-	// hard link (via Link).
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EPERM: `oldPath` or `newPath` is invalid.
-	//   - syscall.EEXIST: `newPath` exists.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Symlink, except the `oldPath` is relative to
-	//     this file system.
-	//   - This is like `symlink` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
-	//   - Only `newPath` is relative to this file system and `oldPath` is kept
-	//     as-is. That is because the link is only resolved relative to the
-	//     directory when dereferencing it (e.g. ReadLink).
-	//     See https://github.com/bytecodealliance/cap-std/blob/v1.0.4/cap-std/src/fs/dir.rs#L404-L409
-	//     for how others implement this.
-	//   - Symlinks in Windows requires `SeCreateSymbolicLinkPrivilege`.
-	//     Otherwise, syscall.EPERM results.
-	//     See https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
-	Symlink(oldPath, linkName string) syscall.Errno
-
-	// Readlink reads the contents of a symbolic link.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Readlink, except the path is relative to this
-	//     filesystem.
-	//   - This is like `readlink` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/readlink.html
-	//   - On Windows, the path separator is different from other platforms,
-	//     but to provide consistent results to Wasm, this normalizes to a "/"
-	//     separator.
-	Readlink(path string) (string, syscall.Errno)
-
-	// Truncate truncates a file to a specified length.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid or size is negative.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//   - syscall.EISDIR: `path` is a directory.
-	//   - syscall.EACCES: `path` doesn't have write access.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Truncate, except the path is relative to this
-	//     filesystem.
-	//   - This is like `truncate` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/truncate.html
-	Truncate(path string, size int64) syscall.Errno
-
-	// Utimens set file access and modification times on a path relative to
-	// this file system, at nanosecond precision.
-	//
-	// # Parameters
-	//
-	// The `times` parameter includes the access and modification timestamps to
-	// assign. Special syscall.Timespec NSec values platform.UTIME_NOW and
-	// platform.UTIME_OMIT may be specified instead of real timestamps. A nil
-	// `times` parameter behaves the same as if both were set to
-	// platform.UTIME_NOW.
-	//
-	// When the `symlinkFollow` parameter is true and the path is a symbolic link,
-	// the target of expanding that link is updated.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.EEXIST: `path` exists and is a directory.
-	//   - syscall.ENOTDIR: `path` exists and is a file.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.UtimesNano and `utimensat` with `AT_FDCWD` in
-	//     POSIX. See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-	Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno
-}
diff --git a/internal/sysfs/unimplemented.go b/internal/sysfs/unimplemented.go
deleted file mode 100644
index 32373bcf..00000000
--- a/internal/sysfs/unimplemented.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package sysfs
-
-import (
-	"io/fs"
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/platform"
-)
-
-// UnimplementedFS is an FS that returns syscall.ENOSYS for all functions,
-// 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: syscall.ENOSYS}
-}
-
-// OpenFile implements FS.OpenFile
-func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (platform.File, syscall.Errno) {
-	return nil, syscall.ENOSYS
-}
-
-// Lstat implements FS.Lstat
-func (UnimplementedFS) Lstat(path string) (platform.Stat_t, syscall.Errno) {
-	return platform.Stat_t{}, syscall.ENOSYS
-}
-
-// Stat implements FS.Stat
-func (UnimplementedFS) Stat(path string) (platform.Stat_t, syscall.Errno) {
-	return platform.Stat_t{}, syscall.ENOSYS
-}
-
-// Readlink implements FS.Readlink
-func (UnimplementedFS) Readlink(path string) (string, syscall.Errno) {
-	return "", syscall.ENOSYS
-}
-
-// Mkdir implements FS.Mkdir
-func (UnimplementedFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chmod implements FS.Chmod
-func (UnimplementedFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chown implements FS.Chown
-func (UnimplementedFS) Chown(path string, uid, gid int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Lchown implements FS.Lchown
-func (UnimplementedFS) Lchown(path string, uid, gid int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Rename implements FS.Rename
-func (UnimplementedFS) Rename(from, to string) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Rmdir implements FS.Rmdir
-func (UnimplementedFS) Rmdir(path string) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Link implements FS.Link
-func (UnimplementedFS) Link(_, _ string) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Symlink implements FS.Symlink
-func (UnimplementedFS) Symlink(_, _ string) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Unlink implements FS.Unlink
-func (UnimplementedFS) Unlink(path string) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Utimens implements FS.Utimens
-func (UnimplementedFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Truncate implements FS.Truncate
-func (UnimplementedFS) Truncate(string, int64) syscall.Errno {
-	return syscall.ENOSYS
-}
diff --git a/internal/sysfs/unlink.go b/internal/sysfs/unlink.go
new file mode 100644
index 00000000..37b74e18
--- /dev/null
+++ b/internal/sysfs/unlink.go
@@ -0,0 +1,17 @@
+//go:build !windows
+
+package sysfs
+
+import (
+	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func Unlink(name string) (errno syscall.Errno) {
+	err := syscall.Unlink(name)
+	if errno = platform.UnwrapOSError(err); errno == syscall.EPERM {
+		errno = syscall.EISDIR
+	}
+	return errno
+}
diff --git a/internal/platform/unlink_windows.go b/internal/sysfs/unlink_windows.go
similarity index 68%
rename from internal/platform/unlink_windows.go
rename to internal/sysfs/unlink_windows.go
index 5ff607ac..410ea844 100644
--- a/internal/platform/unlink_windows.go
+++ b/internal/sysfs/unlink_windows.go
@@ -1,10 +1,12 @@
 //go:build windows
 
-package platform
+package sysfs
 
 import (
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 func Unlink(name string) syscall.Errno {
@@ -12,11 +14,11 @@ func Unlink(name string) syscall.Errno {
 	if err == nil {
 		return 0
 	}
-	errno := UnwrapOSError(err)
+	errno := platform.UnwrapOSError(err)
 	if errno == syscall.EBADF {
 		lstat, errLstat := os.Lstat(name)
 		if errLstat == nil && lstat.Mode()&os.ModeSymlink != 0 {
-			errno = UnwrapOSError(os.Remove(name))
+			errno = platform.UnwrapOSError(os.Remove(name))
 		} else {
 			errno = syscall.EISDIR
 		}


Test patch:
diff --git a/fsconfig_test.go b/fsconfig_test.go
index b114dafe..a6c58bbd 100644
--- a/fsconfig_test.go
+++ b/fsconfig_test.go
@@ -3,6 +3,7 @@ package wazero
 import (
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -18,12 +19,12 @@ func TestFSConfig(t *testing.T) {
 	tests := []struct {
 		name     string
 		input    FSConfig
-		expected sysfs.FS
+		expected fsapi.FS
 	}{
 		{
 			name:     "empty",
 			input:    base,
-			expected: sysfs.UnimplementedFS{},
+			expected: fsapi.UnimplementedFS{},
 		},
 		{
 			name:     "WithFSMount",
@@ -38,7 +39,7 @@ func TestFSConfig(t *testing.T) {
 		{
 			name:     "WithFsMount nil",
 			input:    base.WithFSMount(nil, "/"),
-			expected: sysfs.UnimplementedFS{},
+			expected: fsapi.UnimplementedFS{},
 		},
 		{
 			name:     "WithDirMount overwrites",
@@ -48,9 +49,9 @@ func TestFSConfig(t *testing.T) {
 		{
 			name:  "Composition",
 			input: base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"),
-			expected: func() sysfs.FS {
+			expected: func() fsapi.FS {
 				f, err := sysfs.NewRootFS(
-					[]sysfs.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
+					[]fsapi.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
 					[]string{"/", "/tmp"},
 				)
 				require.NoError(t, err)
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 2c15b8fa..1d90fcb7 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -18,6 +18,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
@@ -240,7 +241,7 @@ func Test_fdFdstatGet(t *testing.T) {
 	preopen := fsc.RootFS()
 
 	// replace stdin with a fake TTY file.
-	// TODO: Make this easier once we have in-memory platform.File
+	// TODO: Make this easier once we have in-memory internalapi.File
 	stdin, _ := fsc.LookupFile(sys.FdStdin)
 	stdinFile, errno := sysfs.Adapt(&gofstest.MapFS{"stdin": &gofstest.MapFile{
 		Mode: fs.ModeDevice | fs.ModeCharDevice | 0o600,
@@ -1950,8 +1951,8 @@ func Test_fdRead_Errors(t *testing.T) {
 }
 
 var (
-	testDirents = func() []platform.Dirent {
-		d, errno := platform.OpenFSFile(fstest.FS, "dir", 0, 0)
+	testDirents = func() []fsapi.Dirent {
+		d, errno := sysfs.OpenFSFile(fstest.FS, "dir", 0, 0)
 		if errno != 0 {
 			panic(errno)
 		}
@@ -1960,7 +1961,7 @@ var (
 		if errno != 0 {
 			panic(errno)
 		}
-		dots := []platform.Dirent{
+		dots := []fsapi.Dirent{
 			{Name: ".", Type: fs.ModeDir},
 			{Name: "..", Type: fs.ModeDir},
 		}
@@ -3657,7 +3658,7 @@ func Test_pathFilestatSetTimes(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			if tc.flags == 0 && !platform.SupportsSymlinkNoFollow {
+			if tc.flags == 0 && !sysfs.SupportsSymlinkNoFollow {
 				tc.expectedErrno = wasip1.ErrnoNosys
 				tc.expectedLog = strings.ReplaceAll(tc.expectedLog, "ESUCCESS", "ENOSYS")
 			}
@@ -3675,7 +3676,7 @@ func Test_pathFilestatSetTimes(t *testing.T) {
 			sys := mod.(*wasm.ModuleInstance).Sys
 			fsc := sys.FS()
 
-			var oldSt platform.Stat_t
+			var oldSt fsapi.Stat_t
 			var errno syscall.Errno
 			if tc.expectedErrno == wasip1.ErrnoSuccess {
 				oldSt, errno = fsc.RootFS().Stat(pathName)
@@ -3832,7 +3833,7 @@ func Test_pathOpen(t *testing.T) {
 
 	tests := []struct {
 		name          string
-		fs            sysfs.FS
+		fs            fsapi.FS
 		path          func(t *testing.T) string
 		oflags        uint16
 		fdflags       uint16
@@ -4098,7 +4099,7 @@ func requireContents(t *testing.T, fsc *sys.FSContext, expectedOpenedFd int32, f
 	require.Equal(t, fileContents, buf)
 }
 
-func readAll(t *testing.T, f platform.File) []byte {
+func readAll(t *testing.T, f fsapi.File) []byte {
 	st, errno := f.Stat()
 	require.EqualErrno(t, 0, errno)
 	buf := make([]byte, st.Size)
@@ -5176,8 +5177,8 @@ func joinPath(dirName, baseName string) string {
 	return path.Join(dirName, baseName)
 }
 
-func openFile(t *testing.T, path string, flag int, perm fs.FileMode) platform.File {
-	f, errno := platform.OpenOSFile(path, flag, perm)
+func openFile(t *testing.T, path string, flag int, perm fs.FileMode) fsapi.File {
+	f, errno := sysfs.OpenOSFile(path, flag, perm)
 	require.EqualErrno(t, 0, errno)
 	return f
 }
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index d0c07932..e7b31fda 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -5,9 +5,10 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
-	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasip1"
 )
@@ -17,7 +18,7 @@ func Test_lastDirents(t *testing.T) {
 		name            string
 		f               *sys.ReadDir
 		cookie          int64
-		expectedDirents []platform.Dirent
+		expectedDirents []fsapi.Dirent
 		expectedErrno   syscall.Errno
 	}{
 		{
@@ -102,7 +103,7 @@ func Test_lastDirents(t *testing.T) {
 func Test_maxDirents(t *testing.T) {
 	tests := []struct {
 		name                        string
-		dirents                     []platform.Dirent
+		dirents                     []fsapi.Dirent
 		maxLen                      uint32
 		expectedCount               uint32
 		expectedwriteTruncatedEntry bool
@@ -194,9 +195,9 @@ func Test_maxDirents(t *testing.T) {
 }
 
 var (
-	testDirents = func() []platform.Dirent {
+	testDirents = func() []fsapi.Dirent {
 		dPath := "dir"
-		d, errno := platform.OpenFSFile(fstest.FS, dPath, syscall.O_RDONLY, 0)
+		d, errno := sysfs.OpenFSFile(fstest.FS, dPath, syscall.O_RDONLY, 0)
 		if errno != 0 {
 			panic(errno)
 		}
@@ -234,7 +235,7 @@ var (
 func Test_writeDirents(t *testing.T) {
 	tests := []struct {
 		name                string
-		entries             []platform.Dirent
+		entries             []fsapi.Dirent
 		entryCount          uint32
 		writeTruncatedEntry bool
 		expectedEntriesBuf  []byte
@@ -291,37 +292,37 @@ func Test_openFlags(t *testing.T) {
 	}{
 		{
 			name:              "oflags=0",
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDONLY,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDONLY,
 		},
 		{
 			name:              "oflags=O_CREAT",
 			oflags:            wasip1.O_CREAT,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDWR | syscall.O_CREAT,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDWR | syscall.O_CREAT,
 		},
 		{
 			name:              "oflags=O_DIRECTORY",
 			oflags:            wasip1.O_DIRECTORY,
-			expectedOpenFlags: platform.O_NOFOLLOW | platform.O_DIRECTORY,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | fsapi.O_DIRECTORY,
 		},
 		{
 			name:              "oflags=O_EXCL",
 			oflags:            wasip1.O_EXCL,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDONLY | syscall.O_EXCL,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDONLY | syscall.O_EXCL,
 		},
 		{
 			name:              "oflags=O_TRUNC",
 			oflags:            wasip1.O_TRUNC,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDWR | syscall.O_TRUNC,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDWR | syscall.O_TRUNC,
 		},
 		{
 			name:              "fdflags=FD_APPEND",
 			fdflags:           wasip1.FD_APPEND,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDWR | syscall.O_APPEND,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDWR | syscall.O_APPEND,
 		},
 		{
 			name:              "oflags=O_TRUNC|O_CREAT",
 			oflags:            wasip1.O_TRUNC | wasip1.O_CREAT,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDWR | syscall.O_TRUNC | syscall.O_CREAT,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDWR | syscall.O_TRUNC | syscall.O_CREAT,
 		},
 		{
 			name:              "dirflags=LOOKUP_SYMLINK_FOLLOW",
@@ -331,17 +332,17 @@ func Test_openFlags(t *testing.T) {
 		{
 			name:              "rights=FD_READ",
 			rights:            wasip1.RIGHT_FD_READ,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDONLY,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDONLY,
 		},
 		{
 			name:              "rights=FD_WRITE",
 			rights:            wasip1.RIGHT_FD_WRITE,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_WRONLY,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_WRONLY,
 		},
 		{
 			name:              "rights=FD_READ|FD_WRITE",
 			rights:            wasip1.RIGHT_FD_READ | wasip1.RIGHT_FD_WRITE,
-			expectedOpenFlags: platform.O_NOFOLLOW | syscall.O_RDWR,
+			expectedOpenFlags: fsapi.O_NOFOLLOW | syscall.O_RDWR,
 		},
 	}
 
diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go
index 8b2f0279..f0920328 100644
--- a/imports/wasi_snapshot_preview1/poll_test.go
+++ b/imports/wasi_snapshot_preview1/poll_test.go
@@ -9,7 +9,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasip1"
@@ -154,7 +154,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 		name                                   string
 		in, out, nsubscriptions, resultNevents uint32
 		mem                                    []byte // at offset in
-		stdin                                  platform.File
+		stdin                                  fsapi.File
 		expectedErrno                          wasip1.Errno
 		expectedMem                            []byte // at offset out
 		expectedLog                            string
@@ -394,7 +394,7 @@ func Test_pollOneoff_Stdin(t *testing.T) {
 	}
 }
 
-func setStdin(t *testing.T, mod api.Module, stdin platform.File) {
+func setStdin(t *testing.T, mod api.Module, stdin fsapi.File) {
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 	f, ok := fsc.LookupFile(sys.FdStdin)
 	require.True(t, ok)
@@ -533,9 +533,9 @@ var fdReadSub = fdReadSubFd(byte(sys.FdStdin))
 // https://github.com/mattn/go-isatty/blob/v0.0.18/isatty_tcgets.go#LL11C1-L12C1
 type ttyStat struct{}
 
-// Stat implements the same method as documented on platform.File
-func (ttyStat) Stat() (platform.Stat_t, syscall.Errno) {
-	return platform.Stat_t{
+// Stat implements the same method as documented on internalapi.File
+func (ttyStat) Stat() (fsapi.Stat_t, syscall.Errno) {
+	return fsapi.Stat_t{
 		Mode:  fs.ModeDevice | fs.ModeCharDevice,
 		Nlink: 1,
 	}, 0
@@ -551,7 +551,7 @@ type neverReadyTtyStdinFile struct {
 	ttyStat
 }
 
-// PollRead implements the same method as documented on platform.File
+// PollRead implements the same method as documented on internalapi.File
 func (neverReadyTtyStdinFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.Errno) {
 	time.Sleep(*timeout)
 	return false, 0
@@ -563,7 +563,7 @@ type pollStdinFile struct {
 	ready bool
 }
 
-// PollRead implements the same method as documented on platform.File
+// PollRead implements the same method as documented on internalapi.File
 func (p *pollStdinFile) PollRead(*time.Duration) (ready bool, errno syscall.Errno) {
 	return p.ready, 0
 }
diff --git a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
index 462a6e09..031e5ffb 100644
--- a/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
@@ -13,7 +13,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/sys"
@@ -201,7 +201,7 @@ func compileAndRun(t *testing.T, config wazero.ModuleConfig, bin []byte) (consol
 	return compileAndRunWithStdin(t, config, bin, nil)
 }
 
-func compileAndRunWithStdin(t *testing.T, config wazero.ModuleConfig, bin []byte, stdin platform.File) (console string) {
+func compileAndRunWithStdin(t *testing.T, config wazero.ModuleConfig, bin []byte, stdin fsapi.File) (console string) {
 	// same for console and stderr as sometimes the stack trace is in one or the other.
 	var consoleBuf bytes.Buffer
 
@@ -239,7 +239,7 @@ func Test_Poll(t *testing.T) {
 	tests := []struct {
 		name            string
 		args            []string
-		stdin           platform.File
+		stdin           fsapi.File
 		expectedOutput  string
 		expectedTimeout time.Duration
 	}{
diff --git a/internal/fstest/fstest.go b/internal/fstest/fstest.go
index a9f81eca..5a337ab7 100644
--- a/internal/fstest/fstest.go
+++ b/internal/fstest/fstest.go
@@ -26,8 +26,6 @@ import (
 	"runtime"
 	"testing/fstest"
 	"time"
-
-	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 var files = []struct {
@@ -100,16 +98,16 @@ func WriteTestFiles(tmpDir string) (err error) {
 			}
 
 			// os.Stat uses GetFileInformationByHandle internally.
-			st, errno := platform.Stat(path)
-			if errno != 0 {
-				return errno
-			}
-			if st.Mtim == info.ModTime().UnixNano() {
+			st, err := os.Stat(path)
+			if err != nil {
+				return err
+			} else if st.ModTime() == info.ModTime() {
 				return nil // synced!
 			}
 
 			// Otherwise, we need to sync the timestamps.
-			return os.Chtimes(path, time.Unix(0, st.Atim), time.Unix(0, st.Mtim))
+			atimeNsec, mtimeNsec := timesFromFileInfo(st)
+			return os.Chtimes(path, time.Unix(0, atimeNsec), time.Unix(0, mtimeNsec))
 		})
 	}
 	return
diff --git a/internal/fstest/times_notwindows.go b/internal/fstest/times_notwindows.go
new file mode 100644
index 00000000..712cf2cc
--- /dev/null
+++ b/internal/fstest/times_notwindows.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package fstest
+
+import "io/fs"
+
+func timesFromFileInfo(t fs.FileInfo) (atim, mtime int64) {
+	panic("unexpected")
+}
diff --git a/internal/fstest/times_windows.go b/internal/fstest/times_windows.go
new file mode 100644
index 00000000..78ffe370
--- /dev/null
+++ b/internal/fstest/times_windows.go
@@ -0,0 +1,14 @@
+package fstest
+
+import (
+	"io/fs"
+	"syscall"
+)
+
+func timesFromFileInfo(t fs.FileInfo) (atim, mtime int64) {
+	if d, ok := t.Sys().(*syscall.Win32FileAttributeData); ok {
+		return d.LastAccessTime.Nanoseconds(), d.LastWriteTime.Nanoseconds()
+	} else {
+		panic("unexpected")
+	}
+}
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 3f3accd6..99586d07 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 	"testing/fstest"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -26,20 +27,20 @@ func TestNewFSContext(t *testing.T) {
 	// Test various usual configuration for the file system.
 	tests := []struct {
 		name string
-		fs   sysfs.FS
+		fs   fsapi.FS
 	}{
 		{
 			name: "embed.FS",
 			fs:   sysfs.Adapt(embedFS),
 		},
 		{
-			name: "sysfs.NewDirFS",
+			name: "NewDirFS",
 			// Don't use "testdata" because it may not be present in
 			// cross-architecture (a.k.a. scratch) build containers.
 			fs: dirfs,
 		},
 		{
-			name: "sysfs.NewReadFS",
+			name: "NewReadFS",
 			fs:   sysfs.NewReadFS(dirfs),
 		},
 		{
@@ -123,12 +124,12 @@ func TestFSContext_CloseFile(t *testing.T) {
 
 func TestUnimplementedFSContext(t *testing.T) {
 	c := Context{}
-	err := c.NewFSContext(nil, nil, nil, sysfs.UnimplementedFS{})
+	err := c.NewFSContext(nil, nil, nil, fsapi.UnimplementedFS{})
 	require.NoError(t, err)
 	testFS := &c.fsc
 	require.NoError(t, err)
 
-	expected := &FSContext{rootFS: sysfs.UnimplementedFS{}}
+	expected := &FSContext{rootFS: fsapi.UnimplementedFS{}}
 	noopStdin, _ := stdinFile(nil)
 	expected.openedFiles.Insert(noopStdin)
 	noopStdout, _ := stdioWriterFile("stdout", nil)
@@ -141,7 +142,7 @@ func TestUnimplementedFSContext(t *testing.T) {
 		require.NoError(t, err)
 
 		// Closes opened files
-		require.Equal(t, &FSContext{rootFS: sysfs.UnimplementedFS{}}, testFS)
+		require.Equal(t, &FSContext{rootFS: fsapi.UnimplementedFS{}}, testFS)
 	})
 }
 
@@ -152,7 +153,7 @@ func TestCompositeFSContext(t *testing.T) {
 	tmpDir2 := t.TempDir()
 	testFS2 := sysfs.NewDirFS(tmpDir2)
 
-	rootFS, err := sysfs.NewRootFS([]sysfs.FS{testFS2, testFS1}, []string{"/tmp", "/"})
+	rootFS, err := sysfs.NewRootFS([]fsapi.FS{testFS2, testFS1}, []string{"/tmp", "/"})
 	require.NoError(t, err)
 
 	c := Context{}
@@ -273,132 +274,3 @@ func TestFSContext_Renumber(t *testing.T) {
 		require.Equal(t, syscall.ENOTSUP, fsc.Renumber(3, 3))
 	})
 }
-
-func TestStdio(t *testing.T) {
-	// simulate regular file attached to stdin
-	f, err := os.CreateTemp(t.TempDir(), "somefile")
-	require.NoError(t, err)
-	defer f.Close()
-
-	stdin, err := stdinFile(os.Stdin)
-	require.NoError(t, err)
-	stdinStat, err := os.Stdin.Stat()
-	require.NoError(t, err)
-
-	stdinNil, err := stdinFile(nil)
-	require.NoError(t, err)
-
-	stdinFile, err := stdinFile(f)
-	require.NoError(t, err)
-
-	stdout, err := stdioWriterFile("stdout", os.Stdout)
-	require.NoError(t, err)
-	stdoutStat, err := os.Stdout.Stat()
-	require.NoError(t, err)
-
-	stdoutNil, err := stdioWriterFile("stdout", nil)
-	require.NoError(t, err)
-
-	stdoutFile, err := stdioWriterFile("stdout", f)
-	require.NoError(t, err)
-
-	stderr, err := stdioWriterFile("stderr", os.Stderr)
-	require.NoError(t, err)
-	stderrStat, err := os.Stderr.Stat()
-	require.NoError(t, err)
-
-	stderrNil, err := stdioWriterFile("stderr", nil)
-	require.NoError(t, err)
-
-	stderrFile, err := stdioWriterFile("stderr", f)
-	require.NoError(t, err)
-
-	tests := []struct {
-		name string
-		f    *FileEntry
-		// Depending on how the tests run, os.Stdin won't necessarily be a char
-		// device. We compare against an os.File, to account for this.
-		expectedType fs.FileMode
-	}{
-		{
-			name:         "stdin",
-			f:            stdin,
-			expectedType: stdinStat.Mode().Type(),
-		},
-		{
-			name:         "stdin noop",
-			f:            stdinNil,
-			expectedType: fs.ModeDevice,
-		},
-		{
-			name:         "stdin file",
-			f:            stdinFile,
-			expectedType: 0, // normal file
-		},
-		{
-			name:         "stdout",
-			f:            stdout,
-			expectedType: stdoutStat.Mode().Type(),
-		},
-		{
-			name:         "stdout noop",
-			f:            stdoutNil,
-			expectedType: fs.ModeDevice,
-		},
-		{
-			name:         "stdout file",
-			f:            stdoutFile,
-			expectedType: 0, // normal file
-		},
-		{
-			name:         "stderr",
-			f:            stderr,
-			expectedType: stderrStat.Mode().Type(),
-		},
-		{
-			name:         "stderr noop",
-			f:            stderrNil,
-			expectedType: fs.ModeDevice,
-		},
-		{
-			name:         "stderr file",
-			f:            stderrFile,
-			expectedType: 0, // normal file
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name+" Stat", func(t *testing.T) {
-			st, errno := tc.f.File.Stat()
-			require.EqualErrno(t, 0, errno)
-			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
-			require.Equal(t, uint64(1), st.Nlink)
-
-			// Fake times are needed to pass wasi-testsuite.
-			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
-			require.Zero(t, st.Ctim)
-			require.Zero(t, st.Mtim)
-			require.Zero(t, st.Atim)
-		})
-
-		buf := make([]byte, 5)
-		switch tc.f {
-		case stdinNil:
-			t.Run(tc.name+" returns zero on Read", func(t *testing.T) {
-				n, errno := tc.f.File.Read(buf)
-				require.EqualErrno(t, 0, errno)
-				require.Zero(t, n) // like reading io.EOF
-			})
-		case stdoutNil, stderrNil:
-			// This is important because some code will loop forever attempting
-			// to write data. This happened in TestShortHash.
-			t.Run(tc.name+" returns length on Write", func(t *testing.T) {
-				n, errno := tc.f.File.Write(buf)
-				require.EqualErrno(t, 0, errno)
-				require.Equal(t, len(buf), n) // like io.Discard
-			})
-		}
-	}
-}
diff --git a/internal/sys/stdio_test.go b/internal/sys/stdio_test.go
new file mode 100644
index 00000000..eedc7833
--- /dev/null
+++ b/internal/sys/stdio_test.go
@@ -0,0 +1,138 @@
+package sys
+
+import (
+	"io/fs"
+	"os"
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestStdio(t *testing.T) {
+	// simulate regular file attached to stdin
+	f, err := os.CreateTemp(t.TempDir(), "somefile")
+	require.NoError(t, err)
+	defer f.Close()
+
+	stdin, err := stdinFile(os.Stdin)
+	require.NoError(t, err)
+	stdinStat, err := os.Stdin.Stat()
+	require.NoError(t, err)
+
+	stdinNil, err := stdinFile(nil)
+	require.NoError(t, err)
+
+	stdinFile, err := stdinFile(f)
+	require.NoError(t, err)
+
+	stdout, err := stdioWriterFile("stdout", os.Stdout)
+	require.NoError(t, err)
+	stdoutStat, err := os.Stdout.Stat()
+	require.NoError(t, err)
+
+	stdoutNil, err := stdioWriterFile("stdout", nil)
+	require.NoError(t, err)
+
+	stdoutFile, err := stdioWriterFile("stdout", f)
+	require.NoError(t, err)
+
+	stderr, err := stdioWriterFile("stderr", os.Stderr)
+	require.NoError(t, err)
+	stderrStat, err := os.Stderr.Stat()
+	require.NoError(t, err)
+
+	stderrNil, err := stdioWriterFile("stderr", nil)
+	require.NoError(t, err)
+
+	stderrFile, err := stdioWriterFile("stderr", f)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name string
+		f    *FileEntry
+		// Depending on how the tests run, os.Stdin won't necessarily be a char
+		// device. We compare against an os.File, to account for this.
+		expectedType fs.FileMode
+	}{
+		{
+			name:         "stdin",
+			f:            stdin,
+			expectedType: stdinStat.Mode().Type(),
+		},
+		{
+			name:         "stdin noop",
+			f:            stdinNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdin file",
+			f:            stdinFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stdout",
+			f:            stdout,
+			expectedType: stdoutStat.Mode().Type(),
+		},
+		{
+			name:         "stdout noop",
+			f:            stdoutNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stdout file",
+			f:            stdoutFile,
+			expectedType: 0, // normal file
+		},
+		{
+			name:         "stderr",
+			f:            stderr,
+			expectedType: stderrStat.Mode().Type(),
+		},
+		{
+			name:         "stderr noop",
+			f:            stderrNil,
+			expectedType: fs.ModeDevice,
+		},
+		{
+			name:         "stderr file",
+			f:            stderrFile,
+			expectedType: 0, // normal file
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name+" Stat", func(t *testing.T) {
+			st, errno := tc.f.File.Stat()
+			require.EqualErrno(t, 0, errno)
+			require.Equal(t, tc.expectedType, st.Mode&fs.ModeType)
+			require.Equal(t, uint64(1), st.Nlink)
+
+			// Fake times are needed to pass wasi-testsuite.
+			// See https://github.com/WebAssembly/wasi-testsuite/blob/af57727/tests/rust/src/bin/fd_filestat_get.rs#L1-L19
+			require.Zero(t, st.Ctim)
+			require.Zero(t, st.Mtim)
+			require.Zero(t, st.Atim)
+		})
+
+		buf := make([]byte, 5)
+		switch tc.f {
+		case stdinNil:
+			t.Run(tc.name+" returns zero on Read", func(t *testing.T) {
+				n, errno := tc.f.File.Read(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Zero(t, n) // like reading io.EOF
+			})
+		case stdoutNil, stderrNil:
+			// This is important because some code will loop forever attempting
+			// to write data. This happened in TestShortHash.
+			t.Run(tc.name+" returns length on Write", func(t *testing.T) {
+				n, errno := tc.f.File.Write(buf)
+				require.EqualErrno(t, 0, errno)
+				require.Equal(t, len(buf), n) // like io.Discard
+			})
+		}
+	}
+}
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 054c9b84..ed6dae7a 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -9,13 +9,14 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
 func TestAdapt_nil(t *testing.T) {
 	testFS := Adapt(nil)
-	_, ok := testFS.(UnimplementedFS)
+	_, ok := testFS.(fsapi.UnimplementedFS)
 	require.True(t, ok)
 }
 
@@ -95,7 +96,7 @@ func TestAdapt_UtimesNano(t *testing.T) {
 }
 
 func TestAdapt_Open_Read(t *testing.T) {
-	// Create a subdirectory, so we can test reads outside the FS root.
+	// Create a subdirectory, so we can test reads outside the fsapi.FS root.
 	tmpDir := t.TempDir()
 	tmpDir = joinPath(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
@@ -111,7 +112,7 @@ func TestAdapt_Open_Read(t *testing.T) {
 	t.Run("path outside root invalid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
 
-		// fs.FS doesn't allow relative path lookups
+		// fsapi.FS doesn't allow relative path lookups
 		require.EqualErrno(t, syscall.EINVAL, err)
 	})
 }
@@ -139,7 +140,7 @@ func TestAdapt_Stat(t *testing.T) {
 	testStat(t, testFS)
 }
 
-// hackFS cheats the fs.FS contract by opening for write (os.O_RDWR).
+// hackFS cheats the api.FS contract by opening for write (os.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.
@@ -160,7 +161,7 @@ func (dir hackFS) Open(name string) (fs.File, error) {
 }
 
 // TestAdapt_HackedWrites ensures we allow writes even if they violate the
-// fs.FS contract.
+// api.FS contract.
 func TestAdapt_HackedWrites(t *testing.T) {
 	tmpDir := t.TempDir()
 	testFS := Adapt(hackFS(tmpDir))
diff --git a/internal/platform/bench_test.go b/internal/sysfs/bench_test.go
similarity index 92%
rename from internal/platform/bench_test.go
rename to internal/sysfs/bench_test.go
index a3512474..23b20fe5 100644
--- a/internal/platform/bench_test.go
+++ b/internal/sysfs/bench_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"io"
@@ -43,8 +43,8 @@ func BenchmarkFsFileRead(b *testing.B) {
 	}{
 		{name: "os.DirFS Read", fs: dirFS, pread: false},
 		{name: "os.DirFS Pread", fs: dirFS, pread: true},
-		{name: "embed.FS Read", fs: embedFS, pread: false},
-		{name: "embed.FS Pread", fs: embedFS, pread: true},
+		{name: "embed.api.FS Read", fs: embedFS, pread: false},
+		{name: "embed.api.FS Pread", fs: embedFS, pread: true},
 	}
 
 	buf := make([]byte, 3)
diff --git a/internal/platform/chown_unix_test.go b/internal/sysfs/chown_unix_test.go
similarity index 92%
rename from internal/platform/chown_unix_test.go
rename to internal/sysfs/chown_unix_test.go
index 5eecdf79..e0fae498 100644
--- a/internal/platform/chown_unix_test.go
+++ b/internal/sysfs/chown_unix_test.go
@@ -1,6 +1,6 @@
 //go:build !windows
 
-package platform
+package sysfs
 
 import (
 	"fmt"
@@ -166,13 +166,3 @@ func TestLchown(t *testing.T) {
 		require.EqualErrno(t, syscall.ENOENT, Lchown(path.Join(tmpDir, "a"), -1, gid))
 	})
 }
-
-// checkUidGid uses lstat to ensure the comparison is against the file, not the
-// target of a symbolic link.
-func checkUidGid(t *testing.T, path string, uid, gid uint32) {
-	ls, err := os.Lstat(path)
-	require.NoError(t, err)
-	sys := ls.Sys().(*syscall.Stat_t)
-	require.Equal(t, uid, sys.Uid)
-	require.Equal(t, gid, sys.Gid)
-}
diff --git a/internal/platform/dir_test.go b/internal/sysfs/dir_test.go
similarity index 85%
rename from internal/platform/dir_test.go
rename to internal/sysfs/dir_test.go
index 561c74ca..937afac3 100644
--- a/internal/platform/dir_test.go
+++ b/internal/sysfs/dir_test.go
@@ -1,4 +1,4 @@
-package platform_test
+package sysfs_test
 
 import (
 	"io"
@@ -10,8 +10,9 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
-	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -35,7 +36,7 @@ func TestReaddir(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			dotF, errno := platform.OpenFSFile(tc.fs, ".", syscall.O_RDONLY, 0)
+			dotF, errno := sysfs.OpenFSFile(tc.fs, ".", syscall.O_RDONLY, 0)
 			require.EqualErrno(t, 0, errno)
 			defer dotF.Close()
 
@@ -63,7 +64,7 @@ func TestReaddir(t *testing.T) {
 				require.EqualErrno(t, 0, errno)
 			})
 
-			fileF, errno := platform.OpenFSFile(tc.fs, "empty.txt", syscall.O_RDONLY, 0)
+			fileF, errno := sysfs.OpenFSFile(tc.fs, "empty.txt", syscall.O_RDONLY, 0)
 			require.EqualErrno(t, 0, errno)
 			defer fileF.Close()
 
@@ -72,7 +73,7 @@ func TestReaddir(t *testing.T) {
 				require.EqualErrno(t, syscall.ENOTDIR, errno)
 			})
 
-			dirF, errno := platform.OpenFSFile(tc.fs, "dir", syscall.O_RDONLY, 0)
+			dirF, errno := sysfs.OpenFSFile(tc.fs, "dir", syscall.O_RDONLY, 0)
 			require.EqualErrno(t, 0, errno)
 			defer dirF.Close()
 
@@ -90,7 +91,7 @@ func TestReaddir(t *testing.T) {
 				require.EqualErrno(t, 0, errno)
 				require.Equal(t, 1, len(dirents3))
 
-				dirents := []platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
+				dirents := []fsapi.Dirent{dirents1[0], dirents2[0], dirents3[0]}
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
 				requireIno(t, dirents, tc.expectIno)
@@ -100,7 +101,7 @@ func TestReaddir(t *testing.T) {
 					dirents[i].Ino = 0
 				}
 
-				require.Equal(t, []platform.Dirent{
+				require.Equal(t, []fsapi.Dirent{
 					{Name: "-", Type: 0},
 					{Name: "a-", Type: fs.ModeDir},
 					{Name: "ab-", Type: 0},
@@ -111,7 +112,7 @@ func TestReaddir(t *testing.T) {
 				require.EqualErrno(t, 0, errno)
 			})
 
-			subdirF, errno := platform.OpenFSFile(tc.fs, "sub", syscall.O_RDONLY, 0)
+			subdirF, errno := sysfs.OpenFSFile(tc.fs, "sub", syscall.O_RDONLY, 0)
 			require.EqualErrno(t, 0, errno)
 			defer subdirF.Close()
 
@@ -129,7 +130,7 @@ func TestReaddir(t *testing.T) {
 
 	// Don't err if something else removed the directory while reading.
 	t.Run("removed while open", func(t *testing.T) {
-		dirF, errno := platform.OpenFSFile(dirFS, "dir", syscall.O_RDONLY, 0)
+		dirF, errno := sysfs.OpenFSFile(dirFS, "dir", syscall.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 		defer dirF.Close()
 
@@ -152,7 +153,7 @@ func TestReaddir(t *testing.T) {
 	})
 }
 
-func testReaddirAll(t *testing.T, dotF platform.File, expectIno bool) {
+func testReaddirAll(t *testing.T, dotF fsapi.File, expectIno bool) {
 	dirents, errno := dotF.Readdir(-1)
 	require.EqualErrno(t, 0, errno) // no io.EOF when -1 is used
 	sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
@@ -164,7 +165,7 @@ func testReaddirAll(t *testing.T, dotF platform.File, expectIno bool) {
 		dirents[i].Ino = 0
 	}
 
-	require.Equal(t, []platform.Dirent{
+	require.Equal(t, []fsapi.Dirent{
 		{Name: "animals.txt", Type: 0},
 		{Name: "dir", Type: fs.ModeDir},
 		{Name: "empty.txt", Type: 0},
@@ -173,7 +174,7 @@ func testReaddirAll(t *testing.T, dotF platform.File, expectIno bool) {
 	}, dirents)
 }
 
-func requireIno(t *testing.T, dirents []platform.Dirent, expectIno bool) {
+func requireIno(t *testing.T, dirents []fsapi.Dirent, expectIno bool) {
 	for _, e := range dirents {
 		if expectIno {
 			require.NotEqual(t, uint64(0), e.Ino, "%+v", e)
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 633e4393..5e052ead 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -10,6 +10,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -126,7 +127,7 @@ func TestDirFS_MkDir(t *testing.T) {
 	})
 }
 
-func testChmod(t *testing.T, testFS FS, path string) {
+func testChmod(t *testing.T, testFS fsapi.FS, path string) {
 	// Test base case, using 0o444 not 0o400 for read-back on windows.
 	requireMode(t, testFS, path, 0o444)
 
@@ -141,7 +142,7 @@ func testChmod(t *testing.T, testFS FS, path string) {
 	}
 }
 
-func requireMode(t *testing.T, testFS FS, path string, mode fs.FileMode) {
+func requireMode(t *testing.T, testFS fsapi.FS, path string, mode fs.FileMode) {
 	st, errno := testFS.Stat(path)
 	require.EqualErrno(t, 0, errno)
 
@@ -431,7 +432,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 		realPath := path.Join(tmpDir, name)
 		require.NoError(t, os.Mkdir(realPath, 0o700))
 
-		f, errno := testFS.OpenFile(name, platform.O_DIRECTORY, 0o700)
+		f, errno := testFS.OpenFile(name, fsapi.O_DIRECTORY, 0o700)
 		require.EqualErrno(t, 0, errno)
 		defer f.Close()
 
@@ -531,7 +532,7 @@ func TestDirFS_Utimesns(t *testing.T) {
 		err := testFS.Utimens("nope", nil, true)
 		require.EqualErrno(t, syscall.ENOENT, err)
 		err = testFS.Utimens("nope", nil, false)
-		if platform.SupportsSymlinkNoFollow {
+		if SupportsSymlinkNoFollow {
 			require.EqualErrno(t, syscall.ENOENT, err)
 		} else {
 			require.EqualErrno(t, syscall.ENOSYS, err)
@@ -552,35 +553,35 @@ func TestDirFS_Utimesns(t *testing.T) {
 		{
 			name: "a=omit,m=omit",
 			times: &[2]syscall.Timespec{
-				{Sec: 123, Nsec: platform.UTIME_OMIT},
-				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_OMIT},
 			},
 		},
 		{
 			name: "a=now,m=omit",
 			times: &[2]syscall.Timespec{
-				{Sec: 123, Nsec: platform.UTIME_NOW},
-				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_OMIT},
 			},
 		},
 		{
 			name: "a=omit,m=now",
 			times: &[2]syscall.Timespec{
-				{Sec: 123, Nsec: platform.UTIME_OMIT},
-				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_NOW},
 			},
 		},
 		{
 			name: "a=now,m=now",
 			times: &[2]syscall.Timespec{
-				{Sec: 123, Nsec: platform.UTIME_NOW},
-				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
 			},
 		},
 		{
 			name: "a=now,m=set",
 			times: &[2]syscall.Timespec{
-				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
 				{Sec: 123, Nsec: 4 * 1e3},
 			},
 		},
@@ -588,7 +589,7 @@ func TestDirFS_Utimesns(t *testing.T) {
 			name: "a=set,m=now",
 			times: &[2]syscall.Timespec{
 				{Sec: 123, Nsec: 4 * 1e3},
-				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
 			},
 		},
 		{
@@ -644,7 +645,7 @@ func TestDirFS_Utimesns(t *testing.T) {
 				require.EqualErrno(t, 0, errno)
 
 				errno = testFS.Utimens(path, tc.times, !symlinkNoFollow)
-				if symlinkNoFollow && !platform.SupportsSymlinkNoFollow {
+				if symlinkNoFollow && !SupportsSymlinkNoFollow {
 					require.EqualErrno(t, syscall.ENOSYS, errno)
 					return
 				}
@@ -654,9 +655,9 @@ func TestDirFS_Utimesns(t *testing.T) {
 				require.EqualErrno(t, 0, errno)
 
 				if platform.CompilerSupported() {
-					if tc.times != nil && tc.times[0].Nsec == platform.UTIME_OMIT {
+					if tc.times != nil && tc.times[0].Nsec == UTIME_OMIT {
 						require.Equal(t, oldSt.Atim, newSt.Atim)
-					} else if tc.times == nil || tc.times[0].Nsec == platform.UTIME_NOW {
+					} else if tc.times == nil || tc.times[0].Nsec == UTIME_NOW {
 						now := time.Now().UnixNano()
 						require.True(t, newSt.Atim <= now, "expected atim %d <= now %d", newSt.Atim, now)
 					} else {
@@ -665,9 +666,9 @@ func TestDirFS_Utimesns(t *testing.T) {
 				}
 
 				// When compiler isn't supported, we can still check mtim.
-				if tc.times != nil && tc.times[1].Nsec == platform.UTIME_OMIT {
+				if tc.times != nil && tc.times[1].Nsec == UTIME_OMIT {
 					require.Equal(t, oldSt.Mtim, newSt.Mtim)
-				} else if tc.times == nil || tc.times[1].Nsec == platform.UTIME_NOW {
+				} else if tc.times == nil || tc.times[1].Nsec == UTIME_NOW {
 					now := time.Now().UnixNano()
 					require.True(t, newSt.Mtim <= now, "expected mtim %d <= now %d", newSt.Mtim, now)
 				} else {
@@ -681,7 +682,7 @@ func TestDirFS_Utimesns(t *testing.T) {
 func TestDirFS_OpenFile(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	// Create a subdirectory, so we can test reads outside the FS root.
+	// Create a subdirectory, so we can test reads outside the fsapi.FS root.
 	tmpDir = path.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
@@ -695,7 +696,7 @@ func TestDirFS_OpenFile(t *testing.T) {
 	t.Run("path outside root valid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
 
-		// syscall.FS allows relative path lookups
+		// fsapi.FS allows relative path lookups
 		require.True(t, errors.Is(err, fs.ErrNotExist))
 	})
 }
diff --git a/internal/platform/file_test.go b/internal/sysfs/file_test.go
similarity index 88%
rename from internal/platform/file_test.go
rename to internal/sysfs/file_test.go
index 8bbb5fc7..36909681 100644
--- a/internal/platform/file_test.go
+++ b/internal/sysfs/file_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"embed"
@@ -13,21 +13,11 @@ import (
 	gofstest "testing/fstest"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-var _ File = NoopFile{}
-
-// NoopFile shows the minimal methods a type embedding UnimplementedFile must
-// implement.
-type NoopFile struct {
-	UnimplementedFile
-}
-
-// The current design requires the user to consciously implement Close.
-// However, we could change UnimplementedFile to return zero.
-func (NoopFile) Close() (errno syscall.Errno) { return }
-
 //go:embed file_test.go
 var embedFS embed.FS
 
@@ -98,7 +88,7 @@ func TestFileIno(t *testing.T) {
 	dirFS, embedFS, mapFS := dirEmbedMapFS(t, tmpDir)
 
 	// get the expected inode
-	st, errno := Stat(tmpDir)
+	st, errno := stat(tmpDir)
 	require.EqualErrno(t, 0, errno)
 
 	tests := []struct {
@@ -107,7 +97,7 @@ func TestFileIno(t *testing.T) {
 		expectedIno uint64
 	}{
 		{name: "os.DirFS", fs: dirFS, expectedIno: st.Ino},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -159,7 +149,7 @@ func TestFileIsDir(t *testing.T) {
 		fs   fs.FS
 	}{
 		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -208,7 +198,7 @@ func TestFileReadAndPread(t *testing.T) {
 		fs   fs.FS
 	}{
 		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -282,13 +272,13 @@ func TestFilePollRead(t *testing.T) {
 	require.Equal(t, expected, buf[:len(expected)])
 }
 
-func requireRead(t *testing.T, f File, buf []byte) {
+func requireRead(t *testing.T, f fsapi.File, buf []byte) {
 	n, errno := f.Read(buf)
 	require.EqualErrno(t, 0, errno)
 	require.Equal(t, len(buf), n)
 }
 
-func requirePread(t *testing.T, f File, buf []byte, off int64) {
+func requirePread(t *testing.T, f fsapi.File, buf []byte, off int64) {
 	n, errno := f.Pread(buf, off)
 	require.EqualErrno(t, 0, errno)
 	require.Equal(t, len(buf), n)
@@ -302,7 +292,7 @@ func TestFileRead_empty(t *testing.T) {
 		fs   fs.FS
 	}{
 		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -366,13 +356,13 @@ func TestFileRead_Errors(t *testing.T) {
 
 	tests := []struct {
 		name string
-		fn   func(File) syscall.Errno
+		fn   func(fsapi.File) syscall.Errno
 	}{
-		{name: "Read", fn: func(f File) syscall.Errno {
+		{name: "Read", fn: func(f fsapi.File) syscall.Errno {
 			_, errno := f.Read(buf)
 			return errno
 		}},
-		{name: "Pread", fn: func(f File) syscall.Errno {
+		{name: "Pread", fn: func(f fsapi.File) syscall.Errno {
 			_, errno := f.Pread(buf, 0)
 			return errno
 		}},
@@ -400,7 +390,7 @@ func TestFileSeek(t *testing.T) {
 		fs   fs.FS
 	}{
 		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -464,21 +454,21 @@ func TestFileSeek(t *testing.T) {
 	}
 
 	t.Run("os.File directory seek to zero", func(t *testing.T) {
-		d := requireOpenFile(t, os.TempDir(), syscall.O_RDONLY|O_DIRECTORY, 0o666)
+		d := requireOpenFile(t, os.TempDir(), syscall.O_RDONLY|fsapi.O_DIRECTORY, 0o666)
 		defer d.Close()
 
 		_, errno := d.Seek(0, io.SeekStart)
 		require.EqualErrno(t, 0, errno)
 	})
 
-	seekToZero := func(f File) syscall.Errno {
+	seekToZero := func(f fsapi.File) syscall.Errno {
 		_, errno := f.Seek(0, io.SeekStart)
 		return errno
 	}
 	testEBADFIfFileClosed(t, seekToZero)
 }
 
-func requireSeek(t *testing.T, f File, off int64, whence int) int64 {
+func requireSeek(t *testing.T, f fsapi.File, off int64, whence int) int64 {
 	n, errno := f.Seek(off, whence)
 	require.EqualErrno(t, 0, errno)
 	return n
@@ -492,7 +482,7 @@ func TestFileSeek_empty(t *testing.T) {
 		fs   fs.FS
 	}{
 		{name: "os.DirFS", fs: dirFS},
-		{name: "embed.FS", fs: embedFS},
+		{name: "embed.api.FS", fs: embedFS},
 		{name: "fstest.MapFS", fs: mapFS},
 	}
 
@@ -532,7 +522,7 @@ func TestFileSeek_Unsupported(t *testing.T) {
 }
 
 func TestFileWriteAndPwrite(t *testing.T) {
-	// fs.FS doesn't support writes, and there is no other built-in
+	// fsapi.FS doesn't support writes, and there is no other built-in
 	// implementation except os.File.
 	path := path.Join(t.TempDir(), wazeroFile)
 	f := requireOpenFile(t, path, syscall.O_RDWR|os.O_CREATE, 0o600)
@@ -572,20 +562,20 @@ func TestFileWriteAndPwrite(t *testing.T) {
 	require.Equal(t, "wazerowazeroero", string(b))
 }
 
-func requireWrite(t *testing.T, f File, buf []byte) {
+func requireWrite(t *testing.T, f fsapi.File, buf []byte) {
 	n, errno := f.Write(buf)
 	require.EqualErrno(t, 0, errno)
 	require.Equal(t, len(buf), n)
 }
 
-func requirePwrite(t *testing.T, f File, buf []byte, off int64) {
+func requirePwrite(t *testing.T, f fsapi.File, buf []byte, off int64) {
 	n, errno := f.Pwrite(buf, off)
 	require.EqualErrno(t, 0, errno)
 	require.Equal(t, len(buf), n)
 }
 
 func TestFileWrite_empty(t *testing.T) {
-	// fs.FS doesn't support writes, and there is no other built-in
+	// fsapi.FS doesn't support writes, and there is no other built-in
 	// implementation except os.File.
 	path := path.Join(t.TempDir(), emptyFile)
 	f := requireOpenFile(t, path, syscall.O_RDWR|os.O_CREATE, 0o600)
@@ -593,15 +583,15 @@ func TestFileWrite_empty(t *testing.T) {
 
 	tests := []struct {
 		name string
-		fn   func(File, []byte) (int, syscall.Errno)
+		fn   func(fsapi.File, []byte) (int, syscall.Errno)
 	}{
-		{name: "Write", fn: func(f File, buf []byte) (int, syscall.Errno) {
+		{name: "Write", fn: func(f fsapi.File, buf []byte) (int, syscall.Errno) {
 			return f.Write(buf)
 		}},
-		{name: "Pwrite from zero", fn: func(f File, buf []byte) (int, syscall.Errno) {
+		{name: "Pwrite from zero", fn: func(f fsapi.File, buf []byte) (int, syscall.Errno) {
 			return f.Pwrite(buf, 0)
 		}},
-		{name: "Pwrite from 3", fn: func(f File, buf []byte) (int, syscall.Errno) {
+		{name: "Pwrite from 3", fn: func(f fsapi.File, buf []byte) (int, syscall.Errno) {
 			return f.Pwrite(buf, 3)
 		}},
 	}
@@ -635,12 +625,12 @@ func TestFileWrite_Unsupported(t *testing.T) {
 
 	tests := []struct {
 		name string
-		fn   func(File, []byte) (int, syscall.Errno)
+		fn   func(fsapi.File, []byte) (int, syscall.Errno)
 	}{
-		{name: "Write", fn: func(f File, buf []byte) (int, syscall.Errno) {
+		{name: "Write", fn: func(f fsapi.File, buf []byte) (int, syscall.Errno) {
 			return f.Write(buf)
 		}},
-		{name: "Pwrite", fn: func(f File, buf []byte) (int, syscall.Errno) {
+		{name: "Pwrite", fn: func(f fsapi.File, buf []byte) (int, syscall.Errno) {
 			return f.Pwrite(buf, 0)
 		}},
 	}
@@ -672,13 +662,13 @@ func TestFileWrite_Errors(t *testing.T) {
 
 	tests := []struct {
 		name string
-		fn   func(File) syscall.Errno
+		fn   func(fsapi.File) syscall.Errno
 	}{
-		{name: "Write", fn: func(f File) syscall.Errno {
+		{name: "Write", fn: func(f fsapi.File) syscall.Errno {
 			_, errno := f.Write(buf)
 			return errno
 		}},
-		{name: "Pwrite", fn: func(f File) syscall.Errno {
+		{name: "Pwrite", fn: func(f fsapi.File) syscall.Errno {
 			_, errno := f.Pwrite(buf, 0)
 			return errno
 		}},
@@ -699,14 +689,14 @@ func TestFileWrite_Errors(t *testing.T) {
 }
 
 func TestFileSync_NoError(t *testing.T) {
-	testSync_NoError(t, File.Sync)
+	testSync_NoError(t, fsapi.File.Sync)
 }
 
 func TestFileDatasync_NoError(t *testing.T) {
-	testSync_NoError(t, File.Datasync)
+	testSync_NoError(t, fsapi.File.Datasync)
 }
 
-func testSync_NoError(t *testing.T, sync func(File) syscall.Errno) {
+func testSync_NoError(t *testing.T, sync func(fsapi.File) syscall.Errno) {
 	roPath := "file_test.go"
 	ro, errno := OpenFSFile(embedFS, roPath, syscall.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
@@ -719,9 +709,9 @@ func testSync_NoError(t *testing.T, sync func(File) syscall.Errno) {
 
 	tests := []struct {
 		name string
-		f    File
+		f    fsapi.File
 	}{
-		{name: "UnimplementedFile", f: NoopFile{}},
+		{name: "UnimplementedFile", f: fsapi.UnimplementedFile{}},
 		{name: "File of read-only fs.File", f: ro},
 		{name: "File of os.File", f: rw},
 	}
@@ -736,17 +726,17 @@ func testSync_NoError(t *testing.T, sync func(File) syscall.Errno) {
 }
 
 func TestFileSync(t *testing.T) {
-	testSync(t, File.Sync)
+	testSync(t, fsapi.File.Sync)
 }
 
 func TestFileDatasync(t *testing.T) {
-	testSync(t, File.Datasync)
+	testSync(t, fsapi.File.Datasync)
 }
 
 // testSync doesn't guarantee sync works because the operating system may
 // sync anyway. There is no test in Go for syscall.Fdatasync, but closest is
 // similar to below. Effectively, this only tests that things don't error.
-func testSync(t *testing.T, sync func(File) syscall.Errno) {
+func testSync(t *testing.T, sync func(fsapi.File) syscall.Errno) {
 	// Even though it is invalid, try to sync a directory
 	dPath := t.TempDir()
 	d := requireOpenFile(t, dPath, syscall.O_RDONLY, 0)
@@ -838,7 +828,7 @@ func TestFileTruncate(t *testing.T) {
 		})
 	}
 
-	truncateToZero := func(f File) syscall.Errno {
+	truncateToZero := func(f fsapi.File) syscall.Errno {
 		return f.Truncate(0)
 	}
 
@@ -865,7 +855,7 @@ func TestFileUtimens(t *testing.T) {
 	case "linux", "darwin": // supported
 	case "freebsd": // TODO: support freebsd w/o CGO
 	case "windows":
-		if !IsGo120 {
+		if !platform.IsGo120 {
 			t.Skip("windows only works after Go 1.20") // TODO: possibly 1.19 ;)
 		}
 	default: // expect ENOSYS and callers need to fall back to Utimens
@@ -874,10 +864,10 @@ func TestFileUtimens(t *testing.T) {
 
 	testUtimens(t, true)
 
-	testEBADFIfFileClosed(t, func(f File) syscall.Errno {
+	testEBADFIfFileClosed(t, func(f fsapi.File) syscall.Errno {
 		return f.Utimens(nil)
 	})
-	testEBADFIfDirClosed(t, func(d File) syscall.Errno {
+	testEBADFIfDirClosed(t, func(d fsapi.File) syscall.Errno {
 		return d.Utimens(nil)
 	})
 }
@@ -906,7 +896,7 @@ func TestNewStdioFile(t *testing.T) {
 
 	tests := []struct {
 		name string
-		f    File
+		f    fsapi.File
 		// Depending on how the tests run, os.Stdin won't necessarily be a char
 		// device. We compare against an os.File, to account for this.
 		expectedType fs.FileMode
@@ -951,7 +941,7 @@ func TestNewStdioFile(t *testing.T) {
 	}
 }
 
-func testEBADFIfDirClosed(t *testing.T, fn func(File) syscall.Errno) bool {
+func testEBADFIfDirClosed(t *testing.T, fn func(fsapi.File) syscall.Errno) bool {
 	return t.Run("EBADF if dir closed", func(t *testing.T) {
 		d := requireOpenFile(t, t.TempDir(), syscall.O_RDONLY, 0o755)
 
@@ -962,7 +952,7 @@ func testEBADFIfDirClosed(t *testing.T, fn func(File) syscall.Errno) bool {
 	})
 }
 
-func testEBADFIfFileClosed(t *testing.T, fn func(File) syscall.Errno) bool {
+func testEBADFIfFileClosed(t *testing.T, fn func(fsapi.File) syscall.Errno) bool {
 	return t.Run("EBADF if file closed", func(t *testing.T) {
 		tmpDir := t.TempDir()
 
@@ -975,21 +965,24 @@ func testEBADFIfFileClosed(t *testing.T, fn func(File) syscall.Errno) bool {
 	})
 }
 
-func testEISDIR(t *testing.T, fn func(File) syscall.Errno) bool {
+func testEISDIR(t *testing.T, fn func(fsapi.File) syscall.Errno) bool {
 	return t.Run("EISDIR if directory", func(t *testing.T) {
-		f := requireOpenFile(t, os.TempDir(), syscall.O_RDONLY|O_DIRECTORY, 0o666)
+		f := requireOpenFile(t, os.TempDir(), syscall.O_RDONLY|fsapi.O_DIRECTORY, 0o666)
 		defer f.Close()
 
 		require.EqualErrno(t, syscall.EISDIR, fn(f))
 	})
 }
 
-func openForWrite(t *testing.T, path string, content []byte) File {
+func openForWrite(t *testing.T, path string, content []byte) fsapi.File {
 	require.NoError(t, os.WriteFile(path, content, 0o0666))
-	return requireOpenFile(t, path, syscall.O_RDWR, 0o666)
+	f := requireOpenFile(t, path, syscall.O_RDWR, 0o666)
+	_, errno := f.Write(content)
+	require.EqualErrno(t, 0, errno)
+	return f
 }
 
-func requireOpenFile(t *testing.T, path string, flag int, perm fs.FileMode) File {
+func requireOpenFile(t *testing.T, path string, flag int, perm fs.FileMode) fsapi.File {
 	f, errno := OpenOSFile(path, flag, perm)
 	require.EqualErrno(t, 0, errno)
 	return f
diff --git a/internal/platform/futimens_test.go b/internal/sysfs/futimens_test.go
similarity index 96%
rename from internal/platform/futimens_test.go
rename to internal/sysfs/futimens_test.go
index a33636c1..eb8318dc 100644
--- a/internal/platform/futimens_test.go
+++ b/internal/sysfs/futimens_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"os"
@@ -8,6 +8,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -147,7 +148,7 @@ func testUtimens(t *testing.T, futimes bool) {
 					panic(tc)
 				}
 
-				oldSt, errno := Lstat(statPath)
+				oldSt, errno := lstat(statPath)
 				require.EqualErrno(t, 0, errno)
 
 				if !futimes {
@@ -174,10 +175,10 @@ func testUtimens(t *testing.T, futimes bool) {
 					require.EqualErrno(t, 0, errno)
 				}
 
-				newSt, errno := Lstat(statPath)
+				newSt, errno := lstat(statPath)
 				require.EqualErrno(t, 0, errno)
 
-				if CompilerSupported() {
+				if platform.CompilerSupported() {
 					if tc.times != nil && tc.times[0].Nsec == UTIME_OMIT {
 						require.Equal(t, oldSt.Atim, newSt.Atim)
 					} else if tc.times == nil || tc.times[0].Nsec == UTIME_NOW {
diff --git a/internal/platform/open_file_test.go b/internal/sysfs/open_file_test.go
similarity index 90%
rename from internal/platform/open_file_test.go
rename to internal/sysfs/open_file_test.go
index 03954dde..30e98db3 100644
--- a/internal/platform/open_file_test.go
+++ b/internal/sysfs/open_file_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"os"
@@ -7,6 +7,7 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -95,18 +96,18 @@ func TestOpenFile_Errors(t *testing.T) {
 		err := os.Symlink(target, symlink)
 		require.NoError(t, err)
 
-		_, errno := OpenFile(symlink, O_DIRECTORY|O_NOFOLLOW, 0o0666)
+		_, errno := OpenFile(symlink, fsapi.O_DIRECTORY|fsapi.O_NOFOLLOW, 0o0666)
 		require.EqualErrno(t, syscall.ENOTDIR, errno)
 
-		_, errno = OpenFile(symlink, O_NOFOLLOW, 0o0666)
+		_, errno = OpenFile(symlink, fsapi.O_NOFOLLOW, 0o0666)
 		require.EqualErrno(t, syscall.ELOOP, errno)
 	})
 
 	t.Run("opening a directory writeable is EISDIR", func(t *testing.T) {
-		_, errno := OpenFile(tmpDir, O_DIRECTORY|syscall.O_WRONLY, 0o0666)
+		_, errno := OpenFile(tmpDir, fsapi.O_DIRECTORY|syscall.O_WRONLY, 0o0666)
 		require.EqualErrno(t, syscall.EISDIR, errno)
 
-		_, errno = OpenFile(tmpDir, O_DIRECTORY|syscall.O_WRONLY, 0o0666)
+		_, errno = OpenFile(tmpDir, fsapi.O_DIRECTORY|syscall.O_WRONLY, 0o0666)
 		require.EqualErrno(t, syscall.EISDIR, errno)
 	})
 }
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 8eb2f242..b8be36e0 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -7,6 +7,7 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -15,9 +16,9 @@ func TestNewReadFS(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	// Doesn't double-wrap file systems that are already read-only
-	require.Equal(t, UnimplementedFS{}, NewReadFS(UnimplementedFS{}))
+	require.Equal(t, fsapi.UnimplementedFS{}, NewReadFS(fsapi.UnimplementedFS{}))
 
-	// Wraps a fs.FS because it allows access to Write
+	// Wraps a fsapi.FS because it allows access to Write
 	adapted := Adapt(os.DirFS(tmpDir))
 	require.NotEqual(t, adapted, NewReadFS(adapted))
 
@@ -133,7 +134,7 @@ func TestReadFS_Open_Read(t *testing.T) {
 
 	type test struct {
 		name      string
-		fs        FS
+		fs        fsapi.FS
 		expectIno bool
 	}
 
diff --git a/internal/platform/rename_test.go b/internal/sysfs/rename_test.go
similarity index 97%
rename from internal/platform/rename_test.go
rename to internal/sysfs/rename_test.go
index 3a5e0cc4..84d17f33 100644
--- a/internal/platform/rename_test.go
+++ b/internal/sysfs/rename_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"errors"
@@ -7,6 +7,7 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -53,7 +54,7 @@ func TestRename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err := os.Stat(dir1Path)
-		require.EqualErrno(t, syscall.ENOENT, UnwrapOSError(err))
+		require.EqualErrno(t, syscall.ENOENT, platform.UnwrapOSError(err))
 
 		s, err := os.Stat(dir2Path)
 		require.NoError(t, err)
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
index 270e494c..fc47d535 100644
--- a/internal/sysfs/rootfs_test.go
+++ b/internal/sysfs/rootfs_test.go
@@ -11,6 +11,7 @@ import (
 	"testing"
 	gofstest "testing/fstest"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -21,12 +22,12 @@ func TestNewRootFS(t *testing.T) {
 		rootFS, err := NewRootFS(nil, nil)
 		require.NoError(t, err)
 
-		require.Equal(t, UnimplementedFS{}, rootFS)
+		require.Equal(t, fsapi.UnimplementedFS{}, rootFS)
 	})
 	t.Run("only root", func(t *testing.T) {
 		testFS := NewDirFS(t.TempDir())
 
-		rootFS, err := NewRootFS([]FS{testFS}, []string{""})
+		rootFS, err := NewRootFS([]fsapi.FS{testFS}, []string{""})
 		require.NoError(t, err)
 
 		// Should not be a composite filesystem
@@ -35,11 +36,11 @@ func TestNewRootFS(t *testing.T) {
 	t.Run("only non root", func(t *testing.T) {
 		testFS := NewDirFS(".")
 
-		rootFS, err := NewRootFS([]FS{testFS}, []string{"/tmp"})
+		rootFS, err := NewRootFS([]fsapi.FS{testFS}, []string{"/tmp"})
 		require.NoError(t, err)
 
 		// unwrapping returns in original order
-		require.Equal(t, []FS{testFS}, rootFS.(*CompositeFS).FS())
+		require.Equal(t, []fsapi.FS{testFS}, rootFS.(*CompositeFS).FS())
 		require.Equal(t, []string{"/tmp"}, rootFS.(*CompositeFS).GuestPaths())
 
 		// String is human-readable
@@ -63,13 +64,13 @@ func TestNewRootFS(t *testing.T) {
 	t.Run("multiple roots unsupported", func(t *testing.T) {
 		testFS := NewDirFS(".")
 
-		_, err := NewRootFS([]FS{testFS, testFS}, []string{"/", "/"})
+		_, err := NewRootFS([]fsapi.FS{testFS, testFS}, []string{"/", "/"})
 		require.EqualError(t, err, "multiple root filesystems are invalid: [.:/ .:/]")
 	})
 	t.Run("virtual paths unsupported", func(t *testing.T) {
 		testFS := NewDirFS(".")
 
-		_, err := NewRootFS([]FS{testFS}, []string{"usr/bin"})
+		_, err := NewRootFS([]fsapi.FS{testFS}, []string{"usr/bin"})
 		require.EqualError(t, err, "only single-level guest paths allowed: [.:usr/bin]")
 	})
 	t.Run("multiple matches", func(t *testing.T) {
@@ -82,11 +83,11 @@ func TestNewRootFS(t *testing.T) {
 		testFS2 := NewDirFS(tmpDir2)
 		require.NoError(t, os.WriteFile(path.Join(tmpDir2, "a"), []byte{2}, 0o600))
 
-		rootFS, err := NewRootFS([]FS{testFS2, testFS1}, []string{"/tmp", "/"})
+		rootFS, err := NewRootFS([]fsapi.FS{testFS2, testFS1}, []string{"/tmp", "/"})
 		require.NoError(t, err)
 
 		// unwrapping returns in original order
-		require.Equal(t, []FS{testFS2, testFS1}, rootFS.(*CompositeFS).FS())
+		require.Equal(t, []fsapi.FS{testFS2, testFS1}, rootFS.(*CompositeFS).FS())
 		require.Equal(t, []string{"/tmp", "/"}, rootFS.(*CompositeFS).GuestPaths())
 
 		// Should be a composite filesystem
@@ -125,7 +126,7 @@ func TestRootFS_String(t *testing.T) {
 	tmpFS := NewDirFS(".")
 	rootFS := NewDirFS(".")
 
-	testFS, err := NewRootFS([]FS{rootFS, tmpFS}, []string{"/", "/tmp"})
+	testFS, err := NewRootFS([]fsapi.FS{rootFS, tmpFS}, []string{"/", "/tmp"})
 	require.NoError(t, err)
 
 	require.Equal(t, "[.:/ .:/tmp]", testFS.String())
@@ -134,14 +135,14 @@ func TestRootFS_String(t *testing.T) {
 func TestRootFS_Open(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	// Create a subdirectory, so we can test reads outside the FS root.
+	// Create a subdirectory, so we can test reads outside the fsapi.FS root.
 	tmpDir = path.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	testRootFS := NewDirFS(tmpDir)
 	testDirFS := NewDirFS(t.TempDir())
-	testFS, err := NewRootFS([]FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
+	testFS, err := NewRootFS([]fsapi.FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
 	require.NoError(t, err)
 
 	testOpen_Read(t, testFS, true)
@@ -151,7 +152,7 @@ func TestRootFS_Open(t *testing.T) {
 	t.Run("path outside root valid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
 
-		// syscall.FS allows relative path lookups
+		// fsapi.FS allows relative path lookups
 		require.True(t, errors.Is(err, fs.ErrNotExist))
 	})
 }
@@ -161,7 +162,7 @@ func TestRootFS_Stat(t *testing.T) {
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	tmpFS := NewDirFS(t.TempDir())
-	testFS, err := NewRootFS([]FS{NewDirFS(tmpDir), tmpFS}, []string{"/", "/tmp"})
+	testFS, err := NewRootFS([]fsapi.FS{NewDirFS(tmpDir), tmpFS}, []string{"/", "/tmp"})
 	require.NoError(t, err)
 	testStat(t, testFS)
 }
@@ -169,7 +170,7 @@ func TestRootFS_Stat(t *testing.T) {
 func TestRootFS_examples(t *testing.T) {
 	tests := []struct {
 		name                 string
-		fs                   []FS
+		fs                   []fsapi.FS
 		guestPaths           []string
 		expected, unexpected []string
 	}{
@@ -178,7 +179,7 @@ func TestRootFS_examples(t *testing.T) {
 		//	$ wazero run -mount=src/text/template:/ template.wasm -test.v
 		{
 			name: "go test text/template",
-			fs: []FS{
+			fs: []fsapi.FS{
 				&adapter{fs: testfs.FS{"go-example-stdout-ExampleTemplate-0.txt": &testfs.File{}}},
 				&adapter{fs: testfs.FS{"testdata/file1.tmpl": &testfs.File{}}},
 			},
@@ -191,7 +192,7 @@ func TestRootFS_examples(t *testing.T) {
 		//	$ wazero run -mount=$(go env GOROOT)/src/compress/flate:/ flate.wasm -test.v
 		{
 			name: "tinygo test compress/flate",
-			fs: []FS{
+			fs: []fsapi.FS{
 				&adapter{fs: testfs.FS{}},
 				&adapter{fs: testfs.FS{"testdata/e.txt": &testfs.File{}}},
 				&adapter{fs: testfs.FS{"testdata/Isaac.Newton-Opticks.txt": &testfs.File{}}},
@@ -205,7 +206,7 @@ func TestRootFS_examples(t *testing.T) {
 		//	$ wazero run -mount=src/net:/ net.wasm -test.v -test.short
 		{
 			name: "go test net",
-			fs: []FS{
+			fs: []fsapi.FS{
 				&adapter{fs: testfs.FS{"services": &testfs.File{}}},
 				&adapter{fs: testfs.FS{"testdata/aliases": &testfs.File{}}},
 			},
@@ -219,7 +220,7 @@ func TestRootFS_examples(t *testing.T) {
 		//	  -env=PYTHONPATH=/opt/wasi-python/lib/python3.11 opt/wasi-python/bin/python3.wasm
 		{
 			name: "python",
-			fs: []FS{
+			fs: []fsapi.FS{
 				&adapter{fs: gofstest.MapFS{ // to allow resolution of "."
 					"pybuilddir.txt": &gofstest.MapFile{},
 					"opt/wasi-python/lib/python3.11/__phello__/__init__.py": &gofstest.MapFile{},
@@ -237,7 +238,7 @@ func TestRootFS_examples(t *testing.T) {
 		//	  --test-cmd-bin -target wasm32-wasi --zig-lib-dir ./lib ./lib/std/std.zig
 		{
 			name: "zig",
-			fs: []FS{
+			fs: []fsapi.FS{
 				&adapter{fs: testfs.FS{"zig-cache": &testfs.File{}}},
 				&adapter{fs: testfs.FS{"qSQRrUkgJX9L20mr": &testfs.File{}}},
 			},
diff --git a/internal/platform/select_test.go b/internal/sysfs/select_test.go
similarity index 95%
rename from internal/platform/select_test.go
rename to internal/sysfs/select_test.go
index fff86461..50d2e690 100644
--- a/internal/platform/select_test.go
+++ b/internal/sysfs/select_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"os"
@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -68,7 +69,7 @@ func TestSelect(t *testing.T) {
 		_, err = ww.Write([]byte("TEST"))
 		require.NoError(t, err)
 
-		rFdSet := &FdSet{}
+		rFdSet := &platform.FdSet{}
 		fd := int(rr.Fd())
 		rFdSet.Set(fd)
 
diff --git a/internal/platform/select_windows_test.go b/internal/sysfs/select_windows_test.go
similarity index 99%
rename from internal/platform/select_windows_test.go
rename to internal/sysfs/select_windows_test.go
index 561eb9cf..8a53565a 100644
--- a/internal/platform/select_windows_test.go
+++ b/internal/sysfs/select_windows_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"context"
diff --git a/internal/platform/stat_test.go b/internal/sysfs/stat_test.go
similarity index 74%
rename from internal/platform/stat_test.go
rename to internal/sysfs/stat_test.go
index 2b0bb837..30024517 100644
--- a/internal/platform/stat_test.go
+++ b/internal/sysfs/stat_test.go
@@ -1,7 +1,6 @@
-package platform
+package sysfs
 
 import (
-	"io/fs"
 	"os"
 	"path"
 	"runtime"
@@ -9,100 +8,23 @@ import (
 	"testing"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func TestLstat(t *testing.T) {
-	tmpDir := t.TempDir()
-
-	_, errno := Lstat(path.Join(tmpDir, "cat"))
-	require.EqualErrno(t, syscall.ENOENT, errno)
-	_, errno = Lstat(path.Join(tmpDir, "sub/cat"))
-	require.EqualErrno(t, syscall.ENOENT, errno)
-
-	var st Stat_t
-	t.Run("dir", func(t *testing.T) {
-		st, errno = Lstat(tmpDir)
-		require.EqualErrno(t, 0, errno)
-
-		require.True(t, st.Mode.IsDir())
-		require.NotEqual(t, uint64(0), st.Ino)
-	})
-
-	file := path.Join(tmpDir, "file")
-	var stFile Stat_t
-
-	t.Run("file", func(t *testing.T) {
-		require.NoError(t, os.WriteFile(file, []byte{1, 2}, 0o400))
-		stFile, errno = Lstat(file)
-		require.EqualErrno(t, 0, errno)
-
-		require.Zero(t, stFile.Mode.Type())
-		require.Equal(t, int64(2), stFile.Size)
-		require.NotEqual(t, uint64(0), stFile.Ino)
-	})
-
-	t.Run("link to file", func(t *testing.T) {
-		requireLinkStat(t, file, stFile)
-	})
-
-	subdir := path.Join(tmpDir, "sub")
-	var stSubdir Stat_t
-	t.Run("subdir", func(t *testing.T) {
-		require.NoError(t, os.Mkdir(subdir, 0o500))
-
-		stSubdir, errno = Lstat(subdir)
-		require.EqualErrno(t, 0, errno)
-
-		require.True(t, stSubdir.Mode.IsDir())
-		require.NotEqual(t, uint64(0), stSubdir.Ino)
-	})
-
-	t.Run("link to dir", func(t *testing.T) {
-		requireLinkStat(t, subdir, stSubdir)
-	})
-
-	t.Run("link to dir link", func(t *testing.T) {
-		pathLink := subdir + "-link"
-		stLink, errno := Lstat(pathLink)
-		require.EqualErrno(t, 0, errno)
-
-		requireLinkStat(t, pathLink, stLink)
-	})
-}
-
-func requireLinkStat(t *testing.T, path string, stat Stat_t) {
-	link := path + "-link"
-	require.NoError(t, os.Symlink(path, link))
-
-	stLink, errno := Lstat(link)
-	require.EqualErrno(t, 0, errno)
-
-	require.NotEqual(t, uint64(0), stLink.Ino)
-	require.NotEqual(t, stat.Ino, stLink.Ino) // inodes are not equal
-	require.Equal(t, fs.ModeSymlink, stLink.Mode.Type())
-	// From https://linux.die.net/man/2/lstat:
-	// The size of a symbolic link is the length of the pathname it
-	// contains, without a terminating null byte.
-	if runtime.GOOS == "windows" { // size is zero, not the path length
-		require.Zero(t, stLink.Size)
-	} else {
-		require.Equal(t, int64(len(path)), stLink.Size)
-	}
-}
-
 func TestStat(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	_, errno := Stat(path.Join(tmpDir, "cat"))
+	_, errno := stat(path.Join(tmpDir, "cat"))
 	require.EqualErrno(t, syscall.ENOENT, errno)
-	_, errno = Stat(path.Join(tmpDir, "sub/cat"))
+	_, errno = stat(path.Join(tmpDir, "sub/cat"))
 	require.EqualErrno(t, syscall.ENOENT, errno)
 
-	var st Stat_t
+	var st fsapi.Stat_t
 
 	t.Run("dir", func(t *testing.T) {
-		st, errno = Stat(tmpDir)
+		st, errno = stat(tmpDir)
 		require.EqualErrno(t, 0, errno)
 
 		require.True(t, st.Mode.IsDir())
@@ -110,12 +32,12 @@ func TestStat(t *testing.T) {
 	})
 
 	file := path.Join(tmpDir, "file")
-	var stFile Stat_t
+	var stFile fsapi.Stat_t
 
 	t.Run("file", func(t *testing.T) {
 		require.NoError(t, os.WriteFile(file, nil, 0o400))
 
-		stFile, errno = Stat(file)
+		stFile, errno = stat(file)
 		require.EqualErrno(t, 0, errno)
 
 		require.False(t, stFile.Mode.IsDir())
@@ -126,18 +48,18 @@ func TestStat(t *testing.T) {
 		link := path.Join(tmpDir, "file-link")
 		require.NoError(t, os.Symlink(file, link))
 
-		stLink, errno := Stat(link)
+		stLink, errno := stat(link)
 		require.EqualErrno(t, 0, errno)
 
 		require.Equal(t, stFile, stLink) // resolves to the file
 	})
 
 	subdir := path.Join(tmpDir, "sub")
-	var stSubdir Stat_t
+	var stSubdir fsapi.Stat_t
 	t.Run("subdir", func(t *testing.T) {
 		require.NoError(t, os.Mkdir(subdir, 0o500))
 
-		stSubdir, errno = Stat(subdir)
+		stSubdir, errno = stat(subdir)
 		require.EqualErrno(t, 0, errno)
 
 		require.True(t, stSubdir.Mode.IsDir())
@@ -148,7 +70,7 @@ func TestStat(t *testing.T) {
 		link := path.Join(tmpDir, "dir-link")
 		require.NoError(t, os.Symlink(subdir, link))
 
-		stLink, errno := Stat(link)
+		stLink, errno := stat(link)
 		require.EqualErrno(t, 0, errno)
 
 		require.Equal(t, stSubdir, stLink) // resolves to the dir
@@ -333,10 +255,10 @@ func TestStatFile_dev_inode(t *testing.T) {
 	require.Equal(t, st1.Ino, st1Again.Ino)
 }
 
-func requireDirectoryDevIno(t *testing.T, st Stat_t) {
+func requireDirectoryDevIno(t *testing.T, st fsapi.Stat_t) {
 	// windows before go 1.20 has trouble reading the inode information on
 	// directories.
-	if runtime.GOOS != "windows" || IsGo120 {
+	if runtime.GOOS != "windows" || platform.IsGo120 {
 		require.NotEqual(t, uint64(0), st.Dev)
 		require.NotEqual(t, uint64(0), st.Ino)
 	} else {
@@ -363,7 +285,7 @@ func TestStat_uid_gid(t *testing.T) {
 		require.NoError(t, os.Mkdir(dir, 0o0777))
 		require.EqualErrno(t, 0, chgid(dir, gid))
 
-		st, errno := Stat(dir)
+		st, errno := stat(dir)
 		require.EqualErrno(t, 0, errno)
 
 		require.Equal(t, uid, st.Uid)
@@ -376,20 +298,20 @@ func TestStat_uid_gid(t *testing.T) {
 		require.NoError(t, os.Symlink(tmpDir, link))
 		require.EqualErrno(t, 0, chgid(link, gid))
 
-		st, errno := Lstat(link)
+		st, errno := lstat(link)
 		require.EqualErrno(t, 0, errno)
 
 		require.Equal(t, uid, st.Uid)
 		require.Equal(t, gid, st.Gid)
 	})
 
-	t.Run("StatFile", func(t *testing.T) {
+	t.Run("statFile", func(t *testing.T) {
 		tmpDir := t.TempDir()
 		file := path.Join(tmpDir, "file")
 		require.NoError(t, os.WriteFile(file, nil, 0o0666))
 		require.EqualErrno(t, 0, chgid(file, gid))
 
-		st, errno := Lstat(file)
+		st, errno := lstat(file)
 		require.EqualErrno(t, 0, errno)
 
 		require.Equal(t, uid, st.Uid)
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 0be0eb84..2566ed1c 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -11,11 +11,12 @@ import (
 	"syscall"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
+func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS fsapi.FS) {
 	file := "file"
 	realPath := path.Join(tmpDir, file)
 	err := os.WriteFile(realPath, []byte{}, 0o600)
@@ -68,7 +69,7 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
 	}
 }
 
-func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
+func testOpen_Read(t *testing.T, testFS fsapi.FS, expectIno bool) {
 	t.Run("doesn't exist", func(t *testing.T) {
 		_, errno := testFS.OpenFile("nope", os.O_RDONLY, 0)
 
@@ -88,7 +89,7 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 			dirents[i].Ino = 0
 		}
 
-		require.Equal(t, []platform.Dirent{
+		require.Equal(t, []fsapi.Dirent{
 			{Name: "animals.txt", Type: 0},
 			{Name: "dir", Type: fs.ModeDir},
 			{Name: "empty.txt", Type: 0},
@@ -124,7 +125,7 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 		require.EqualErrno(t, 0, errno)
 		require.Equal(t, 1, len(dirents3))
 
-		dirents := []platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
+		dirents := []fsapi.Dirent{dirents1[0], dirents2[0], dirents3[0]}
 		sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
 		requireIno(t, dirents, expectIno)
@@ -134,7 +135,7 @@ func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 			dirents[i].Ino = 0
 		}
 
-		require.Equal(t, []platform.Dirent{
+		require.Equal(t, []fsapi.Dirent{
 			{Name: "-", Type: 0},
 			{Name: "a-", Type: fs.ModeDir},
 			{Name: "ab-", Type: 0},
@@ -197,18 +198,18 @@ human
 	})
 
 	t.Run("opening a directory with O_RDWR is EISDIR", func(t *testing.T) {
-		_, errno := testFS.OpenFile("sub", platform.O_DIRECTORY|os.O_RDWR, 0)
+		_, errno := testFS.OpenFile("sub", fsapi.O_DIRECTORY|os.O_RDWR, 0)
 		require.EqualErrno(t, syscall.EISDIR, errno)
 	})
 }
 
-func testLstat(t *testing.T, testFS FS) {
+func testLstat(t *testing.T, testFS fsapi.FS) {
 	_, errno := testFS.Lstat("cat")
 	require.EqualErrno(t, syscall.ENOENT, errno)
 	_, errno = testFS.Lstat("sub/cat")
 	require.EqualErrno(t, syscall.ENOENT, errno)
 
-	var st platform.Stat_t
+	var st fsapi.Stat_t
 
 	t.Run("dir", func(t *testing.T) {
 		st, errno = testFS.Lstat(".")
@@ -217,7 +218,7 @@ func testLstat(t *testing.T, testFS FS) {
 		require.NotEqual(t, uint64(0), st.Ino)
 	})
 
-	var stFile platform.Stat_t
+	var stFile fsapi.Stat_t
 
 	t.Run("file", func(t *testing.T) {
 		stFile, errno = testFS.Lstat("animals.txt")
@@ -232,7 +233,7 @@ func testLstat(t *testing.T, testFS FS) {
 		requireLinkStat(t, testFS, "animals.txt", stFile)
 	})
 
-	var stSubdir platform.Stat_t
+	var stSubdir fsapi.Stat_t
 	t.Run("subdir", func(t *testing.T) {
 		stSubdir, errno = testFS.Lstat("sub")
 		require.EqualErrno(t, 0, errno)
@@ -254,7 +255,7 @@ func testLstat(t *testing.T, testFS FS) {
 	})
 }
 
-func requireLinkStat(t *testing.T, testFS FS, path string, stat platform.Stat_t) {
+func requireLinkStat(t *testing.T, testFS fsapi.FS, path string, stat fsapi.Stat_t) {
 	link := path + "-link"
 	stLink, errno := testFS.Lstat(link)
 	require.EqualErrno(t, 0, errno)
@@ -271,7 +272,7 @@ func requireLinkStat(t *testing.T, testFS FS, path string, stat platform.Stat_t)
 	}
 }
 
-func testStat(t *testing.T, testFS FS) {
+func testStat(t *testing.T, testFS fsapi.FS) {
 	_, errno := testFS.Stat("cat")
 	require.EqualErrno(t, syscall.ENOENT, errno)
 	_, errno = testFS.Stat("sub/cat")
@@ -295,7 +296,7 @@ func testStat(t *testing.T, testFS FS) {
 	}
 }
 
-func readAll(t *testing.T, f platform.File) []byte {
+func readAll(t *testing.T, f fsapi.File) []byte {
 	st, errno := f.Stat()
 	require.EqualErrno(t, 0, errno)
 	buf := make([]byte, st.Size)
@@ -306,7 +307,7 @@ func readAll(t *testing.T, f platform.File) []byte {
 
 // requireReaddir ensures the input file is a directory, and returns its
 // entries.
-func requireReaddir(t *testing.T, f platform.File, n int, expectIno bool) []platform.Dirent {
+func requireReaddir(t *testing.T, f fsapi.File, n int, expectIno bool) []fsapi.Dirent {
 	entries, errno := f.Readdir(n)
 	require.EqualErrno(t, 0, errno)
 
@@ -315,7 +316,7 @@ func requireReaddir(t *testing.T, f platform.File, n int, expectIno bool) []plat
 	return entries
 }
 
-func testReadlink(t *testing.T, readFS, writeFS FS) {
+func testReadlink(t *testing.T, readFS, writeFS fsapi.FS) {
 	testLinks := []struct {
 		old, dst string
 	}{
@@ -347,7 +348,7 @@ func testReadlink(t *testing.T, readFS, writeFS FS) {
 	})
 }
 
-func requireIno(t *testing.T, dirents []platform.Dirent, expectIno bool) {
+func requireIno(t *testing.T, dirents []fsapi.Dirent, expectIno bool) {
 	for i := range dirents {
 		d := dirents[i]
 		if expectIno {
diff --git a/internal/platform/unlink_test.go b/internal/sysfs/unlink_test.go
similarity index 98%
rename from internal/platform/unlink_test.go
rename to internal/sysfs/unlink_test.go
index cdd579b1..b44261e7 100644
--- a/internal/platform/unlink_test.go
+++ b/internal/sysfs/unlink_test.go
@@ -1,4 +1,4 @@
-package platform
+package sysfs
 
 import (
 	"os"
diff --git a/internal/testing/hammer/hammer.go b/internal/testing/hammer/hammer.go
index bee9ccf1..82e86961 100644
--- a/internal/testing/hammer/hammer.go
+++ b/internal/testing/hammer/hammer.go
@@ -74,7 +74,9 @@ func (h *hammer) Run(test func(name string), onRunning func()) {
 		go func() { // Launch goroutine 'p'
 			defer func() { // Ensure each require.XX failure is visible on hammer test fail.
 				if recovered := recover(); recovered != nil {
-					h.t.Error(recovered.(string))
+					// Has been seen to be string, runtime.errorString, and it may be others. Let
+					// printing take care of conversion in a generic way.
+					h.t.Error(recovered)
 				}
 				finished <- 1
 			}()
diff --git a/internal/wasm/module_instance_test.go b/internal/wasm/module_instance_test.go
index e9eec315..3699f930 100644
--- a/internal/wasm/module_instance_test.go
+++ b/internal/wasm/module_instance_test.go
@@ -155,7 +155,7 @@ func TestModuleInstance_Close(t *testing.T) {
 		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
-		// In sysfs.FS, non syscall errors map to syscall.EIO.
+		// In internalapi.FS, non syscall errors map to syscall.EIO.
 		require.EqualErrno(t, syscall.EIO, m.Close(testCtx))
 
 		// Verify our intended side-effect
@@ -254,7 +254,7 @@ func TestModuleInstance_CallDynamic(t *testing.T) {
 		m, err := s.Instantiate(testCtx, &Module{}, t.Name(), sysCtx, nil)
 		require.NoError(t, err)
 
-		// In sysfs.FS, non syscall errors map to syscall.EIO.
+		// In internalapi.FS, non syscall errors map to syscall.EIO.
 		require.EqualErrno(t, syscall.EIO, m.Close(testCtx))
 
 		// Verify our intended side-effect