Repository: tetratelabs/wazero
Pull request #1140 (2023-02-20 04:30:53)

sysfs: adds FS.Stat and companions in platform
This centralizes filestat logic by making our own `Stat_t` similar to `syscall.Stat_t`. This exposes utilities in the platform package and adds a new function `FS.Stat` which avoids having to use `fs.File` to get the same info. Doing so at the FS abstraction allows us to optimize how it is implemented internally using portable means (e.g. `os.StatFile`) or OS-specific means where necessary, e.g. in windows.

This also ensures `platform.OpenFile` returns syscall.Errno and centralizes error checking with a new `require.EqualErrno` test.

codefromthecrypt: I will resolve the glitch tinygo found and backfill a normal test for it:

```
=== RUN   TestStatBadDir
[885](https://github.com/tetratelabs/wazero/actions/runs/4220217670/jobs/7326309383#step:6:886)
--- FAIL: TestStatBadDir (0.00s)
[886](https://github.com/tetratelabs/wazero/actions/runs/4220217670/jobs/7326309383#step:6:887)
    Mkdir error = &T{Op:"stat", Path:"/tmp/not-exist/really-not-exist", Err:0x36}; want PathError for path "/tmp/not-exist/really-not-exist" satisifying IsNotExist
```
codefromthecrypt: Err:0x36 is ErrNotDir in wasese
codefromthecrypt: reworked the PR to ensure `platform.OpenFile` returns syscall.Errno and
centralized error checking with a new `require.EqualErrno` test.
codefromthecrypt: sometimes doing stat on a closed file is EIO or EBADF, I'll coerce to the latter, as it is a better errno

Patch:
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 31c67e77..77063475 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -234,6 +234,10 @@ jobs:
       - name: Install wazero
         run: go install ./cmd/wazero
 
+      # This runs the previously compiled TinyGo tests with wazero. If you need
+      # to troubleshoot one, you can add "-hostlogging=filesystem" and also a
+      # trailing argument narrowing which test to execute.
+      # e.g. "-test.run '^TestStatBadDir$'"
       - name: Run standard library tests
         run: |
           for bin in *.test; do
diff --git a/Makefile b/Makefile
index 2d635d05..bba12b22 100644
--- a/Makefile
+++ b/Makefile
@@ -220,7 +220,9 @@ check:
 # This makes sure the intepreter can be used. Most often the package that can
 # drift here is "platform" or "sysfs":
 #
-# Ensure we build on an arbitrary operating system
+# Ensure we build on windows:
+	@GOARCH=amd64 GOOS=windows go build ./...
+# Ensure we build on an arbitrary operating system:
 	@GOARCH=amd64 GOOS=dragonfly go build ./...
 # Ensure we build on linux arm for Dapr:
 #	gh release view -R dapr/dapr --json assets --jq 'first(.assets[] | select(.name = "daprd_linux_arm.tar.gz") | {url, downloadCount})'
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 1bce1ff2..4d3c575a 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -6,7 +6,6 @@ import (
 	"io"
 	"io/fs"
 	"math"
-	"os"
 	pathutil "path"
 	"reflect"
 	"syscall"
@@ -97,28 +96,22 @@ func fdAllocateFn(_ context.Context, mod api.Module, params []uint64) Errno {
 		return ErrnoInval
 	}
 
-	st, err := f.Stat()
-	if err != nil {
+	var st platform.Stat_t
+	if err := f.Stat(&st); err != nil {
 		return ToErrno(err)
 	}
 
-	if st.Size() >= tail {
+	if st.Size >= tail {
 		// We already have enough space.
 		return ErrnoSuccess
 	}
 
-	// This is implemented the implementation of fs.File by all platforms.
-	// TODO: this should be removed once we have fs.File.
-	type truncatable interface {
-		Truncate(size int64) error
-	}
-
-	osf, ok := f.File.(truncatable)
+	osf, ok := f.File.(truncater)
 	if !ok {
 		return ErrnoBadf
 	}
 
-	if err = osf.Truncate(tail); err != nil {
+	if err := osf.Truncate(tail); err != nil {
 		return ToErrno(err)
 	}
 	return ErrnoSuccess
@@ -357,12 +350,12 @@ func fdFilestatGetFunc(mod api.Module, fd, resultBuf uint32) Errno {
 		return ErrnoBadf
 	}
 
-	stat, err := f.Stat()
-	if err != nil {
+	var st platform.Stat_t
+	if err := f.Stat(&st); err != nil {
 		return ToErrno(err)
 	}
 
-	if err = writeFilestat(buf, f.File, stat); err != nil {
+	if err := writeFilestat(buf, &st); err != nil {
 		return ToErrno(err)
 	}
 
@@ -385,22 +378,15 @@ func getWasiFiletype(fileMode fs.FileMode) uint8 {
 	return wasiFileType
 }
 
-func writeFilestat(buf []byte, f fs.File, stat fs.FileInfo) (err error) {
-	filetype := getWasiFiletype(stat.Mode())
-	filesize := uint64(stat.Size())
-	atimeNsec, mtimeNsec, ctimeNsec, nlink, device, inode, err := platform.Stat(f, stat)
-	if err != nil {
-		return err
-	}
-
-	le.PutUint64(buf, device)
-	le.PutUint64(buf[8:], inode)
-	le.PutUint64(buf[16:], uint64(filetype))
-	le.PutUint64(buf[24:], nlink)
-	le.PutUint64(buf[32:], filesize)
-	le.PutUint64(buf[40:], uint64(atimeNsec))
-	le.PutUint64(buf[48:], uint64(mtimeNsec))
-	le.PutUint64(buf[56:], uint64(ctimeNsec))
+func writeFilestat(buf []byte, st *platform.Stat_t) (err error) {
+	le.PutUint64(buf, st.Dev)
+	le.PutUint64(buf[8:], st.Ino)
+	le.PutUint64(buf[16:], uint64(getWasiFiletype(st.Mode)))
+	le.PutUint64(buf[24:], st.Nlink)
+	le.PutUint64(buf[32:], uint64(st.Size))
+	le.PutUint64(buf[40:], uint64(st.Atim))
+	le.PutUint64(buf[48:], uint64(st.Mtim))
+	le.PutUint64(buf[56:], uint64(st.Ctim))
 	return
 }
 
@@ -491,16 +477,15 @@ func fdFilestatSetTimesFn(_ context.Context, mod api.Module, params []uint64) Er
 	// Handle if either parameter should be taken from stat.
 	if statAtime || statMtime {
 		// Get the current timestamp via Stat in order to un-change after calling FS.Utimes().
-		st, err := f.Stat()
-		if err != nil {
-			return ErrnoBadf
+		var st platform.Stat_t
+		if err := f.Stat(&st); err != nil {
+			return ToErrno(err)
 		}
-		atimeNsec, mtimeNsec, _ := platform.StatTimes(st)
 		if statAtime {
-			atime = atimeNsec
+			atime = st.Atim
 		}
 		if statMtime {
-			mtime = mtimeNsec
+			mtime = st.Mtim
 		}
 	}
 
@@ -1418,12 +1403,8 @@ func pathFilestatGetFn(_ context.Context, mod api.Module, params []uint64) Errno
 	}
 
 	// Stat the file without allocating a file descriptor
-	f, err := preopen.OpenFile(pathName, os.O_RDONLY, 0)
-	if err != nil {
-		return ToErrno(err)
-	}
-	stat, err := sysfs.StatFile(f)
-	if err != nil {
+	var st platform.Stat_t
+	if err := preopen.Stat(pathName, &st); err != nil {
 		return ToErrno(err)
 	}
 
@@ -1434,7 +1415,7 @@ func pathFilestatGetFn(_ context.Context, mod api.Module, params []uint64) Errno
 		return ErrnoFault
 	}
 
-	if err = writeFilestat(buf, f, stat); err != nil {
+	if err := writeFilestat(buf, &st); err != nil {
 		return ToErrno(err)
 	}
 	return ErrnoSuccess
diff --git a/internal/gojs/errno.go b/internal/gojs/errno.go
index ea09bb48..6ee5e0fb 100644
--- a/internal/gojs/errno.go
+++ b/internal/gojs/errno.go
@@ -3,7 +3,7 @@ package gojs
 import (
 	"syscall"
 
-	"github.com/tetratelabs/wazero/internal/sysfs"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // Errno is a (GOARCH=wasm) error, which must match a key in mapJSError.
@@ -60,7 +60,7 @@ var (
 //
 // This should match wasi_snapshot_preview1.ToErrno for maintenance ease.
 func ToErrno(err error) *Errno {
-	errno := sysfs.UnwrapOSError(err)
+	errno := platform.UnwrapOSError(err)
 
 	switch errno {
 	case syscall.EACCES:
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index d1f468d0..d691b324 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -127,16 +127,11 @@ func (jsfsStat) invoke(ctx context.Context, mod api.Module, args ...interface{})
 func syscallStat(mod api.Module, path string) (*jsSt, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	f, err := fsc.RootFS().OpenFile(path, os.O_RDONLY, 0)
-	if err != nil {
+	var stat platform.Stat_t
+	if err := fsc.RootFS().Stat(path, &stat); err != nil {
 		return nil, err
 	}
-	defer f.Close()
-	if stat, err := sysfs.StatFile(f); err != nil {
-		return nil, err
-	} else {
-		return newJsSt(stat, f), nil
-	}
+	return newJsSt(&stat), nil
 }
 
 // jsfsLstat implements jsFn for syscall.Lstat
@@ -190,23 +185,24 @@ func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
 		return nil, syscall.EBADF
 	}
 
-	stat, err := f.Stat()
-	if err != nil {
+	var st platform.Stat_t
+	if err := f.Stat(&st); err != nil {
 		return nil, err
 	}
-	return newJsSt(stat, f.File), nil
+	return newJsSt(&st), nil
 }
 
-func newJsSt(stat fs.FileInfo, f fs.File) *jsSt {
+func newJsSt(stat *platform.Stat_t) *jsSt {
 	ret := &jsSt{}
-	ret.isDir = stat.IsDir()
-	_, _, _, _, ret.dev, ret.ino, _ = platform.Stat(f, stat)
-	ret.mode = getJsMode(stat.Mode())
-	ret.size = stat.Size()
-	atimeNsec, mtimeNsec, ctimeNsec := platform.StatTimes(stat)
-	ret.atimeMs = atimeNsec / 1e6
-	ret.mtimeMs = mtimeNsec / 1e6
-	ret.ctimeMs = ctimeNsec / 1e6
+	ret.isDir = stat.Mode.IsDir()
+	ret.dev = stat.Dev
+	ret.ino = stat.Ino
+	ret.mode = getJsMode(stat.Mode)
+	ret.nlink = uint32(stat.Nlink)
+	ret.size = stat.Size
+	ret.atimeMs = stat.Atim / 1e6
+	ret.mtimeMs = stat.Mtim / 1e6
+	ret.ctimeMs = stat.Ctim / 1e6
 	return ret
 }
 
diff --git a/internal/platform/errno.go b/internal/platform/errno.go
new file mode 100644
index 00000000..c2941934
--- /dev/null
+++ b/internal/platform/errno.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package platform
+
+func adjustErrno(err error) error {
+	return err
+}
diff --git a/internal/platform/errno_windows.go b/internal/platform/errno_windows.go
new file mode 100644
index 00000000..a860ac6a
--- /dev/null
+++ b/internal/platform/errno_windows.go
@@ -0,0 +1,64 @@
+package platform
+
+import "syscall"
+
+// See https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
+const (
+	// ERROR_ACCESS_DENIED is a Windows error returned by syscall.Unlink
+	// instead of syscall.EPERM
+	ERROR_ACCESS_DENIED = syscall.Errno(5)
+
+	// ERROR_INVALID_HANDLE is a Windows error returned by syscall.Write
+	// instead of syscall.EBADF
+	ERROR_INVALID_HANDLE = syscall.Errno(6)
+
+	// ERROR_FILE_EXISTS is a Windows error returned by os.OpenFile
+	// instead of syscall.EEXIST
+	ERROR_FILE_EXISTS = syscall.Errno(0x50)
+
+	// ERROR_NEGATIVE_SEEK is a Windows error returned by os.Truncate
+	// instead of syscall.EINVAL
+	ERROR_NEGATIVE_SEEK = syscall.Errno(0x83)
+
+	// ERROR_DIR_NOT_EMPTY is a Windows error returned by syscall.Rmdir
+	// instead of syscall.ENOTEMPTY
+	ERROR_DIR_NOT_EMPTY = syscall.Errno(0x91)
+
+	// ERROR_ALREADY_EXISTS is a Windows error returned by os.Mkdir
+	// instead of syscall.EEXIST
+	ERROR_ALREADY_EXISTS = syscall.Errno(0xB7)
+
+	// ERROR_DIRECTORY is a Windows error returned by syscall.Rmdir
+	// instead of syscall.ENOTDIR
+	ERROR_DIRECTORY = syscall.Errno(0x10B)
+)
+
+// See https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--1300-1699-
+const (
+	// ERROR_PRIVILEGE_NOT_HELD is a Windows error returned by os.Symlink
+	// instead of syscall.EPERM.
+	//
+	// Note: This can happen when trying to create symlinks w/o admin perms.
+	ERROR_PRIVILEGE_NOT_HELD = syscall.Errno(0x522)
+)
+
+func adjustErrno(err syscall.Errno) error {
+	// Note: In windows, ERROR_PATH_NOT_FOUND(0x3) maps to syscall.ENOTDIR
+	switch err {
+	case ERROR_ALREADY_EXISTS:
+		return syscall.EEXIST
+	case ERROR_DIRECTORY:
+		return syscall.ENOTDIR
+	case ERROR_DIR_NOT_EMPTY:
+		return syscall.ENOTEMPTY
+	case ERROR_FILE_EXISTS:
+		return syscall.EEXIST
+	case ERROR_INVALID_HANDLE:
+		return syscall.EBADF
+	case ERROR_ACCESS_DENIED, ERROR_PRIVILEGE_NOT_HELD:
+		return syscall.EPERM
+	case ERROR_NEGATIVE_SEEK:
+		return syscall.EINVAL
+	}
+	return err
+}
diff --git a/internal/platform/error.go b/internal/platform/error.go
new file mode 100644
index 00000000..7730715a
--- /dev/null
+++ b/internal/platform/error.go
@@ -0,0 +1,50 @@
+package platform
+
+import (
+	"io/fs"
+	"os"
+	"syscall"
+)
+
+// UnwrapOSError returns a syscall.Errno or nil if the input is nil.
+func UnwrapOSError(err error) error {
+	if err == nil {
+		return nil
+	}
+	err = underlyingError(err)
+	if se, ok := err.(syscall.Errno); ok {
+		return adjustErrno(se)
+	}
+	// Below are all the fs.ErrXXX in fs.go.
+	//
+	// Note: Once we have our own file type, we should never see these.
+	switch err {
+	case nil:
+	case fs.ErrInvalid:
+		return syscall.EINVAL
+	case fs.ErrPermission:
+		return syscall.EPERM
+	case fs.ErrExist:
+		return syscall.EEXIST
+	case fs.ErrNotExist:
+		return syscall.ENOENT
+	case fs.ErrClosed:
+		return syscall.EBADF
+	}
+	return syscall.EIO
+}
+
+// underlyingError returns the underlying error if a well-known OS error type.
+//
+// This impl is basically the same as os.underlyingError in os/error.go
+func underlyingError(err error) error {
+	switch err := err.(type) {
+	case *os.PathError:
+		return err.Err
+	case *os.LinkError:
+		return err.Err
+	case *os.SyscallError:
+		return err.Err
+	}
+	return err
+}
diff --git a/internal/platform/open_file.go b/internal/platform/open_file.go
index bed40d37..cff965a3 100644
--- a/internal/platform/open_file.go
+++ b/internal/platform/open_file.go
@@ -15,6 +15,9 @@ const (
 	O_NOFOLLOW  = syscall.O_NOFOLLOW
 )
 
-func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
-	return os.OpenFile(name, flag, perm)
+// OpenFile is like os.OpenFile except it returns syscall.Errno
+func OpenFile(name string, flag int, perm fs.FileMode) (f *os.File, err error) {
+	f, err = os.OpenFile(name, flag, perm)
+	err = UnwrapOSError(err)
+	return
 }
diff --git a/internal/platform/open_file_js.go b/internal/platform/open_file_js.go
index 32cdc62d..496e966a 100644
--- a/internal/platform/open_file_js.go
+++ b/internal/platform/open_file_js.go
@@ -13,7 +13,9 @@ const (
 	O_NOFOLLOW  = 1 << 30
 )
 
-func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
+func OpenFile(name string, flag int, perm fs.FileMode) (f *os.File, err error) {
 	flag &= ^(O_DIRECTORY | O_NOFOLLOW) // erase placeholders
-	return os.OpenFile(name, flag, perm)
+	f, err = os.OpenFile(name, flag, perm)
+	err = UnwrapOSError(err)
+	return
 }
diff --git a/internal/platform/open_file_windows.go b/internal/platform/open_file_windows.go
index 8955366b..66414a47 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/platform/open_file_windows.go
@@ -1,7 +1,6 @@
 package platform
 
 import (
-	"errors"
 	"io/fs"
 	"os"
 	"syscall"
@@ -32,41 +31,44 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 	isDir := flag&O_DIRECTORY > 0
 	flag &= ^(O_DIRECTORY | O_NOFOLLOW) // erase placeholders
 
+	// TODO: document why we are opening twice
 	fd, err := open(name, flag|syscall.O_CLOEXEC, uint32(perm))
 	if err == nil {
 		return os.NewFile(uintptr(fd), name), nil
 	}
+
 	// TODO: Set FILE_SHARE_DELETE for directory as well.
 	f, err := os.OpenFile(name, flag, perm)
-	if err != nil {
-		if errors.Is(err, syscall.ENOTDIR) {
-			err = syscall.ENOENT
-		} else if errors.Is(err, syscall.ERROR_FILE_EXISTS) {
-			err = syscall.EEXIST
-		} else if notFound := errors.Is(err, syscall.ERROR_FILE_NOT_FOUND); notFound && isDir {
-			// Either symlink or hard link directory not found. We change the returned errno depending on
-			// if it is symlink or not to have consistent behavior across OSes.
-			st, e := os.Lstat(name)
-			if e == nil && st.Mode()&os.ModeSymlink != 0 {
-				// Dangling symlink dir must raise ENOTIDR.
+	if err = UnwrapOSError(err); err == nil {
+		return f, nil
+	}
+
+	switch err {
+	case syscall.ENOTDIR:
+		err = syscall.ENOENT
+	case syscall.ENOENT:
+		if isSymlink(name) {
+			// Either symlink or hard link not found. We change the returned
+			// errno depending on if it is symlink or not to have consistent
+			// behavior across OSes.
+			if isDir {
+				// Dangling symlink dir must raise ENOTDIR.
 				err = syscall.ENOTDIR
 			} else {
-				err = syscall.ENOENT
-			}
-		} else if notFound {
-			// Either symlink or hard link file not found. We change the returned errno depending on
-			// if it is symlink or not to have consistent behavior across OSes.
-			st, e := os.Lstat(name)
-			if e == nil && st.Mode()&os.ModeSymlink != 0 {
 				err = syscall.ELOOP
-			} else {
-				err = syscall.ENOENT
 			}
 		}
 	}
 	return f, err
 }
 
+func isSymlink(path string) bool {
+	if st, e := os.Lstat(path); e == nil && st.Mode()&os.ModeSymlink != 0 {
+		return true
+	}
+	return false
+}
+
 // The following is lifted from syscall_windows.go to add support for setting FILE_SHARE_DELETE.
 // https://github.com/golang/go/blob/go1.20/src/syscall/syscall_windows.go#L308-L379
 func open(path string, mode int, perm uint32) (fd syscall.Handle, err error) {
diff --git a/internal/platform/stat.go b/internal/platform/stat.go
index 26904166..a19685a3 100644
--- a/internal/platform/stat.go
+++ b/internal/platform/stat.go
@@ -2,32 +2,94 @@ package platform
 
 import (
 	"io/fs"
-	"os"
+	"syscall"
 )
 
-// StatTimes returns platform-specific values if os.FileInfo Sys is available.
-// Otherwise, it returns the mod time for all values.
-func StatTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
-	if t.Sys() == nil { // possibly fake filesystem
-		atimeNsec, mtimeNsec, ctimeNsec = mtimes(t)
+// Stat_t is similar to syscall.Stat_t, and fields frequently used by
+// WebAssembly ABI including WASI snapshot-01, GOOS=js and wasi-filesystem.
+//
+// # Note
+//
+// Zero values may be returned where not available. For example, fs.FileInfo
+// implementations may not be able to provide Ino values.
+type Stat_t struct {
+	// Dev is the device ID of device containing the file.
+	Dev uint64
+
+	// Ino is the file serial number.
+	Ino uint64
+
+	// Mode is the same as Mode on fs.FileInfo containing bits to identify the
+	// type of the file and its permissions (fs.ModePerm).
+	Mode fs.FileMode
+
+	/// Nlink is the number of hard links to the file.
+	Nlink uint64
+	// ^^ uint64 not uint16 to accept widest syscall.Stat_t.Nlink
+
+	// Size is the length in bytes for regular files. For symbolic links, this
+	// is length in bytes of the pathname contained in the symbolic link.
+	Size int64
+	// ^^ int64 not uint64 to defer to fs.FileInfo
+
+	// Atim is the last data access timestamp in epoch nanoseconds.
+	Atim int64
+
+	// Mtim is the last data modification timestamp in epoch nanoseconds.
+	Mtim int64
+
+	// Ctim is the last file status change timestamp in epoch nanoseconds.
+	Ctim int64
+}
+
+// Stat is like syscall.Stat. This returns syscall.ENOENT if the path doesn't
+// exist.
+func Stat(path string, stat *Stat_t) (err error) {
+	// TODO: The current windows needs the file to be an open handle. See if
+	// we can avoid this and call os.Stat instead.
+	f, err := OpenFile(path, syscall.O_RDONLY, 0)
+	if err != nil {
 		return
 	}
-	return statTimes(t)
+	defer f.Close()
+	return StatFile(f, stat)
 }
 
-// Stat returns platform-specific values if os.FileInfo Sys is available.
-func Stat(f fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlink, dev, inode uint64, err error) {
-	if t.Sys() == nil { // possibly fake filesystem
-		atimeNsec, mtimeNsec, ctimeNsec = mtimes(t)
-		nlink = 1
+// StatFile is like syscall.Fstat, but for fs.File instead of a file
+// descriptor. This returns syscall.EBADF if the file or directory was closed.
+// Note: windows allows you to stat a closed directory.
+func StatFile(f fs.File, stat *Stat_t) (err error) {
+	t, err := f.Stat()
+	if err = UnwrapOSError(err); err != nil {
+		if err == syscall.EIO { // linux/darwin returns this on a closed file.
+			err = syscall.EBADF // windows returns this, which is better.
+		}
 		return
 	}
-	return stat(f, t)
+	return fillStat(stat, f, t)
 }
 
-func mtimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
-	mtimeNsec = t.ModTime().UnixNano()
-	atimeNsec = mtimeNsec
-	ctimeNsec = mtimeNsec
+// fder is implemented by os.File in file_unix.go and file_windows.go
+// Note: we use this until we finalize our own FD-scoped file.
+type fder interface{ Fd() (fd uintptr) }
+
+func fillStat(stat *Stat_t, f fs.File, t fs.FileInfo) (err error) {
+	if of, ok := f.(fder); !ok { // possibly fake filesystem
+		fillStatFromFileInfo(stat, t)
+	} else {
+		err = fillStatFromOpenFile(stat, of.Fd(), t)
+	}
 	return
 }
+
+func fillStatFromFileInfo(stat *Stat_t, t fs.FileInfo) {
+	stat.Ino = 0
+	stat.Dev = 0
+	stat.Mode = t.Mode()
+	stat.Nlink = 1
+	stat.Size = t.Size()
+	mtim := t.ModTime().UnixNano() // Set all times to the mod time
+	stat.Atim = mtim
+	stat.Mtim = mtim
+	stat.Ctim = mtim
+}
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
index 4d065e62..4c7e7ab3 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/platform/stat_bsd.go
@@ -3,24 +3,22 @@
 package platform
 
 import (
-	"io/fs"
 	"os"
 	"syscall"
 )
 
-func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
+func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
 	d := t.Sys().(*syscall.Stat_t)
+	stat.Ino = d.Ino
+	stat.Dev = uint64(d.Dev)
+	stat.Mode = t.Mode()
+	stat.Nlink = uint64(d.Nlink)
+	stat.Size = d.Size
 	atime := d.Atimespec
+	stat.Atim = atime.Sec*1e9 + atime.Nsec
 	mtime := d.Mtimespec
+	stat.Mtim = mtime.Sec*1e9 + mtime.Nsec
 	ctime := d.Ctimespec
-	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec
-}
-
-func stat(_ fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlink, dev, inode uint64, err error) {
-	d := t.Sys().(*syscall.Stat_t)
-	atime := d.Atimespec
-	mtime := d.Mtimespec
-	ctime := d.Ctimespec
-	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec,
-		uint64(d.Nlink), uint64(d.Dev), uint64(d.Ino), nil
+	stat.Ctim = ctime.Sec*1e9 + ctime.Nsec
+	return
 }
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
index ca6ee60f..e665cbee 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/platform/stat_linux.go
@@ -6,23 +6,22 @@
 package platform
 
 import (
-	"io/fs"
 	"os"
 	"syscall"
 )
 
-func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
+func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
 	d := t.Sys().(*syscall.Stat_t)
+	stat.Ino = uint64(d.Ino)
+	stat.Dev = uint64(d.Dev)
+	stat.Mode = t.Mode()
+	stat.Nlink = uint64(d.Nlink)
+	stat.Size = d.Size
 	atime := d.Atim
+	stat.Atim = atime.Sec*1e9 + atime.Nsec
 	mtime := d.Mtim
+	stat.Mtim = mtime.Sec*1e9 + mtime.Nsec
 	ctime := d.Ctim
-	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec
-}
-
-func stat(_ fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlink, dev, inode uint64, err error) {
-	d := t.Sys().(*syscall.Stat_t)
-	atime := d.Atim
-	mtime := d.Mtim
-	ctime := d.Ctim
-	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec, uint64(d.Nlink), uint64(d.Dev), uint64(d.Ino), nil
+	stat.Ctim = ctime.Sec*1e9 + ctime.Nsec
+	return
 }
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
index 29fd0b6c..83f204ca 100644
--- a/internal/platform/stat_unsupported.go
+++ b/internal/platform/stat_unsupported.go
@@ -2,17 +2,9 @@
 
 package platform
 
-import (
-	"io/fs"
-	"os"
-)
+import "os"
 
-func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
-	atimeNsec, mtimeNsec, ctimeNsec = mtimes(t)
-	return
-}
-
-func stat(_ fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlink, dev, inode uint64, err error) {
-	atimeNsec, mtimeNsec, ctimeNsec = mtimes(t)
+func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
+	fillStatFromFileInfo(stat, t)
 	return
 }
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
index 43fdc13c..cd1df421 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/platform/stat_windows.go
@@ -3,37 +3,13 @@
 package platform
 
 import (
-	"io/fs"
 	"os"
 	"syscall"
 )
 
-// The following interfaces are used until we finalize our own FD-scoped file.
-type (
-	// fder is implemented by os.File in file_unix.go and file_windows.go
-	fder interface{ Fd() (fd uintptr) }
-)
-
-func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
-	d := t.Sys().(*syscall.Win32FileAttributeData)
-	atimeNsec = d.LastAccessTime.Nanoseconds()
-	mtimeNsec = d.LastWriteTime.Nanoseconds()
-	ctimeNsec = d.CreationTime.Nanoseconds()
-	return
-}
-
-func stat(f fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlink, dev, inode uint64, err error) {
+func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
 	d := t.Sys().(*syscall.Win32FileAttributeData)
-	atimeNsec = d.LastAccessTime.Nanoseconds()
-	mtimeNsec = d.LastWriteTime.Nanoseconds()
-	ctimeNsec = d.CreationTime.Nanoseconds()
-
-	of, ok := f.(fder)
-	if !ok {
-		return
-	}
-
-	handle := syscall.Handle(of.Fd())
+	handle := syscall.Handle(fd)
 	var info syscall.ByHandleFileInformation
 	if err = syscall.GetFileInformationByHandle(handle, &info); err != nil {
 		// If the file descriptor is already closed, we have to re-open just like
@@ -47,9 +23,16 @@ func stat(f fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlin
 			err = nil
 		}
 	}
-	nlink, dev = uint64(info.NumberOfLinks), uint64(info.VolumeSerialNumber)
+
 	// 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
-	inode = (uint64(info.FileIndexHigh) << 32) | uint64(info.FileIndexLow)
+	stat.Ino = (uint64(info.FileIndexHigh) << 32) | uint64(info.FileIndexLow)
+	stat.Dev = uint64(info.VolumeSerialNumber)
+	stat.Mode = t.Mode()
+	stat.Nlink = uint64(info.NumberOfLinks)
+	stat.Size = t.Size()
+	stat.Atim = d.LastAccessTime.Nanoseconds()
+	stat.Mtim = d.LastWriteTime.Nanoseconds()
+	stat.Ctim = d.CreationTime.Nanoseconds()
 	return
 }
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 8d554595..2a51efc8 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -181,17 +181,18 @@ func (f *FileEntry) IsDir() bool {
 	if f.isDirectory {
 		return true
 	}
-	_, _ = f.Stat() // Maybe the file hasn't had stat yet.
+	var stat platform.Stat_t
+	_ = f.Stat(&stat) // Maybe the file hasn't had stat yet.
 	return f.isDirectory
 }
 
 // Stat returns the underlying stat of this file.
-func (f *FileEntry) Stat() (stat fs.FileInfo, err error) {
-	stat, err = sysfs.StatFile(f.File)
-	if err == nil && stat.IsDir() {
+func (f *FileEntry) Stat(stat *platform.Stat_t) (err error) {
+	err = platform.StatFile(f.File, stat)
+	if err == nil && stat.Mode.IsDir() {
 		f.isDirectory = true
 	}
-	return stat, err
+	return
 }
 
 // ReadDir is the status of a prior fs.ReadDirFile call.
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index d0962860..aaecbf28 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -7,6 +7,8 @@ import (
 	pathutil "path"
 	"runtime"
 	"strings"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // Adapt adapts the input to FS unless it is already one. NewDirFS should be
@@ -44,7 +46,7 @@ func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, er
 	f, err := a.fs.Open(path)
 
 	if err != nil {
-		return nil, UnwrapOSError(err)
+		return nil, platform.UnwrapOSError(err)
 	} else if osF, ok := f.(*os.File); ok {
 		// If this is an OS file, it has same portability issues as dirFS.
 		return maybeWrapFile(osF, a, path, flag, perm), nil
@@ -52,6 +54,17 @@ func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, er
 	return f, nil
 }
 
+// Stat implements FS.Stat
+func (a *adapter) Stat(path string, stat *platform.Stat_t) error {
+	name := cleanPath(path)
+	f, err := a.fs.Open(name)
+	if err != nil {
+		return platform.UnwrapOSError(err)
+	}
+	defer f.Close()
+	return platform.StatFile(f, stat)
+}
+
 func cleanPath(name string) string {
 	if len(name) == 0 {
 		return name
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 552588c4..027ebf51 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -41,18 +41,23 @@ func (d *dirFS) Open(name string) (fs.File, error) {
 }
 
 // OpenFile implements FS.OpenFile
-func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
-	f, err := platform.OpenFile(d.join(name), flag, perm)
-	if err != nil {
-		return nil, UnwrapOSError(err)
+func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
+	if f, err := platform.OpenFile(d.join(path), flag, perm); err != nil {
+		return nil, err
+	} else {
+		return maybeWrapFile(f, d, path, flag, perm), nil
 	}
-	return maybeWrapFile(f, d, name, flag, perm), nil
+}
+
+// Stat implements FS.Stat
+func (d *dirFS) Stat(path string, stat *platform.Stat_t) error {
+	return platform.Stat(d.join(path), stat)
 }
 
 // Mkdir implements FS.Mkdir
 func (d *dirFS) Mkdir(name string, perm fs.FileMode) (err error) {
 	err = os.Mkdir(d.join(name), perm)
-	if err = UnwrapOSError(err); err == syscall.ENOTDIR {
+	if err = platform.UnwrapOSError(err); err == syscall.ENOTDIR {
 		err = syscall.ENOENT
 	}
 	return
@@ -61,14 +66,14 @@ func (d *dirFS) Mkdir(name string, perm fs.FileMode) (err error) {
 // Chmod implements FS.Chmod
 func (d *dirFS) Chmod(name string, perm fs.FileMode) error {
 	err := os.Chmod(d.join(name), perm)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Rename implements FS.Rename
 func (d *dirFS) Rename(from, to string) error {
 	from, to = d.join(from), d.join(to)
 	err := platform.Rename(from, to)
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Readlink implements FS.Readlink
@@ -77,7 +82,7 @@ func (d *dirFS) Readlink(path string, buf []byte) (n int, err error) {
 	// In any case, syscall.Readlink does almost the same logic as os.Readlink.
 	res, err := os.Readlink(d.join(path))
 	if err != nil {
-		err = UnwrapOSError(err)
+		err = platform.UnwrapOSError(err)
 		return
 	}
 
@@ -94,20 +99,19 @@ func (d *dirFS) Readlink(path string, buf []byte) (n int, err error) {
 // Link implements FS.Link.
 func (d *dirFS) Link(oldName, newName string) error {
 	err := os.Link(d.join(oldName), d.join(newName))
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Rmdir implements FS.Rmdir
 func (d *dirFS) Rmdir(name string) error {
 	err := syscall.Rmdir(d.join(name))
-	err = UnwrapOSError(err)
-	return adjustRmdirError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Unlink implements FS.Unlink
 func (d *dirFS) Unlink(name string) (err error) {
 	err = syscall.Unlink(d.join(name))
-	if err = UnwrapOSError(err); err == syscall.EPERM {
+	if err = platform.UnwrapOSError(err); err == syscall.EPERM {
 		err = syscall.EISDIR
 	}
 	return
@@ -119,7 +123,7 @@ func (d *dirFS) Symlink(oldName, link string) (err error) {
 	// when dereference the `link` on its usage (e.g. readlink, read, etc).
 	// https://github.com/bytecodealliance/cap-std/blob/v1.0.4/cap-std/src/fs/dir.rs#L404-L409
 	err = os.Symlink(oldName, d.join(link))
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Utimes implements FS.Utimes
@@ -128,15 +132,14 @@ func (d *dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
 		syscall.NsecToTimespec(atimeNsec),
 		syscall.NsecToTimespec(mtimeNsec),
 	})
-	return UnwrapOSError(err)
+	return platform.UnwrapOSError(err)
 }
 
 // Truncate implements FS.Truncate
 func (d *dirFS) Truncate(name string, size int64) error {
 	// Use os.Truncate as syscall.Truncate doesn't exist on Windows.
 	err := os.Truncate(d.join(name), size)
-	err = UnwrapOSError(err)
-	return adjustTruncateError(err)
+	return platform.UnwrapOSError(err)
 }
 
 func (d *dirFS) join(name string) string {
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 001e2ca4..630cc3e2 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -5,6 +5,8 @@ import (
 	"io/fs"
 	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // NewReadFS is used to mask an existing FS for reads. Notably, this allows
@@ -125,3 +127,8 @@ func maskForReads(f fs.File) fs.File {
 		panic("BUG: unhandled pattern")
 	}
 }
+
+// Stat implements FS.Stat
+func (r *readFS) Stat(path string, stat *platform.Stat_t) error {
+	return r.fs.Stat(path, stat)
+}
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index 1c9ce515..f39841cb 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -7,6 +7,8 @@ import (
 	"strings"
 	"syscall"
 	"time"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 func NewRootFS(fs []FS, guestPaths []string) (FS, error) {
@@ -190,27 +192,33 @@ func (d *openRootDir) readDir() (err error) {
 }
 
 func (d *openRootDir) rootEntry(name string, fsI int) (fs.DirEntry, error) {
-	if fi, err := StatPath(d.c.fs[fsI], "."); err != nil {
+	var stat platform.Stat_t
+	if err := d.c.fs[fsI].Stat(".", &stat); err != nil {
 		return nil, err
 	} else {
-		return fs.FileInfoToDirEntry(&renamedFileInfo{name, fi}), nil
+		return &dirInfo{name, &stat}, nil
 	}
 }
 
-// renamedFileInfo is needed to retain the stat info for a mount, knowing the
-// directory is masked. For example, we don't want to leak the underlying host
-// directory name.
-type renamedFileInfo struct {
+// dirInfo is a DirEntry based on a FileInfo.
+type dirInfo struct {
+	// name is needed to retain the stat info for a mount, knowing the
+	// directory is masked. For example, we don't want to leak the underlying
+	// host directory name.
 	name string
-	f    fs.FileInfo
+	stat *platform.Stat_t
 }
 
-func (i *renamedFileInfo) Name() string       { return i.name }
-func (i *renamedFileInfo) Size() int64        { return i.f.Size() }
-func (i *renamedFileInfo) Mode() fs.FileMode  { return i.f.Mode() }
-func (i *renamedFileInfo) ModTime() time.Time { return i.f.ModTime() }
-func (i *renamedFileInfo) IsDir() bool        { return i.f.IsDir() }
-func (i *renamedFileInfo) Sys() interface{}   { return i.f.Sys() }
+func (i *dirInfo) Name() string               { return i.name }
+func (i *dirInfo) Type() fs.FileMode          { return i.stat.Mode.Type() }
+func (i *dirInfo) Info() (fs.FileInfo, error) { return i, nil }
+func (i *dirInfo) Size() int64                { return i.stat.Size }
+func (i *dirInfo) Mode() fs.FileMode          { return i.stat.Mode }
+func (i *dirInfo) ModTime() time.Time {
+	return time.Unix(i.stat.Mtim/1e9, i.stat.Mtim%1e9)
+}
+func (i *dirInfo) IsDir() bool      { return i.stat.Mode.IsDir() }
+func (i *dirInfo) Sys() interface{} { return nil }
 
 func (d *openRootDir) ReadDir(count int) ([]fs.DirEntry, error) {
 	if d.dirents == nil {
@@ -238,6 +246,12 @@ func (d *openRootDir) ReadDir(count int) ([]fs.DirEntry, error) {
 	return list, nil
 }
 
+// Stat implements FS.Stat
+func (c *CompositeFS) Stat(path string, stat *platform.Stat_t) error {
+	matchIndex, relativePath := c.chooseFS(path)
+	return c.fs[matchIndex].Stat(relativePath, stat)
+}
+
 // Mkdir implements FS.Mkdir
 func (c *CompositeFS) Mkdir(path string, perm fs.FileMode) error {
 	matchIndex, relativePath := c.chooseFS(path)
diff --git a/internal/sysfs/syscall.go b/internal/sysfs/syscall.go
index a37da970..38ebab47 100644
--- a/internal/sysfs/syscall.go
+++ b/internal/sysfs/syscall.go
@@ -6,18 +6,6 @@ import (
 	"io/fs"
 )
 
-func adjustErrno(err error) error {
-	return err
-}
-
-func adjustRmdirError(err error) error {
-	return err
-}
-
-func adjustTruncateError(err error) error {
-	return err
-}
-
 func maybeWrapFile(f file, _ FS, _ string, _ int, _ fs.FileMode) file {
 	return f
 }
diff --git a/internal/sysfs/syscall_windows.go b/internal/sysfs/syscall_windows.go
index 35685a91..bc87c8c5 100644
--- a/internal/sysfs/syscall_windows.go
+++ b/internal/sysfs/syscall_windows.go
@@ -3,70 +3,10 @@ package sysfs
 import (
 	"io/fs"
 	"syscall"
-)
-
-// See https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
-const (
-	// ERROR_ACCESS_DENIED is a Windows error returned by syscall.Unlink
-	// instead of syscall.EPERM
-	ERROR_ACCESS_DENIED = syscall.Errno(5)
-
-	// ERROR_INVALID_HANDLE is a Windows error returned by syscall.Write
-	// instead of syscall.EBADF
-	ERROR_INVALID_HANDLE = syscall.Errno(6)
-
-	// ERROR_NEGATIVE_SEEK is a Windows error returned by os.Truncate
-	// instead of syscall.EINVAL
-	ERROR_NEGATIVE_SEEK = syscall.Errno(131)
 
-	// ERROR_DIR_NOT_EMPTY is a Windows error returned by syscall.Rmdir
-	// instead of syscall.ENOTEMPTY
-	ERROR_DIR_NOT_EMPTY = syscall.Errno(145)
-
-	// ERROR_ALREADY_EXISTS is a Windows error returned by os.Mkdir
-	// instead of syscall.EEXIST
-	ERROR_ALREADY_EXISTS = syscall.Errno(183)
-
-	// ERROR_DIRECTORY is a Windows error returned by syscall.Rmdir
-	// instead of syscall.ENOTDIR
-	ERROR_DIRECTORY = syscall.Errno(267)
-
-	// ERROR_PRIVILEGE_NOT_HELD is a Windows error returned by os.Symlink
-	// instead of syscall.EPERM.
-	//
-	// Note: This can happen when trying to create symlinks w/o admin perms.
-	ERROR_PRIVILEGE_NOT_HELD = syscall.Errno(1314)
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func adjustErrno(err syscall.Errno) error {
-	switch err {
-	case ERROR_ALREADY_EXISTS:
-		return syscall.EEXIST
-	case ERROR_DIR_NOT_EMPTY:
-		return syscall.ENOTEMPTY
-	case ERROR_INVALID_HANDLE:
-		return syscall.EBADF
-	case ERROR_ACCESS_DENIED, ERROR_PRIVILEGE_NOT_HELD:
-		return syscall.EPERM
-	}
-	return err
-}
-
-func adjustRmdirError(err error) error {
-	switch err {
-	case ERROR_DIRECTORY:
-		return syscall.ENOTDIR
-	}
-	return err
-}
-
-func adjustTruncateError(err error) error {
-	if err == ERROR_NEGATIVE_SEEK {
-		return syscall.EINVAL
-	}
-	return err
-}
-
 // maybeWrapFile deals with errno portability issues in Windows. This code is
 // likely to change as we complete syscall support needed for WASI and GOOS=js.
 //
@@ -116,9 +56,10 @@ func (w *windowsWrappedFile) Write(p []byte) (n int, err error) {
 
 	// os.File.Wrap wraps the syscall error in a path error
 	if pe, ok := err.(*fs.PathError); ok {
-		if pe.Err = UnwrapOSError(pe.Err); pe.Err == syscall.EPERM {
+		if pe.Err = platform.UnwrapOSError(pe.Err); pe.Err == syscall.EPERM {
 			// go1.20 returns access denied, not invalid handle, writing to a directory.
-			if stat, statErr := StatPath(w.fs, w.path); statErr == nil && stat.IsDir() {
+			var stat platform.Stat_t
+			if statErr := w.fs.Stat(w.path, &stat); statErr == nil && stat.Mode.IsDir() {
 				pe.Err = syscall.EBADF
 			}
 		}
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index d0d3ed99..e099b615 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -8,7 +8,6 @@ package sysfs
 import (
 	"io"
 	"io/fs"
-	"os"
 	"syscall"
 
 	"github.com/tetratelabs/wazero/internal/platform"
@@ -62,6 +61,20 @@ type FS interface {
 	// ^^ TODO: Consider syscall.Open, though this implies defining and
 	// coercing flags and perms similar to what is done in os.OpenFile.
 
+	// Stat is similar to syscall.Stat, except the path is relative to this
+	// file system.
+	//
+	// # Errors
+	//
+	// The following errors are expected:
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//
+	// # Notes
+	//
+	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
+	//     same value.
+	Stat(path string, stat *platform.Stat_t) error
+
 	// Mkdir is similar to os.Mkdir, except the path is relative to this file
 	// system, and syscall.Errno are returned instead of a os.PathError.
 	//
@@ -218,25 +231,6 @@ type FS interface {
 	Utimes(path string, atimeNsec, mtimeNsec int64) error
 }
 
-// StatPath is a convenience that calls FS.OpenFile, then StatFile, until there
-// is a stat method.
-func StatPath(fs FS, path string) (s fs.FileInfo, err error) {
-	f, err := fs.OpenFile(path, os.O_RDONLY, 0)
-	if err != nil {
-		return nil, err
-	}
-	defer f.Close()
-	return StatFile(f)
-}
-
-// StatFile is like the same method on fs.File, except the returned error is
-// nil or syscall.Errno.
-func StatFile(f fs.File) (s fs.FileInfo, err error) {
-	s, err = f.Stat()
-	err = UnwrapOSError(err)
-	return
-}
-
 // readFile declares all read interfaces defined on os.File used by wazero.
 type readFile interface {
 	fs.ReadDirFile
@@ -393,46 +387,3 @@ func (r *writerAtOffset) Write(p []byte) (int, error) {
 	r.offset += int64(n)
 	return n, err
 }
-
-// UnwrapOSError returns a syscall.Errno or nil if the input is nil.
-func UnwrapOSError(err error) error {
-	if err == nil {
-		return nil
-	}
-	err = underlyingError(err)
-	if se, ok := err.(syscall.Errno); ok {
-		return adjustErrno(se)
-	}
-	// Below are all the fs.ErrXXX in fs.go.
-	//
-	// Note: Once we have our own file type, we should never see these.
-	switch err {
-	case nil:
-	case fs.ErrInvalid:
-		return syscall.EINVAL
-	case fs.ErrPermission:
-		return syscall.EPERM
-	case fs.ErrExist:
-		return syscall.EEXIST
-	case fs.ErrNotExist:
-		return syscall.ENOENT
-	case fs.ErrClosed:
-		return syscall.EBADF
-	}
-	return syscall.EIO
-}
-
-// underlyingError returns the underlying error if a well-known OS error type.
-//
-// This impl is basically the same as os.underlyingError in os/error.go
-func underlyingError(err error) error {
-	switch err := err.(type) {
-	case *os.PathError:
-		return err.Err
-	case *os.LinkError:
-		return err.Err
-	case *os.SyscallError:
-		return err.Err
-	}
-	return err
-}
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index e9c73b6d..70d1206a 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -3,6 +3,8 @@ package sysfs
 import (
 	"io/fs"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // UnimplementedFS is an FS that returns syscall.ENOSYS for all functions,
@@ -24,6 +26,11 @@ func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.Fil
 	return nil, syscall.ENOSYS
 }
 
+// Stat implements FS.Stat
+func (UnimplementedFS) Stat(path string, stat *platform.Stat_t) error {
+	return syscall.ENOSYS
+}
+
 // Mkdir implements FS.Mkdir
 func (UnimplementedFS) Mkdir(path string, perm fs.FileMode) error {
 	return syscall.ENOSYS
diff --git a/internal/wasi_snapshot_preview1/errno.go b/internal/wasi_snapshot_preview1/errno.go
index 42e0ac96..4ba41f43 100644
--- a/internal/wasi_snapshot_preview1/errno.go
+++ b/internal/wasi_snapshot_preview1/errno.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"syscall"
 
-	"github.com/tetratelabs/wazero/internal/sysfs"
+	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 // Errno is neither uint16 nor an alias for parity with wasm.ValueType.
@@ -266,7 +266,7 @@ var errnoToString = [...]string{
 // error codes. For example, wasi-filesystem and GOOS=js don't map to these
 // Errno.
 func ToErrno(err error) Errno {
-	errno := sysfs.UnwrapOSError(err)
+	errno := platform.UnwrapOSError(err)
 
 	switch errno {
 	case syscall.EACCES:


Test patch:
diff --git a/imports/wasi_snapshot_preview1/args_test.go b/imports/wasi_snapshot_preview1/args_test.go
index 0173cda2..c6478fb1 100644
--- a/imports/wasi_snapshot_preview1/args_test.go
+++ b/imports/wasi_snapshot_preview1/args_test.go
@@ -26,7 +26,7 @@ func Test_argsGet(t *testing.T) {
 	maskMemory(t, mod, len(expectedMemory)+int(argvBuf))
 
 	// Invoke argsGet and check the memory side effects.
-	requireErrno(t, ErrnoSuccess, mod, ArgsGetName, uint64(argv), uint64(argvBuf))
+	requireErrnoResult(t, ErrnoSuccess, mod, ArgsGetName, uint64(argv), uint64(argvBuf))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.args_get(argv=22,argv_buf=16)
 <== errno=ESUCCESS
@@ -95,7 +95,7 @@ func Test_argsGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, ArgsGetName, uint64(tc.argv), uint64(tc.argvBuf))
+			requireErrnoResult(t, ErrnoFault, mod, ArgsGetName, uint64(tc.argv), uint64(tc.argvBuf))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -118,7 +118,7 @@ func Test_argsSizesGet(t *testing.T) {
 	maskMemory(t, mod, int(resultArgc)+len(expectedMemory))
 
 	// Invoke argsSizesGet and check the memory side effects.
-	requireErrno(t, ErrnoSuccess, mod, ArgsSizesGetName, uint64(resultArgc), uint64(resultArgvLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, ArgsSizesGetName, uint64(resultArgc), uint64(resultArgvLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.args_sizes_get(result.argc=16,result.argv_len=21)
 <== errno=ESUCCESS
@@ -185,7 +185,7 @@ func Test_argsSizesGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, ArgsSizesGetName, uint64(tc.argc), uint64(tc.argvLen))
+			requireErrnoResult(t, ErrnoFault, mod, ArgsSizesGetName, uint64(tc.argc), uint64(tc.argvLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
diff --git a/imports/wasi_snapshot_preview1/clock_test.go b/imports/wasi_snapshot_preview1/clock_test.go
index 52b755ac..71dc438a 100644
--- a/imports/wasi_snapshot_preview1/clock_test.go
+++ b/imports/wasi_snapshot_preview1/clock_test.go
@@ -60,7 +60,7 @@ func Test_clockResGet(t *testing.T) {
 			resultResolution := 16 // arbitrary offset
 			maskMemory(t, mod, resultResolution+len(tc.expectedMemory))
 
-			requireErrno(t, ErrnoSuccess, mod, ClockResGetName, uint64(tc.clockID), uint64(resultResolution))
+			requireErrnoResult(t, ErrnoSuccess, mod, ClockResGetName, uint64(tc.clockID), uint64(resultResolution))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(uint32(resultResolution-1), uint32(len(tc.expectedMemory)))
@@ -115,7 +115,7 @@ func Test_clockResGet_Unsupported(t *testing.T) {
 			defer log.Reset()
 
 			resultResolution := 16 // arbitrary offset
-			requireErrno(t, tc.expectedErrno, mod, ClockResGetName, uint64(tc.clockID), uint64(resultResolution))
+			requireErrnoResult(t, tc.expectedErrno, mod, ClockResGetName, uint64(tc.clockID), uint64(resultResolution))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -167,7 +167,7 @@ func Test_clockTimeGet(t *testing.T) {
 			resultTimestamp := 16 // arbitrary offset
 			maskMemory(t, mod, resultTimestamp+len(tc.expectedMemory))
 
-			requireErrno(t, ErrnoSuccess, mod, ClockTimeGetName, uint64(tc.clockID), 0 /* TODO: precision */, uint64(resultTimestamp))
+			requireErrnoResult(t, ErrnoSuccess, mod, ClockTimeGetName, uint64(tc.clockID), 0 /* TODO: precision */, uint64(resultTimestamp))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(uint32(resultTimestamp-1), uint32(len(tc.expectedMemory)))
@@ -186,7 +186,7 @@ func Test_clockTimeGet_monotonic(t *testing.T) {
 
 	getMonotonicTime := func() uint64 {
 		const offset uint32 = 0
-		requireErrno(t, ErrnoSuccess, mod, ClockTimeGetName, uint64(ClockIDMonotonic),
+		requireErrnoResult(t, ErrnoSuccess, mod, ClockTimeGetName, uint64(ClockIDMonotonic),
 			0 /* TODO: precision */, uint64(offset))
 		timestamp, ok := mod.Memory().ReadUint64Le(offset)
 		require.True(t, ok)
@@ -249,7 +249,7 @@ func Test_clockTimeGet_Unsupported(t *testing.T) {
 			defer log.Reset()
 
 			resultTimestamp := 16 // arbitrary offset
-			requireErrno(t, tc.expectedErrno, mod, ClockTimeGetName, uint64(tc.clockID), uint64(0) /* TODO: precision */, uint64(resultTimestamp))
+			requireErrnoResult(t, tc.expectedErrno, mod, ClockTimeGetName, uint64(tc.clockID), uint64(0) /* TODO: precision */, uint64(resultTimestamp))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -290,7 +290,7 @@ func Test_clockTimeGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, ClockTimeGetName, uint64(0) /* TODO: id */, uint64(0) /* TODO: precision */, uint64(tc.resultTimestamp))
+			requireErrnoResult(t, ErrnoFault, mod, ClockTimeGetName, uint64(0) /* TODO: id */, uint64(0) /* TODO: precision */, uint64(tc.resultTimestamp))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
diff --git a/imports/wasi_snapshot_preview1/environ_test.go b/imports/wasi_snapshot_preview1/environ_test.go
index a5c11a43..19cd54e7 100644
--- a/imports/wasi_snapshot_preview1/environ_test.go
+++ b/imports/wasi_snapshot_preview1/environ_test.go
@@ -28,7 +28,7 @@ func Test_environGet(t *testing.T) {
 	maskMemory(t, mod, len(expectedMemory)+int(resultEnvironBuf))
 
 	// Invoke environGet and check the memory side effects.
-	requireErrno(t, ErrnoSuccess, mod, EnvironGetName, uint64(resultEnviron), uint64(resultEnvironBuf))
+	requireErrnoResult(t, ErrnoSuccess, mod, EnvironGetName, uint64(resultEnviron), uint64(resultEnvironBuf))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.environ_get(environ=26,environ_buf=16)
 <== errno=ESUCCESS
@@ -98,7 +98,7 @@ func Test_environGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, EnvironGetName, uint64(tc.environ), uint64(tc.environBuf))
+			requireErrnoResult(t, ErrnoFault, mod, EnvironGetName, uint64(tc.environ), uint64(tc.environBuf))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -122,7 +122,7 @@ func Test_environSizesGet(t *testing.T) {
 	maskMemory(t, mod, len(expectedMemory)+int(resultEnvironc))
 
 	// Invoke environSizesGet and check the memory side effects.
-	requireErrno(t, ErrnoSuccess, mod, EnvironSizesGetName, uint64(resultEnvironc), uint64(resultEnvironvLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, EnvironSizesGetName, uint64(resultEnvironc), uint64(resultEnvironvLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.environ_sizes_get(result.environc=16,result.environv_len=21)
 <== errno=ESUCCESS
@@ -190,7 +190,7 @@ func Test_environSizesGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, EnvironSizesGetName, uint64(tc.environc), uint64(tc.environLen))
+			requireErrnoResult(t, ErrnoFault, mod, EnvironSizesGetName, uint64(tc.environc), uint64(tc.environLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 0cf69596..f0a1e98a 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -27,14 +27,14 @@ import (
 func Test_fdAdvise(t *testing.T) {
 	mod, r, _ := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fstest.FS))
 	defer r.Close(testCtx)
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNormal))
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceSequential))
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceRandom))
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceWillNeed))
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceDontNeed))
-	requireErrno(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNoReuse))
-	requireErrno(t, ErrnoInval, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNoReuse+1))
-	requireErrno(t, ErrnoBadf, mod, FdAdviseName, uint64(1111111), 0, 0, uint64(FdAdviceNoReuse+1))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNormal))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceSequential))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceRandom))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceWillNeed))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceDontNeed))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNoReuse))
+	requireErrnoResult(t, ErrnoInval, mod, FdAdviseName, uint64(3), 0, 0, uint64(FdAdviceNoReuse+1))
+	requireErrnoResult(t, ErrnoBadf, mod, FdAdviseName, uint64(1111111), 0, 0, uint64(FdAdviceNoReuse+1))
 }
 
 // Test_fdAllocate only tests it is stubbed for GrainLang per #271
@@ -60,17 +60,17 @@ func Test_fdAllocate(t *testing.T) {
 	require.True(t, ok)
 
 	requireSizeEqual := func(exp int64) {
-		st, err := f.Stat()
-		require.NoError(t, err)
-		require.Equal(t, exp, st.Size())
+		var st platform.Stat_t
+		require.NoError(t, f.Stat(&st))
+		require.Equal(t, exp, st.Size)
 	}
 
 	t.Run("errors", func(t *testing.T) {
-		requireErrno(t, ErrnoBadf, mod, FdAllocateName, uint64(12345), 0, 0)
+		requireErrnoResult(t, ErrnoBadf, mod, FdAllocateName, uint64(12345), 0, 0)
 		minusOne := int64(-1)
-		requireErrno(t, ErrnoInval, mod, FdAllocateName, uint64(fd), uint64(minusOne), uint64(minusOne))
-		requireErrno(t, ErrnoInval, mod, FdAllocateName, uint64(fd), 0, uint64(minusOne))
-		requireErrno(t, ErrnoInval, mod, FdAllocateName, uint64(fd), uint64(minusOne), 0)
+		requireErrnoResult(t, ErrnoInval, mod, FdAllocateName, uint64(fd), uint64(minusOne), uint64(minusOne))
+		requireErrnoResult(t, ErrnoInval, mod, FdAllocateName, uint64(fd), 0, uint64(minusOne))
+		requireErrnoResult(t, ErrnoInval, mod, FdAllocateName, uint64(fd), uint64(minusOne), 0)
 	})
 
 	t.Run("do not change size", func(t *testing.T) {
@@ -81,7 +81,7 @@ func Test_fdAllocate(t *testing.T) {
 			{offset: 10, length: 0},
 		} {
 			// This shouldn't change the size.
-			requireErrno(t, ErrnoSuccess, mod, FdAllocateName,
+			requireErrnoResult(t, ErrnoSuccess, mod, FdAllocateName,
 				uint64(fd), tc.offset, tc.length)
 			requireSizeEqual(10)
 		}
@@ -89,7 +89,7 @@ func Test_fdAllocate(t *testing.T) {
 
 	t.Run("increase", func(t *testing.T) {
 		// 10 + 10 > the current size -> increase the size.
-		requireErrno(t, ErrnoSuccess, mod, FdAllocateName,
+		requireErrnoResult(t, ErrnoSuccess, mod, FdAllocateName,
 			uint64(fd), 10, 10)
 		requireSizeEqual(20)
 
@@ -138,7 +138,7 @@ func Test_fdClose(t *testing.T) {
 	require.NoError(t, err)
 
 	// Close
-	requireErrno(t, ErrnoSuccess, mod, FdCloseName, uint64(fdToClose))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdCloseName, uint64(fdToClose))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_close(fd=4)
 <== errno=ESUCCESS
@@ -154,7 +154,7 @@ func Test_fdClose(t *testing.T) {
 
 	log.Reset()
 	t.Run("ErrnoBadF for an invalid FD", func(t *testing.T) {
-		requireErrno(t, ErrnoBadf, mod, FdCloseName, uint64(42)) // 42 is an arbitrary invalid FD
+		requireErrnoResult(t, ErrnoBadf, mod, FdCloseName, uint64(42)) // 42 is an arbitrary invalid FD
 		require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_close(fd=42)
 <== errno=EBADF
@@ -162,7 +162,7 @@ func Test_fdClose(t *testing.T) {
 	})
 	log.Reset()
 	t.Run("ErrnoNotsup for a preopen", func(t *testing.T) {
-		requireErrno(t, ErrnoNotsup, mod, FdCloseName, uint64(sys.FdPreopen))
+		requireErrnoResult(t, ErrnoNotsup, mod, FdCloseName, uint64(sys.FdPreopen))
 		require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_close(fd=3)
 <== errno=ENOTSUP
@@ -208,7 +208,7 @@ func Test_fdDatasync(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdDatasyncName, uint64(tc.fd))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdDatasyncName, uint64(tc.fd))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -350,7 +350,7 @@ func Test_fdFdstatGet(t *testing.T) {
 
 			maskMemory(t, mod, len(tc.expectedMemory))
 
-			requireErrno(t, tc.expectedErrno, mod, FdFdstatGetName, uint64(tc.fd), uint64(tc.resultFdstat))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdFdstatGetName, uint64(tc.fd), uint64(tc.resultFdstat))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -398,7 +398,7 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 		ok := mod.Memory().Write(0, initialMemory)
 		require.True(t, ok)
 
-		requireErrno(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
+		requireErrnoResult(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
 		require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_write(fd=4,iovs=1,iovs_len=2)
 <== (nwritten=6,errno=ESUCCESS)
@@ -417,7 +417,7 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 	requireFileContent("0123456789" + "wazero")
 
 	// Let's remove O_APPEND.
-	requireErrno(t, ErrnoSuccess, mod, FdFdstatSetFlagsName, uint64(fd), uint64(0))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdFdstatSetFlagsName, uint64(fd), uint64(0))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_fdstat_set_flags(fd=4,flags=0)
 <== errno=ESUCCESS
@@ -429,7 +429,7 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 	requireFileContent("wazero6789" + "wazero")
 
 	// Restore the O_APPEND flag.
-	requireErrno(t, ErrnoSuccess, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_APPEND))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_APPEND))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_fdstat_set_flags(fd=4,flags=1)
 <== errno=ESUCCESS
@@ -441,12 +441,12 @@ func Test_fdFdstatSetFlags(t *testing.T) {
 	requireFileContent("wazero6789" + "wazero" + "wazero")
 
 	t.Run("errors", func(t *testing.T) {
-		requireErrno(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_DSYNC))
-		requireErrno(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_NONBLOCK))
-		requireErrno(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_RSYNC))
-		requireErrno(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_SYNC))
-		requireErrno(t, ErrnoBadf, mod, FdFdstatSetFlagsName, uint64(12345), uint64(FD_APPEND))
-		requireErrno(t, ErrnoIsdir, mod, FdFdstatSetFlagsName, uint64(3) /* preopen */, uint64(FD_APPEND))
+		requireErrnoResult(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_DSYNC))
+		requireErrnoResult(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_NONBLOCK))
+		requireErrnoResult(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_RSYNC))
+		requireErrnoResult(t, ErrnoInval, mod, FdFdstatSetFlagsName, uint64(fd), uint64(FD_SYNC))
+		requireErrnoResult(t, ErrnoBadf, mod, FdFdstatSetFlagsName, uint64(12345), uint64(FD_APPEND))
+		requireErrnoResult(t, ErrnoIsdir, mod, FdFdstatSetFlagsName, uint64(3) /* preopen */, uint64(FD_APPEND))
 	})
 }
 
@@ -622,7 +622,7 @@ func Test_fdFilestatGet(t *testing.T) {
 
 			maskMemory(t, mod, len(tc.expectedMemory))
 
-			requireErrno(t, tc.expectedErrno, mod, FdFilestatGetName, uint64(tc.fd), uint64(tc.resultFilestat))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdFilestatGetName, uint64(tc.fd), uint64(tc.resultFilestat))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -697,7 +697,7 @@ func Test_fdFilestatSetSize(t *testing.T) {
 			if filepath == "badf" {
 				fd++
 			}
-			requireErrno(t, tc.expectedErrno, mod, FdFilestatSetSizeName, uint64(fd), uint64(tc.size))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdFilestatSetSizeName, uint64(fd), uint64(tc.size))
 
 			actual, err := os.ReadFile(path.Join(tmpDir, filepath))
 			require.NoError(t, err)
@@ -843,11 +843,12 @@ func Test_fdFilestatSetTimes(t *testing.T) {
 
 			f, ok := fsc.LookupFile(fd)
 			require.True(t, ok)
-			stat, err := f.Stat()
-			require.NoError(t, err)
-			prevAtime, prevMtime, _ := platform.StatTimes(stat)
 
-			requireErrno(t, tc.expectedErrno, mod, FdFilestatSetTimesName,
+			var st platform.Stat_t
+			require.NoError(t, f.Stat(&st))
+			prevAtime, prevMtime := st.Atim, st.Mtim
+
+			requireErrnoResult(t, tc.expectedErrno, mod, FdFilestatSetTimesName,
 				uint64(paramFd), uint64(tc.atime), uint64(tc.mtime),
 				uint64(tc.flags),
 			)
@@ -855,22 +856,22 @@ func Test_fdFilestatSetTimes(t *testing.T) {
 			if tc.expectedErrno == ErrnoSuccess {
 				f, ok := fsc.LookupFile(fd)
 				require.True(t, ok)
-				stat, err := f.Stat()
-				require.NoError(t, err)
-				atime, mtime, _ := platform.StatTimes(stat)
+
+				var st platform.Stat_t
+				require.NoError(t, f.Stat(&st))
 				if tc.flags&FileStatAdjustFlagsAtim != 0 {
-					require.Equal(t, tc.atime, atime)
+					require.Equal(t, tc.atime, st.Atim)
 				} else if tc.flags&FileStatAdjustFlagsAtimNow != 0 {
-					require.True(t, (sys.WalltimeNanos()-atime) < time.Second.Nanoseconds())
+					require.True(t, (sys.WalltimeNanos()-st.Atim) < time.Second.Nanoseconds())
 				} else {
-					require.Equal(t, prevAtime, atime)
+					require.Equal(t, prevAtime, st.Atim)
 				}
 				if tc.flags&FileStatAdjustFlagsMtim != 0 {
-					require.Equal(t, tc.mtime, mtime)
+					require.Equal(t, tc.mtime, st.Mtim)
 				} else if tc.flags&FileStatAdjustFlagsMtimNow != 0 {
-					require.True(t, (sys.WalltimeNanos()-mtime) < time.Second.Nanoseconds())
+					require.True(t, (sys.WalltimeNanos()-st.Mtim) < time.Second.Nanoseconds())
 				} else {
-					require.Equal(t, prevMtime, mtime)
+					require.Equal(t, prevMtime, st.Mtim)
 				}
 			}
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
@@ -946,7 +947,7 @@ func Test_fdPread(t *testing.T) {
 			ok := mod.Memory().Write(0, initialMemory)
 			require.True(t, ok)
 
-			requireErrno(t, ErrnoSuccess, mod, FdPreadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(tc.offset), uint64(resultNread))
+			requireErrnoResult(t, ErrnoSuccess, mod, FdPreadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(tc.offset), uint64(resultNread))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -988,7 +989,7 @@ func Test_fdPread_offset(t *testing.T) {
 	ok := mod.Memory().Write(0, initialMemory)
 	require.True(t, ok)
 
-	requireErrno(t, ErrnoSuccess, mod, FdPreadName, uint64(fd), uint64(iovs), uint64(iovsCount), 2, uint64(resultNread))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdPreadName, uint64(fd), uint64(iovs), uint64(iovsCount), 2, uint64(resultNread))
 	actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
 	require.True(t, ok)
 	require.Equal(t, expectedMemory, actual)
@@ -1005,7 +1006,7 @@ func Test_fdPread_offset(t *testing.T) {
 		'?',
 	)
 
-	requireErrno(t, ErrnoSuccess, mod, FdReadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNread))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdReadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNread))
 	actual, ok = mod.Memory().Read(0, uint32(len(expectedMemory)))
 	require.True(t, ok)
 	require.Equal(t, expectedMemory, actual)
@@ -1147,7 +1148,7 @@ func Test_fdPread_Errors(t *testing.T) {
 			memoryWriteOK := mod.Memory().Write(offset, tc.memory)
 			require.True(t, memoryWriteOK)
 
-			requireErrno(t, tc.expectedErrno, mod, FdPreadName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount), uint64(tc.offset), uint64(tc.resultNread+offset))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdPreadName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount), uint64(tc.offset), uint64(tc.resultNread+offset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -1171,7 +1172,7 @@ func Test_fdPrestatGet(t *testing.T) {
 
 	maskMemory(t, mod, len(expectedMemory))
 
-	requireErrno(t, ErrnoSuccess, mod, FdPrestatGetName, uint64(dirFD), uint64(resultPrestat))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdPrestatGetName, uint64(dirFD), uint64(resultPrestat))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_prestat_get(fd=3)
 <== (prestat={pr_name_len=1},errno=ESUCCESS)
@@ -1232,7 +1233,7 @@ func Test_fdPrestatGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdPrestatGetName, uint64(tc.fd), uint64(tc.resultPrestat))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdPrestatGetName, uint64(tc.fd), uint64(tc.resultPrestat))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -1252,7 +1253,7 @@ func Test_fdPrestatDirName(t *testing.T) {
 
 	maskMemory(t, mod, len(expectedMemory))
 
-	requireErrno(t, ErrnoSuccess, mod, FdPrestatDirNameName, uint64(dirFD), uint64(path), uint64(pathLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdPrestatDirNameName, uint64(dirFD), uint64(path), uint64(pathLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_prestat_dir_name(fd=3)
 <== (path=,errno=ESUCCESS)
@@ -1344,7 +1345,7 @@ func Test_fdPrestatDirName_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdPrestatDirNameName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdPrestatDirNameName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -1421,7 +1422,7 @@ func Test_fdPwrite(t *testing.T) {
 			ok := mod.Memory().Write(0, initialMemory)
 			require.True(t, ok)
 
-			requireErrno(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(tc.offset), uint64(resultNwritten))
+			requireErrnoResult(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(tc.offset), uint64(resultNwritten))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -1469,7 +1470,7 @@ func Test_fdPwrite_offset(t *testing.T) {
 	require.True(t, ok)
 
 	// Write the last half first, to offset 3
-	requireErrno(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), 3, uint64(resultNwritten))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), 3, uint64(resultNwritten))
 	actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
 	require.True(t, ok)
 	require.Equal(t, expectedMemory, actual)
@@ -1493,7 +1494,7 @@ func Test_fdPwrite_offset(t *testing.T) {
 	ok = mod.Memory().Write(0, writeMemory)
 	require.True(t, ok)
 
-	requireErrno(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
 	actual, ok = mod.Memory().Read(0, uint32(len(expectedMemory)))
 	require.True(t, ok)
 	require.Equal(t, expectedMemory, actual)
@@ -1640,7 +1641,7 @@ func Test_fdPwrite_Errors(t *testing.T) {
 			memoryWriteOK := mod.Memory().Write(offset, tc.memory)
 			require.True(t, memoryWriteOK)
 
-			requireErrno(t, tc.expectedErrno, mod, FdPwriteName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount), uint64(tc.offset), uint64(tc.resultNwritten+offset))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdPwriteName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount), uint64(tc.offset), uint64(tc.resultNwritten+offset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -1676,7 +1677,7 @@ func Test_fdRead(t *testing.T) {
 	ok := mod.Memory().Write(0, initialMemory)
 	require.True(t, ok)
 
-	requireErrno(t, ErrnoSuccess, mod, FdReadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNread))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdReadName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNread))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_read(fd=4,iovs=1,iovs_len=2)
 <== (nread=6,errno=ESUCCESS)
@@ -1793,7 +1794,7 @@ func Test_fdRead_Errors(t *testing.T) {
 			memoryWriteOK := mod.Memory().Write(offset, tc.memory)
 			require.True(t, memoryWriteOK)
 
-			requireErrno(t, tc.expectedErrno, mod, FdReadName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount+offset), uint64(tc.resultNread+offset))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdReadName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount+offset), uint64(tc.resultNread+offset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -2087,7 +2088,7 @@ func Test_fdReaddir(t *testing.T) {
 
 			resultBufused := uint32(0) // where to write the amount used out of bufLen
 			buf := uint32(8)           // where to start the dirents
-			requireErrno(t, ErrnoSuccess, mod, FdReaddirName,
+			requireErrnoResult(t, ErrnoSuccess, mod, FdReaddirName,
 				uint64(fd), uint64(buf), uint64(tc.bufLen), uint64(tc.cookie), uint64(resultBufused))
 
 			// read back the bufused and compare memory against it
@@ -2122,7 +2123,7 @@ func Test_fdReaddir_Rewind(t *testing.T) {
 	mem := mod.Memory()
 	const resultBufUsed, buf, bufSize = 0, 8, 100
 	read := func(cookie, bufSize uint64) (bufUsed uint32) {
-		requireErrno(t, ErrnoSuccess, mod, FdReaddirName,
+		requireErrnoResult(t, ErrnoSuccess, mod, FdReaddirName,
 			uint64(fd), buf, bufSize, cookie, uint64(resultBufUsed))
 
 		bufUsed, ok := mem.ReadUint32Le(resultBufUsed)
@@ -2284,7 +2285,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 				file.ReadDir = nil
 			}
 
-			requireErrno(t, tc.expectedErrno, mod, FdReaddirName,
+			requireErrnoResult(t, tc.expectedErrno, mod, FdReaddirName,
 				uint64(tc.fd), uint64(tc.buf), uint64(tc.bufLen), uint64(tc.cookie), uint64(tc.resultBufused))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
@@ -2380,7 +2381,7 @@ func Test_fdRenumber(t *testing.T) {
 			require.NoError(t, err)
 			require.Equal(t, uint32(dirFd), dirFdAssigned)
 
-			requireErrno(t, tc.expectedErrno, mod, FdRenumberName, uint64(tc.from), uint64(tc.to))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdRenumberName, uint64(tc.from), uint64(tc.to))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -2465,7 +2466,7 @@ func Test_fdSeek(t *testing.T) {
 			require.NoError(t, err)
 			require.Equal(t, int64(1), offset)
 
-			requireErrno(t, ErrnoSuccess, mod, FdSeekName, uint64(fd), uint64(tc.offset), uint64(tc.whence), uint64(resultNewoffset))
+			requireErrnoResult(t, ErrnoSuccess, mod, FdSeekName, uint64(fd), uint64(tc.offset), uint64(tc.whence), uint64(resultNewoffset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -2542,7 +2543,7 @@ func Test_fdSeek_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdSeekName, uint64(tc.fd), tc.offset, uint64(tc.whence), uint64(tc.resultNewoffset))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdSeekName, uint64(tc.fd), tc.offset, uint64(tc.whence), uint64(tc.resultNewoffset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -2586,7 +2587,7 @@ func Test_fdSync(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdSyncName, uint64(tc.fd))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdSyncName, uint64(tc.fd))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -2623,7 +2624,7 @@ func Test_fdTell(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, int64(1), offset)
 
-	requireErrno(t, ErrnoSuccess, mod, FdTellName, uint64(fd), uint64(resultNewoffset))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdTellName, uint64(fd), uint64(resultNewoffset))
 	require.Equal(t, expectedLog, "\n"+log.String())
 
 	actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
@@ -2674,7 +2675,7 @@ func Test_fdTell_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, tc.expectedErrno, mod, FdTellName, uint64(tc.fd), uint64(tc.resultNewoffset))
+			requireErrnoResult(t, tc.expectedErrno, mod, FdTellName, uint64(tc.fd), uint64(tc.resultNewoffset))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -2711,7 +2712,7 @@ func Test_fdWrite(t *testing.T) {
 	ok := mod.Memory().Write(0, initialMemory)
 	require.True(t, ok)
 
-	requireErrno(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.fd_write(fd=4,iovs=1,iovs_len=2)
 <== (nwritten=6,errno=ESUCCESS)
@@ -2762,7 +2763,7 @@ func Test_fdWrite_discard(t *testing.T) {
 	require.True(t, ok)
 
 	fd := sys.FdStdout
-	requireErrno(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
+	requireErrnoResult(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
 	// Should not amplify logging
 	require.Zero(t, len(log.Bytes()))
 
@@ -2860,7 +2861,7 @@ func Test_fdWrite_Errors(t *testing.T) {
 				'h', 'i', // iovs[0].length bytes
 			))
 
-			requireErrno(t, tc.expectedErrno, mod, FdWriteName, uint64(tc.fd), uint64(tc.iovs), uint64(iovsCount),
+			requireErrnoResult(t, tc.expectedErrno, mod, FdWriteName, uint64(tc.fd), uint64(tc.iovs), uint64(iovsCount),
 				uint64(tc.resultNwritten))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
@@ -2883,7 +2884,7 @@ func Test_pathCreateDirectory(t *testing.T) {
 	name := 1
 	nameLen := len(pathName)
 
-	requireErrno(t, ErrnoSuccess, mod, PathCreateDirectoryName, uint64(preopenedFD), uint64(name), uint64(nameLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, PathCreateDirectoryName, uint64(preopenedFD), uint64(name), uint64(nameLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.path_create_directory(fd=3,path=wazero)
 <== errno=ESUCCESS
@@ -2993,7 +2994,7 @@ func Test_pathCreateDirectory_Errors(t *testing.T) {
 
 			mod.Memory().Write(tc.path, []byte(tc.pathName))
 
-			requireErrno(t, tc.expectedErrno, mod, PathCreateDirectoryName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
+			requireErrnoResult(t, tc.expectedErrno, mod, PathCreateDirectoryName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -3154,7 +3155,7 @@ func Test_pathFilestatGet(t *testing.T) {
 			mod.Memory().Write(0, tc.memory)
 
 			flags := uint32(0)
-			requireErrno(t, tc.expectedErrno, mod, PathFilestatGetName, uint64(tc.fd), uint64(flags), uint64(1), uint64(tc.pathLen), uint64(tc.resultFilestat))
+			requireErrnoResult(t, tc.expectedErrno, mod, PathFilestatGetName, uint64(tc.fd), uint64(flags), uint64(1), uint64(tc.pathLen), uint64(tc.resultFilestat))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
 			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
@@ -3209,7 +3210,7 @@ func Test_pathLink(t *testing.T) {
 	destinationRealPath := path.Join(tmpDir, newDirName, destinationName)
 
 	t.Run("success", func(t *testing.T) {
-		requireErrno(t, ErrnoSuccess, mod, PathLinkName,
+		requireErrnoResult(t, ErrnoSuccess, mod, PathLinkName,
 			uint64(oldFd), 0, fileNamePtr, uint64(len(filename)),
 			uint64(newFd), destinationNamePtr, uint64(len(destinationName)))
 		require.Contains(t, log.String(), ErrnoName(ErrnoSuccess))
@@ -3219,13 +3220,12 @@ func Test_pathLink(t *testing.T) {
 		defer func() {
 			require.NoError(t, f.Close())
 		}()
-		st, err := f.Stat()
-		require.NoError(t, err)
-		require.False(t, st.Mode()&os.ModeSymlink == os.ModeSymlink)
 
-		_, _, _, nlink, _, _, err := platform.Stat(f, st)
+		var st platform.Stat_t
+		require.NoError(t, platform.StatFile(f, &st))
 		require.NoError(t, err)
-		require.Equal(t, uint64(2), nlink)
+		require.False(t, st.Mode&os.ModeSymlink == os.ModeSymlink)
+		require.Equal(t, uint64(2), st.Nlink)
 	})
 
 	t.Run("errors", func(t *testing.T) {
@@ -3251,7 +3251,7 @@ func Test_pathLink(t *testing.T) {
 		} {
 			name := ErrnoName(tc.errno)
 			t.Run(name, func(t *testing.T) {
-				requireErrno(t, tc.errno, mod, PathLinkName,
+				requireErrnoResult(t, tc.errno, mod, PathLinkName,
 					tc.oldDirFd, 0, tc.oldPtr, tc.oldLen,
 					tc.newDirFd, tc.newPtr, tc.newLen)
 				require.Contains(t, log.String(), name)
@@ -3500,7 +3500,7 @@ func Test_pathOpen(t *testing.T) {
 			// rights aren't used
 			fsRightsBase, fsRightsInheriting := uint64(0), uint64(0)
 
-			requireErrno(t, tc.expectedErrno, mod, PathOpenName, uint64(dirfd), uint64(dirflags), uint64(path),
+			requireErrnoResult(t, tc.expectedErrno, mod, PathOpenName, uint64(dirfd), uint64(dirflags), uint64(path),
 				uint64(pathLen), uint64(tc.oflags), fsRightsBase, fsRightsInheriting, uint64(tc.fdflags), uint64(resultOpenedFd))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
@@ -3678,7 +3678,7 @@ func Test_pathOpen_Errors(t *testing.T) {
 
 			mod.Memory().Write(tc.path, []byte(tc.pathName))
 
-			requireErrno(t, tc.expectedErrno, mod, PathOpenName, uint64(tc.fd), uint64(0), uint64(tc.path),
+			requireErrnoResult(t, tc.expectedErrno, mod, PathOpenName, uint64(tc.fd), uint64(0), uint64(tc.path),
 				uint64(tc.pathLen), uint64(tc.oflags), 0, 0, 0, uint64(tc.resultOpenedFd))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
@@ -3743,7 +3743,7 @@ func Test_pathReadlink(t *testing.T) {
 		} {
 			t.Run(tc.name, func(t *testing.T) {
 				const bufPtr, bufSize, resultBufUsedPtr = 0x100, 0xff, 0x200
-				requireErrno(t, ErrnoSuccess, mod, PathReadlinkName,
+				requireErrnoResult(t, ErrnoSuccess, mod, PathReadlinkName,
 					uint64(topFd),
 					tc.pathPtr, tc.pathLen,
 					bufPtr, bufSize, resultBufUsedPtr)
@@ -3779,7 +3779,7 @@ func Test_pathReadlink(t *testing.T) {
 		} {
 			name := ErrnoName(tc.errno)
 			t.Run(name, func(t *testing.T) {
-				requireErrno(t, tc.errno, mod, PathReadlinkName,
+				requireErrnoResult(t, tc.errno, mod, PathReadlinkName,
 					tc.dirFd, tc.pathPtr, tc.pathLen, tc.bufPtr,
 					tc.bufLen, tc.resultBufUsedPtr)
 				require.Contains(t, log.String(), name)
@@ -3808,7 +3808,7 @@ func Test_pathRemoveDirectory(t *testing.T) {
 	name := 1
 	nameLen := len(pathName)
 
-	requireErrno(t, ErrnoSuccess, mod, PathRemoveDirectoryName, uint64(dirFD), uint64(name), uint64(nameLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, PathRemoveDirectoryName, uint64(dirFD), uint64(name), uint64(nameLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.path_remove_directory(fd=3,path=wazero)
 <== errno=ESUCCESS
@@ -3932,7 +3932,7 @@ func Test_pathRemoveDirectory_Errors(t *testing.T) {
 
 			mod.Memory().Write(tc.path, []byte(tc.pathName))
 
-			requireErrno(t, tc.expectedErrno, mod, PathRemoveDirectoryName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
+			requireErrnoResult(t, tc.expectedErrno, mod, PathRemoveDirectoryName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -3966,7 +3966,7 @@ func Test_pathSymlink_errors(t *testing.T) {
 	require.True(t, ok)
 
 	t.Run("success", func(t *testing.T) {
-		requireErrno(t, ErrnoSuccess, mod, PathSymlinkName,
+		requireErrnoResult(t, ErrnoSuccess, mod, PathSymlinkName,
 			fileNamePtr, uint64(len(filename)), uint64(fd), destinationNamePtr, uint64(len(destinationName)))
 		require.Contains(t, log.String(), ErrnoName(ErrnoSuccess))
 		st, err := os.Lstat(path.Join(tmpDir, dirname, destinationName))
@@ -4002,7 +4002,7 @@ func Test_pathSymlink_errors(t *testing.T) {
 		} {
 			name := ErrnoName(tc.errno)
 			t.Run(name, func(t *testing.T) {
-				requireErrno(t, tc.errno, mod, PathSymlinkName,
+				requireErrnoResult(t, tc.errno, mod, PathSymlinkName,
 					tc.oldPtr, tc.oldLen, tc.dirFd, tc.newPtr, tc.newLen)
 				require.Contains(t, log.String(), name)
 			})
@@ -4037,7 +4037,7 @@ func Test_pathRename(t *testing.T) {
 	ok = mod.Memory().Write(newPath, []byte(newPathName))
 	require.True(t, ok)
 
-	requireErrno(t, ErrnoSuccess, mod, PathRenameName,
+	requireErrnoResult(t, ErrnoSuccess, mod, PathRenameName,
 		uint64(oldDirFD), uint64(oldPath), uint64(oldPathLen),
 		uint64(newDirFD), uint64(newPath), uint64(newPathLen))
 	require.Equal(t, `
@@ -4218,7 +4218,7 @@ func Test_pathRename_Errors(t *testing.T) {
 			mod.Memory().Write(tc.oldPath, []byte(tc.oldPathName))
 			mod.Memory().Write(tc.newPath, []byte(tc.newPathName))
 
-			requireErrno(t, tc.expectedErrno, mod, PathRenameName,
+			requireErrnoResult(t, tc.expectedErrno, mod, PathRenameName,
 				uint64(tc.oldFd), uint64(tc.oldPath), uint64(tc.oldPathLen),
 				uint64(tc.newFd), uint64(tc.newPath), uint64(tc.newPathLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
@@ -4246,7 +4246,7 @@ func Test_pathUnlinkFile(t *testing.T) {
 	name := 1
 	nameLen := len(pathName)
 
-	requireErrno(t, ErrnoSuccess, mod, PathUnlinkFileName, uint64(dirFD), uint64(name), uint64(nameLen))
+	requireErrnoResult(t, ErrnoSuccess, mod, PathUnlinkFileName, uint64(dirFD), uint64(name), uint64(nameLen))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.path_unlink_file(fd=3,path=wazero)
 <== errno=ESUCCESS
@@ -4351,7 +4351,7 @@ func Test_pathUnlinkFile_Errors(t *testing.T) {
 
 			mod.Memory().Write(tc.path, []byte(tc.pathName))
 
-			requireErrno(t, tc.expectedErrno, mod, PathUnlinkFileName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
+			requireErrnoResult(t, tc.expectedErrno, mod, PathUnlinkFileName, uint64(tc.fd), uint64(tc.path), uint64(tc.pathLen))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -4404,7 +4404,7 @@ func Test_fdReaddir_opened_file_written(t *testing.T) {
 
 	const readDirTarget = "dir"
 	mem.Write(0, []byte(readDirTarget))
-	requireErrno(t, ErrnoSuccess, mod, PathCreateDirectoryName,
+	requireErrnoResult(t, ErrnoSuccess, mod, PathCreateDirectoryName,
 		uint64(sys.FdPreopen), uint64(0), uint64(len(readDirTarget)))
 
 	// Open the directory, before writing files!
@@ -4419,7 +4419,7 @@ func Test_fdReaddir_opened_file_written(t *testing.T) {
 	// Try list them!
 	resultBufused := uint32(0) // where to write the amount used out of bufLen
 	buf := uint32(8)           // where to start the dirents
-	requireErrno(t, ErrnoSuccess, mod, FdReaddirName,
+	requireErrnoResult(t, ErrnoSuccess, mod, FdReaddirName,
 		uint64(dirFd), uint64(buf), uint64(0x2000), 0, uint64(resultBufused))
 
 	used, _ := mem.ReadUint32Le(resultBufused)
diff --git a/imports/wasi_snapshot_preview1/poll_test.go b/imports/wasi_snapshot_preview1/poll_test.go
index 00801336..d2956297 100644
--- a/imports/wasi_snapshot_preview1/poll_test.go
+++ b/imports/wasi_snapshot_preview1/poll_test.go
@@ -39,7 +39,7 @@ func Test_pollOneoff(t *testing.T) {
 	maskMemory(t, mod, 1024)
 	mod.Memory().Write(in, mem)
 
-	requireErrno(t, ErrnoSuccess, mod, PollOneoffName, uint64(in), uint64(out), uint64(nsubscriptions),
+	requireErrnoResult(t, ErrnoSuccess, mod, PollOneoffName, uint64(in), uint64(out), uint64(nsubscriptions),
 		uint64(resultNevents))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.poll_oneoff(in=0,out=128,nsubscriptions=1)
@@ -146,7 +146,7 @@ func Test_pollOneoff_Errors(t *testing.T) {
 				mod.Memory().Write(tc.in, tc.mem)
 			}
 
-			requireErrno(t, tc.expectedErrno, mod, PollOneoffName, uint64(tc.in), uint64(tc.out),
+			requireErrnoResult(t, tc.expectedErrno, mod, PollOneoffName, uint64(tc.in), uint64(tc.out),
 				uint64(tc.nsubscriptions), uint64(tc.resultNevents))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 
diff --git a/imports/wasi_snapshot_preview1/random_test.go b/imports/wasi_snapshot_preview1/random_test.go
index a65ecf0a..5fa2e1dd 100644
--- a/imports/wasi_snapshot_preview1/random_test.go
+++ b/imports/wasi_snapshot_preview1/random_test.go
@@ -28,7 +28,7 @@ func Test_randomGet(t *testing.T) {
 	maskMemory(t, mod, len(expectedMemory))
 
 	// Invoke randomGet and check the memory side effects!
-	requireErrno(t, ErrnoSuccess, mod, RandomGetName, uint64(offset), uint64(length))
+	requireErrnoResult(t, ErrnoSuccess, mod, RandomGetName, uint64(offset), uint64(length))
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.random_get(buf=1,buf_len=5)
 <== errno=ESUCCESS
@@ -76,7 +76,7 @@ func Test_randomGet_Errors(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
 
-			requireErrno(t, ErrnoFault, mod, RandomGetName, uint64(tc.offset), uint64(tc.length))
+			requireErrnoResult(t, ErrnoFault, mod, RandomGetName, uint64(tc.offset), uint64(tc.length))
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
@@ -113,7 +113,7 @@ func Test_randomGet_SourceError(t *testing.T) {
 				WithRandSource(tc.randSource))
 			defer r.Close(testCtx)
 
-			requireErrno(t, ErrnoIo, mod, RandomGetName, uint64(1), uint64(5)) // arbitrary offset and length
+			requireErrnoResult(t, ErrnoIo, mod, RandomGetName, uint64(1), uint64(5)) // arbitrary offset and length
 			require.Equal(t, tc.expectedLog, "\n"+log.String())
 		})
 	}
diff --git a/imports/wasi_snapshot_preview1/sched_test.go b/imports/wasi_snapshot_preview1/sched_test.go
index fb3c2256..596563c0 100644
--- a/imports/wasi_snapshot_preview1/sched_test.go
+++ b/imports/wasi_snapshot_preview1/sched_test.go
@@ -15,7 +15,7 @@ func Test_schedYield(t *testing.T) {
 			yielded = true
 		}))
 	defer r.Close(testCtx)
-	requireErrno(t, ErrnoSuccess, mod, SchedYieldName)
+	requireErrnoResult(t, ErrnoSuccess, mod, SchedYieldName)
 	require.Equal(t, `
 ==> wasi_snapshot_preview1.sched_yield()
 <== errno=ESUCCESS
diff --git a/imports/wasi_snapshot_preview1/wasi_test.go b/imports/wasi_snapshot_preview1/wasi_test.go
index a223e0d6..bcc25cd5 100644
--- a/imports/wasi_snapshot_preview1/wasi_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_test.go
@@ -137,11 +137,11 @@ func requireErrnoNosys(t *testing.T, funcName string, params ...uint64) string {
 	mod, err := r.InstantiateModule(ctx, proxyCompiled, wazero.NewModuleConfig())
 	require.NoError(t, err)
 
-	requireErrno(t, ErrnoNosys, mod, funcName, params...)
+	requireErrnoResult(t, ErrnoNosys, mod, funcName, params...)
 	return "\n" + log.String()
 }
 
-func requireErrno(t *testing.T, expectedErrno Errno, mod api.Closer, funcName string, params ...uint64) {
+func requireErrnoResult(t *testing.T, expectedErrno Errno, mod api.Closer, funcName string, params ...uint64) {
 	results, err := mod.(api.Module).ExportedFunction(funcName).Call(testCtx, params...)
 	require.NoError(t, err)
 	errno := Errno(results[0])
diff --git a/internal/fstest/fstest.go b/internal/fstest/fstest.go
index 03310309..8bd223a0 100644
--- a/internal/fstest/fstest.go
+++ b/internal/fstest/fstest.go
@@ -100,17 +100,16 @@ func WriteTestFiles(tmpDir string) (err error) {
 			}
 
 			// os.Stat uses GetFileInformationByHandle internally.
-			stat, err := os.Stat(path)
-			if err != nil {
+			var stat platform.Stat_t
+			if err = platform.Stat(path, &stat); err != nil {
 				return err
 			}
-			if stat.ModTime() == info.ModTime() {
+			if stat.Mtim == info.ModTime().UnixNano() {
 				return nil // synced!
 			}
 
 			// Otherwise, we need to sync the timestamps.
-			atimeNsec, mtimeNsec, _ := platform.StatTimes(stat)
-			return os.Chtimes(path, time.Unix(0, atimeNsec), time.Unix(0, mtimeNsec))
+			return os.Chtimes(path, time.Unix(0, stat.Atim), time.Unix(0, stat.Mtim))
 		})
 	}
 	return
diff --git a/internal/gojs/errno_test.go b/internal/gojs/errno_test.go
index 47bc972a..b99c482f 100644
--- a/internal/gojs/errno_test.go
+++ b/internal/gojs/errno_test.go
@@ -3,8 +3,6 @@ package gojs
 import (
 	"syscall"
 	"testing"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
 func TestToErrno(t *testing.T) {
@@ -103,8 +101,9 @@ func TestToErrno(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			errno := ToErrno(tc.input)
-			require.Equal(t, tc.expected, errno)
+			if errno := ToErrno(tc.input); errno != tc.expected {
+				t.Fatalf("expected %#v but was %#v", tc.expected, errno)
+			}
 		})
 	}
 }
diff --git a/internal/platform/error_test.go b/internal/platform/error_test.go
new file mode 100644
index 00000000..f24b90ea
--- /dev/null
+++ b/internal/platform/error_test.go
@@ -0,0 +1,83 @@
+package platform
+
+import (
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"syscall"
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestUnwrapOSError(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    error
+		expected syscall.Errno
+	}{
+		{
+			name:     "LinkError ErrInvalid",
+			input:    &os.LinkError{Err: fs.ErrInvalid},
+			expected: syscall.EINVAL,
+		},
+		{
+			name:     "PathError ErrInvalid",
+			input:    &os.PathError{Err: fs.ErrInvalid},
+			expected: syscall.EINVAL,
+		},
+		{
+			name:     "SyscallError ErrInvalid",
+			input:    &os.SyscallError{Err: fs.ErrInvalid},
+			expected: syscall.EINVAL,
+		},
+		{
+			name:     "PathError ErrPermission",
+			input:    &os.PathError{Err: os.ErrPermission},
+			expected: syscall.EPERM,
+		},
+		{
+			name:     "PathError ErrExist",
+			input:    &os.PathError{Err: os.ErrExist},
+			expected: syscall.EEXIST,
+		},
+		{
+			name:     "PathError syscall.ErrnotExist",
+			input:    &os.PathError{Err: os.ErrNotExist},
+			expected: syscall.ENOENT,
+		},
+		{
+			name:     "PathError ErrClosed",
+			input:    &os.PathError{Err: os.ErrClosed},
+			expected: syscall.EBADF,
+		},
+		{
+			name:     "PathError unknown == syscall.EIO",
+			input:    &os.PathError{Err: errors.New("ice cream")},
+			expected: syscall.EIO,
+		},
+		{
+			name:     "unknown == syscall.EIO",
+			input:    errors.New("ice cream"),
+			expected: syscall.EIO,
+		},
+		{
+			name:     "very wrapped unknown == syscall.EIO",
+			input:    fmt.Errorf("%w", fmt.Errorf("%w", fmt.Errorf("%w", errors.New("ice cream")))),
+			expected: syscall.EIO,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			errno := UnwrapOSError(tc.input)
+			require.EqualErrno(t, tc.expected, errno)
+		})
+	}
+
+	t.Run("nil", func(t *testing.T) {
+		require.Nil(t, UnwrapOSError(nil))
+	})
+}
diff --git a/internal/platform/open_file_test.go b/internal/platform/open_file_test.go
index 0f0a029c..3ab2e256 100644
--- a/internal/platform/open_file_test.go
+++ b/internal/platform/open_file_test.go
@@ -15,7 +15,7 @@ func TestOpenFile_Errors(t *testing.T) {
 
 	t.Run("not found must be ENOENT", func(t *testing.T) {
 		_, err := OpenFile(path.Join(tmp, "not-really-exist.txt"), os.O_RDONLY, 0o600)
-		require.ErrorIs(t, err, syscall.ENOENT)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	// This is the same as https://github.com/ziglang/zig/blob/d24ebf1d12cf66665b52136a2807f97ff021d78d/lib/std/os/test.zig#L105-L112
@@ -26,7 +26,7 @@ func TestOpenFile_Errors(t *testing.T) {
 		require.NoError(t, err)
 
 		_, err = OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666)
-		require.ErrorIs(t, err, syscall.EEXIST)
+		require.EqualErrno(t, syscall.EEXIST, err)
 	})
 
 	// This is similar to https://github.com/WebAssembly/wasi-testsuite/blob/dc7f8d27be1030cd4788ebdf07d9b57e5d23441e/tests/rust/src/bin/dangling_symlink.rs
@@ -38,9 +38,9 @@ func TestOpenFile_Errors(t *testing.T) {
 		require.NoError(t, err)
 
 		_, err = OpenFile(symlink, O_DIRECTORY|O_NOFOLLOW, 0o0666)
-		require.ErrorIs(t, err, syscall.ENOTDIR)
+		require.EqualErrno(t, syscall.ENOTDIR, err)
 
 		_, err = OpenFile(symlink, O_NOFOLLOW, 0o0666)
-		require.ErrorIs(t, err, syscall.ELOOP)
+		require.EqualErrno(t, syscall.ELOOP, err)
 	})
 }
diff --git a/internal/platform/rename_test.go b/internal/platform/rename_test.go
index 42755aa5..e2f29bc4 100644
--- a/internal/platform/rename_test.go
+++ b/internal/platform/rename_test.go
@@ -19,7 +19,7 @@ func TestRename(t *testing.T) {
 		require.NoError(t, err)
 
 		err = Rename(path.Join(tmpDir, "non-exist"), file1Path)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 	t.Run("file to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
@@ -35,7 +35,7 @@ func TestRename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		s, err := os.Stat(file2Path)
 		require.NoError(t, err)
@@ -53,7 +53,7 @@ func TestRename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		s, err := os.Stat(dir2Path)
 		require.NoError(t, err)
@@ -72,7 +72,7 @@ func TestRename(t *testing.T) {
 		require.NoError(t, err)
 
 		err = Rename(dir1Path, dir2Path)
-		require.Equal(t, syscall.ENOTDIR, err)
+		require.EqualErrno(t, syscall.ENOTDIR, err)
 	})
 	t.Run("file to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
@@ -86,7 +86,7 @@ func TestRename(t *testing.T) {
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		err = Rename(file1Path, dir1Path)
-		require.Equal(t, syscall.EISDIR, err)
+		require.EqualErrno(t, syscall.EISDIR, err)
 	})
 
 	// Similar to https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs/test.zig#L567-L582
@@ -112,7 +112,7 @@ func TestRename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		// Show the file inside that directory moved
 		s, err := os.Stat(path.Join(dir2Path, file1))
@@ -142,7 +142,7 @@ func TestRename(t *testing.T) {
 		require.NoError(t, err)
 
 		err = Rename(dir1Path, dir2Path)
-		require.ErrorIs(t, syscall.ENOTEMPTY, err)
+		require.EqualErrno(t, syscall.ENOTEMPTY, err)
 	})
 
 	t.Run("file to file", func(t *testing.T) {
@@ -163,7 +163,7 @@ func TestRename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		// Show the file1 overwrote file2
 		b, err := os.ReadFile(file2Path)
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
index e5962534..79340d25 100644
--- a/internal/platform/stat_test.go
+++ b/internal/platform/stat_test.go
@@ -4,13 +4,109 @@ import (
 	"os"
 	"path"
 	"runtime"
+	"syscall"
 	"testing"
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func Test_Stat(t *testing.T) {
+func TestStat(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	var stat Stat_t
+	require.EqualErrno(t, syscall.ENOENT, Stat(path.Join(tmpDir, "cat"), &stat))
+	require.EqualErrno(t, syscall.ENOENT, Stat(path.Join(tmpDir, "sub/cat"), &stat))
+
+	t.Run("dir", func(t *testing.T) {
+		err := Stat(tmpDir, &stat)
+		require.NoError(t, err)
+		require.True(t, stat.Mode.IsDir())
+	})
+
+	t.Run("file", func(t *testing.T) {
+		file := path.Join(tmpDir, "file")
+		require.NoError(t, os.WriteFile(file, nil, 0o400))
+
+		require.NoError(t, Stat(file, &stat))
+		require.False(t, stat.Mode.IsDir())
+	})
+
+	t.Run("subdir", func(t *testing.T) {
+		subdir := path.Join(tmpDir, "sub")
+		require.NoError(t, os.Mkdir(subdir, 0o500))
+
+		require.NoError(t, Stat(subdir, &stat))
+		require.True(t, stat.Mode.IsDir())
+	})
+}
+
+func TestStatFile(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	var stat Stat_t
+
+	tmpDirF, err := OpenFile(tmpDir, syscall.O_RDONLY, 0)
+	if err != nil {
+		return
+	}
+	defer tmpDirF.Close()
+
+	t.Run("dir", func(t *testing.T) {
+		err = StatFile(tmpDirF, &stat)
+		require.NoError(t, err)
+		require.True(t, stat.Mode.IsDir())
+	})
+
+	if runtime.GOOS != "windows" { // windows allows you to stat a closed dir
+		t.Run("closed dir", func(t *testing.T) {
+			require.NoError(t, tmpDirF.Close())
+			require.EqualErrno(t, syscall.EBADF, StatFile(tmpDirF, &stat))
+		})
+	}
+
+	file := path.Join(tmpDir, "file")
+	require.NoError(t, os.WriteFile(file, nil, 0o400))
+	fileF, err := OpenFile(file, syscall.O_RDONLY, 0)
+	if err != nil {
+		return
+	}
+	defer fileF.Close()
+
+	t.Run("file", func(t *testing.T) {
+		err = StatFile(fileF, &stat)
+		require.NoError(t, err)
+		require.False(t, stat.Mode.IsDir())
+	})
+
+	t.Run("closed file", func(t *testing.T) {
+		require.NoError(t, fileF.Close())
+		require.EqualErrno(t, syscall.EBADF, StatFile(fileF, &stat))
+	})
+
+	subdir := path.Join(tmpDir, "sub")
+	require.NoError(t, os.Mkdir(subdir, 0o500))
+	subdirF, err := OpenFile(subdir, syscall.O_RDONLY, 0)
+	if err != nil {
+		return
+	}
+	defer subdirF.Close()
+
+	t.Run("subdir", func(t *testing.T) {
+		err = StatFile(subdirF, &stat)
+		require.NoError(t, err)
+		require.True(t, stat.Mode.IsDir())
+	})
+
+	if runtime.GOOS != "windows" { // windows allows you to stat a closed dir
+		t.Run("closed subdir", func(t *testing.T) {
+			require.NoError(t, subdirF.Close())
+			require.EqualErrno(t, syscall.EBADF, StatFile(subdirF, &stat))
+		})
+	}
+}
+
+func Test_StatFile_times(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	file := path.Join(tmpDir, "file")
@@ -49,63 +145,58 @@ func Test_Stat(t *testing.T) {
 			err := os.Chtimes(file, time.UnixMicro(tc.atimeNsec/1e3), time.UnixMicro(tc.mtimeNsec/1e3))
 			require.NoError(t, err)
 
-			stat, err := os.Stat(file)
+			file, err := os.Open(file)
 			require.NoError(t, err)
+			defer file.Close()
 
-			atimeNsec, mtimeNsec, _ := StatTimes(stat)
-			require.Equal(t, atimeNsec, tc.atimeNsec)
-			require.Equal(t, mtimeNsec, tc.mtimeNsec)
+			var stat Stat_t
+			require.NoError(t, StatFile(file, &stat))
+			require.Equal(t, stat.Atim, tc.atimeNsec)
+			require.Equal(t, stat.Mtim, tc.mtimeNsec)
 		})
 	}
 }
 
-func TestStat_dev_inode(t *testing.T) {
+func TestStatFile_dev_inode(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	path1 := path.Join(tmpDir, "1")
-	fa, err := os.Create(path1)
+	f1, err := os.Create(path1)
 	require.NoError(t, err)
 
 	path2 := path.Join(tmpDir, "2")
-	fb, err := os.Create(path2)
+	f2, err := os.Create(path2)
 	require.NoError(t, err)
 
-	stat1, err := fa.Stat()
-	require.NoError(t, err)
-	_, _, _, _, device1, inode1, err := Stat(fa, stat1)
-	require.NoError(t, err)
+	var stat1 Stat_t
+	require.NoError(t, StatFile(f1, &stat1))
 
-	stat2, err := fb.Stat()
-	require.NoError(t, err)
-	_, _, _, _, device2, inode2, err := Stat(fb, stat2)
-	require.NoError(t, err)
+	var stat2 Stat_t
+	require.NoError(t, StatFile(f2, &stat2))
 
 	// The files should be on the same device, but different inodes
-	require.Equal(t, device1, device2)
-	require.NotEqual(t, inode1, inode2)
+	require.Equal(t, stat1.Dev, stat2.Dev)
+	require.NotEqual(t, stat1.Ino, stat2.Ino)
 
 	// Redoing stat should result in the same inodes
-	stat1Again, err := os.Stat(path1)
-	require.NoError(t, err)
-	_, _, _, _, device1Again, inode1Again, err := Stat(fa, stat1Again)
-	require.NoError(t, err)
-	require.Equal(t, device1, device1Again)
-	require.Equal(t, inode1, inode1Again)
+	var stat1Again Stat_t
+	require.NoError(t, StatFile(f1, &stat1Again))
+
+	require.Equal(t, stat1.Dev, stat1Again.Dev)
+	require.Equal(t, stat1.Ino, stat1Again.Ino)
 
 	// On Windows, we cannot rename while opening.
 	// So we manually close here before renaming.
-	require.NoError(t, fa.Close())
-	require.NoError(t, fb.Close())
+	require.NoError(t, f1.Close())
+	require.NoError(t, f2.Close())
 
 	// Renaming a file shouldn't change its inodes.
 	require.NoError(t, Rename(path1, path2))
-	fa, err = os.Open(path2)
-	require.NoError(t, err)
-	defer func() { require.NoError(t, fa.Close()) }()
-	stat1Again, err = os.Stat(path2)
+	f1, err = os.Open(path2)
 	require.NoError(t, err)
-	_, _, _, _, device1Again, inode1Again, err = Stat(fa, stat1Again)
-	require.NoError(t, err)
-	require.Equal(t, device1, device1Again)
-	require.Equal(t, inode1, inode1Again)
+	defer f1.Close()
+
+	require.NoError(t, StatFile(f1, &stat1Again))
+	require.Equal(t, stat1.Dev, stat1Again.Dev)
+	require.Equal(t, stat1.Ino, stat1Again.Ino)
 }
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index fcbcb164..770a1909 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -21,14 +21,14 @@ func TestAdapt_MkDir(t *testing.T) {
 	testFS := Adapt(os.DirFS(t.TempDir()))
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Chmod(t *testing.T) {
 	testFS := Adapt(os.DirFS(t.TempDir()))
 
 	err := testFS.Chmod("chmod", fs.ModeDir)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Rename(t *testing.T) {
@@ -48,7 +48,7 @@ func TestAdapt_Rename(t *testing.T) {
 	require.NoError(t, err)
 
 	err = testFS.Rename(file1, file2)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Rmdir(t *testing.T) {
@@ -60,7 +60,7 @@ func TestAdapt_Rmdir(t *testing.T) {
 	require.NoError(t, os.Mkdir(realPath, 0o700))
 
 	err := testFS.Rmdir(path)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Unlink(t *testing.T) {
@@ -72,7 +72,7 @@ func TestAdapt_Unlink(t *testing.T) {
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Unlink(path)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Utimes(t *testing.T) {
@@ -84,7 +84,7 @@ func TestAdapt_Utimes(t *testing.T) {
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Utimes(path, 1, 1)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestAdapt_Open_Read(t *testing.T) {
@@ -100,10 +100,18 @@ func TestAdapt_Open_Read(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
 
 		// fs.FS doesn't allow relative path lookups
-		require.Equal(t, syscall.EINVAL, err)
+		require.EqualErrno(t, syscall.EINVAL, err)
 	})
 }
 
+func TestAdapt_Stat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	testFS := Adapt(os.DirFS(tmpDir))
+	testStat(t, testFS)
+}
+
 // hackFS cheats the fs.FS contract by opening for write (os.O_RDWR).
 //
 // Until we have an alternate public interface for filesystems, some users will
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 4ae5ec4e..f9f54de0 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -10,6 +10,7 @@ import (
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/fstest"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -24,7 +25,7 @@ func TestNewDirFS(t *testing.T) {
 	t.Run("host path not found", func(t *testing.T) {
 		testFS := NewDirFS("a")
 		_, err = testFS.OpenFile(".", os.O_RDONLY, 0)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 	t.Run("host path not a directory", func(t *testing.T) {
 		arg0 := os.Args[0] // should be safe in scratch tests which don't have the source mounted.
@@ -33,7 +34,7 @@ func TestNewDirFS(t *testing.T) {
 		d, err := testFS.OpenFile(".", os.O_RDONLY, 0)
 		require.NoError(t, err)
 		_, err = d.(fs.ReadDirFile).ReadDir(-1)
-		require.Equal(t, syscall.ENOTDIR, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOTDIR, errors.Unwrap(err))
 	})
 }
 
@@ -61,7 +62,7 @@ func TestDirFS_MkDir(t *testing.T) {
 
 	t.Run("dir exists", func(t *testing.T) {
 		err := testFS.Mkdir(name, fs.ModeDir)
-		require.Equal(t, syscall.EEXIST, err)
+		require.EqualErrno(t, syscall.EEXIST, err)
 	})
 
 	t.Run("file exists", func(t *testing.T) {
@@ -69,12 +70,12 @@ func TestDirFS_MkDir(t *testing.T) {
 		require.NoError(t, os.Mkdir(realPath, 0o700))
 
 		err := testFS.Mkdir(name, fs.ModeDir)
-		require.Equal(t, syscall.EEXIST, err)
+		require.EqualErrno(t, syscall.EEXIST, err)
 	})
 	t.Run("try creating on file", func(t *testing.T) {
 		filePath := pathutil.Join("non-existing-dir", "foo.txt")
 		err := testFS.Mkdir(filePath, fs.ModeDir)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	// Remove the path so that we can test creating it with perms.
@@ -112,9 +113,9 @@ func testChmod(t *testing.T, testFS FS, path string) {
 }
 
 func requireMode(t *testing.T, testFS FS, path string, mode fs.FileMode) {
-	stat, err := StatPath(testFS, path)
-	require.NoError(t, err)
-	require.Equal(t, mode, stat.Mode()&fs.ModePerm)
+	var stat platform.Stat_t
+	require.NoError(t, testFS.Stat(path, &stat))
+	require.Equal(t, mode, stat.Mode.Perm())
 }
 
 func TestDirFS_Rename(t *testing.T) {
@@ -128,7 +129,7 @@ func TestDirFS_Rename(t *testing.T) {
 		require.NoError(t, err)
 
 		err = testFS.Rename("file2", file1)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 	t.Run("file to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
@@ -147,7 +148,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		s, err := os.Stat(file2Path)
 		require.NoError(t, err)
@@ -168,7 +169,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		s, err := os.Stat(dir2Path)
 		require.NoError(t, err)
@@ -191,7 +192,7 @@ func TestDirFS_Rename(t *testing.T) {
 		require.NoError(t, f.Close())
 
 		err = testFS.Rename(dir1, dir2)
-		require.Equal(t, syscall.ENOTDIR, err)
+		require.EqualErrno(t, syscall.ENOTDIR, err)
 	})
 	t.Run("file to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
@@ -208,7 +209,7 @@ func TestDirFS_Rename(t *testing.T) {
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		err = testFS.Rename(file1, dir1)
-		require.Equal(t, syscall.EISDIR, err)
+		require.EqualErrno(t, syscall.EISDIR, err)
 	})
 
 	// Similar to https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs/test.zig#L567-L582
@@ -236,7 +237,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		// Show the file inside that directory moved
 		s, err := os.Stat(pathutil.Join(dir2Path, file1))
@@ -269,7 +270,7 @@ func TestDirFS_Rename(t *testing.T) {
 		require.NoError(t, err)
 
 		err = testFS.Rename(dir1, dir2)
-		require.ErrorIs(t, syscall.ENOTEMPTY, err)
+		require.EqualErrno(t, syscall.ENOTEMPTY, err)
 	})
 
 	t.Run("file to file", func(t *testing.T) {
@@ -293,7 +294,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
 
 		// Show the file1 overwrote file2
 		b, err := os.ReadFile(file2Path)
@@ -343,7 +344,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Rmdir(name)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	t.Run("dir not empty", func(t *testing.T) {
@@ -352,7 +353,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 		require.NoError(t, os.WriteFile(fileInDir, []byte{}, 0o600))
 
 		err := testFS.Rmdir(name)
-		require.Equal(t, syscall.ENOTEMPTY, err)
+		require.EqualErrno(t, syscall.ENOTEMPTY, err)
 
 		require.NoError(t, os.Remove(fileInDir))
 	})
@@ -367,7 +368,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 		require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 		err := testFS.Rmdir(name)
-		require.Equal(t, syscall.ENOTDIR, err)
+		require.EqualErrno(t, syscall.ENOTDIR, err)
 
 		require.NoError(t, os.Remove(realPath))
 	})
@@ -382,14 +383,14 @@ func TestDirFS_Unlink(t *testing.T) {
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Unlink(name)
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	t.Run("not file", func(t *testing.T) {
 		require.NoError(t, os.Mkdir(realPath, 0o700))
 
 		err := testFS.Unlink(name)
-		require.Equal(t, syscall.EISDIR, err)
+		require.EqualErrno(t, syscall.EISDIR, err)
 
 		require.NoError(t, os.Remove(realPath))
 	})
@@ -431,6 +432,14 @@ func TestDirFS_OpenFile(t *testing.T) {
 	})
 }
 
+func TestDirFS_Stat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	testFS := NewDirFS(tmpDir)
+	testStat(t, testFS)
+}
+
 func TestDirFS_Truncate(t *testing.T) {
 	content := []byte("123456")
 
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 68c1dea9..591c3556 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -38,7 +38,7 @@ func TestReadFS_MkDir(t *testing.T) {
 	testFS := NewReadFS(writeable)
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Chmod(t *testing.T) {
@@ -46,7 +46,7 @@ func TestReadFS_Chmod(t *testing.T) {
 	testFS := NewReadFS(writeable)
 
 	err := testFS.Chmod("chmod", fs.ModeDir)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Rename(t *testing.T) {
@@ -67,7 +67,7 @@ func TestReadFS_Rename(t *testing.T) {
 	require.NoError(t, err)
 
 	err = testFS.Rename(file1, file2)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Rmdir(t *testing.T) {
@@ -80,7 +80,7 @@ func TestReadFS_Rmdir(t *testing.T) {
 	require.NoError(t, os.Mkdir(realPath, 0o700))
 
 	err := testFS.Rmdir(path)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Unlink(t *testing.T) {
@@ -93,7 +93,7 @@ func TestReadFS_Unlink(t *testing.T) {
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Unlink(path)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Utimes(t *testing.T) {
@@ -106,7 +106,7 @@ func TestReadFS_Utimes(t *testing.T) {
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Utimes(path, 1, 1)
-	require.Equal(t, syscall.ENOSYS, err)
+	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Open_Read(t *testing.T) {
@@ -117,6 +117,15 @@ func TestReadFS_Open_Read(t *testing.T) {
 	testOpen_Read(t, tmpDir, testFS)
 }
 
+func TestReadFS_Stat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	writeable := NewDirFS(tmpDir)
+	testFS := NewReadFS(writeable)
+	testStat(t, testFS)
+}
+
 func TestReadFS_TestFS(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
index 7b33b23f..bb42f5a6 100644
--- a/internal/sysfs/rootfs_test.go
+++ b/internal/sysfs/rootfs_test.go
@@ -142,7 +142,10 @@ func TestRootFS_Open(t *testing.T) {
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 
-	testFS := NewDirFS(tmpDir)
+	testRootFS := NewDirFS(tmpDir)
+	testDirFS := NewDirFS(t.TempDir())
+	testFS, err := NewRootFS([]FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
+	require.NoError(t, err)
 
 	testOpen_Read(t, tmpDir, testFS)
 
@@ -156,6 +159,16 @@ func TestRootFS_Open(t *testing.T) {
 	})
 }
 
+func TestRootFS_Stat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	tmpFS := NewDirFS(t.TempDir())
+	testFS, err := NewRootFS([]FS{NewDirFS(tmpDir), tmpFS}, []string{"/", "/tmp"})
+	require.NoError(t, err)
+	testStat(t, testFS)
+}
+
 func TestRootFS_TestFS(t *testing.T) {
 	t.Parallel()
 
@@ -276,7 +289,7 @@ func TestRootFS_examples(t *testing.T) {
 
 			for _, p := range tc.unexpected {
 				_, err := root.OpenFile(p, os.O_RDONLY, 0)
-				require.Equal(t, syscall.ENOENT, err)
+				require.EqualErrno(t, syscall.ENOENT, err)
 			}
 		})
 	}
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 813ff57a..1b59ed25 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -5,7 +5,6 @@ import (
 	"embed"
 	_ "embed"
 	"errors"
-	"fmt"
 	"io"
 	"io/fs"
 	"os"
@@ -17,7 +16,6 @@ import (
 	gofstest "testing/fstest"
 	"time"
 
-	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -60,7 +58,7 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
 	if runtime.GOOS != "windows" {
 		// If the read-only flag was honored, we should not be able to write!
 		_, err = w.Write(fileContents)
-		require.Equal(t, syscall.EBADF, UnwrapOSError(err))
+		require.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
 	}
 
 	// Verify stat on the file
@@ -84,11 +82,16 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 	fileInDir := path.Join(dirRealPath, file1)
 	require.NoError(t, os.WriteFile(fileInDir, []byte{2}, 0o600))
 
+	emptydir := "emptydir"
+	emptydirRealPath := path.Join(tmpDir, emptydir)
+	err = os.Mkdir(emptydirRealPath, 0o700)
+	require.NoError(t, err)
+
 	t.Run("doesn't exist", func(t *testing.T) {
 		_, err := testFS.OpenFile("nope", os.O_RDONLY, 0)
 
 		// We currently follow os.Open not syscall.Open, so the error is wrapped.
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	t.Run(". opens root", func(t *testing.T) {
@@ -97,11 +100,22 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 		defer f.Close()
 
 		entries := requireReadDir(t, f)
-		require.Equal(t, 2, len(entries))
+		require.Equal(t, 3, len(entries))
 		require.True(t, entries[0].IsDir())
 		require.Equal(t, dir, entries[0].Name())
-		require.False(t, entries[1].IsDir())
-		require.Equal(t, file, entries[1].Name())
+		require.True(t, entries[1].IsDir())
+		require.Equal(t, emptydir, entries[1].Name())
+		require.False(t, entries[2].IsDir())
+		require.Equal(t, file, entries[2].Name())
+	})
+
+	t.Run("empty dir exists", func(t *testing.T) {
+		f, err := testFS.OpenFile(emptydir, os.O_RDONLY, 0)
+		require.NoError(t, err)
+		defer f.Close()
+
+		entries := requireReadDir(t, f)
+		require.Zero(t, len(entries))
 	})
 
 	t.Run("dir exists", func(t *testing.T) {
@@ -143,9 +157,9 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 		if w, ok := f.(io.Writer); ok {
 			_, err := w.Write([]byte("hello"))
 			if runtime.GOOS == "windows" {
-				requireErrno(t, syscall.EPERM, err)
+				require.EqualErrno(t, syscall.EPERM, errors.Unwrap(err))
 			} else {
-				requireErrno(t, syscall.EBADF, err)
+				require.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
 			}
 		}
 	})
@@ -162,12 +176,26 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 	})
 }
 
+func testStat(t *testing.T, testFS FS) {
+	var stat platform.Stat_t
+	require.EqualErrno(t, syscall.ENOENT, testFS.Stat("cat", &stat))
+	require.EqualErrno(t, syscall.ENOENT, testFS.Stat("sub/cat", &stat))
+
+	err := testFS.Stat("sub/test.txt", &stat)
+	require.NoError(t, err)
+	require.False(t, stat.Mode.IsDir())
+
+	err = testFS.Stat("sub", &stat)
+	require.NoError(t, err)
+	require.True(t, stat.Mode.IsDir())
+}
+
 // requireReadDir ensures the input file is a directory, and returns its
 // entries.
 func requireReadDir(t *testing.T, f fs.File) []fs.DirEntry {
 	if w, ok := f.(io.Writer); ok {
 		_, err := w.Write([]byte("hello"))
-		requireErrno(t, syscall.EBADF, err)
+		require.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
 	}
 	// Ensure it implements fs.ReadDirFile
 	dir, ok := f.(fs.ReadDirFile)
@@ -191,7 +219,7 @@ func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 		err := testFS.Utimes("nope",
 			time.Unix(123, 4*1e3).UnixNano(),
 			time.Unix(567, 8*1e3).UnixNano())
-		require.Equal(t, syscall.ENOENT, err)
+		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
 	type test struct {
@@ -243,24 +271,16 @@ func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 			err := testFS.Utimes(tc.path, tc.atimeNsec, tc.mtimeNsec)
 			require.NoError(t, err)
 
-			stat, err := os.Stat(path.Join(tmpDir, tc.path))
-			require.NoError(t, err)
-
-			atimeNsec, mtimeNsec, _ := platform.StatTimes(stat)
+			var stat platform.Stat_t
+			require.NoError(t, testFS.Stat(tc.path, &stat))
 			if platform.CompilerSupported() {
-				require.Equal(t, atimeNsec, tc.atimeNsec)
+				require.Equal(t, stat.Atim, tc.atimeNsec)
 			} // else only mtimes will return.
-			require.Equal(t, mtimeNsec, tc.mtimeNsec)
+			require.Equal(t, stat.Mtim, tc.mtimeNsec)
 		})
 	}
 }
 
-// requireErrno should only be used for functions that wrap the underlying
-// syscall.Errno.
-func requireErrno(t *testing.T, expected syscall.Errno, actual error) {
-	require.True(t, errors.Is(actual, expected), "expected %v, but was %v", expected, actual)
-}
-
 var (
 	//go:embed testdata/*.txt
 	readerAtFS   embed.FS
@@ -535,98 +555,6 @@ func TestWriterAtOffset_Unsupported(t *testing.T) {
 	require.Equal(t, syscall.ENOSYS, err)
 }
 
-func TestUnwrapOSError(t *testing.T) {
-	tests := []struct {
-		name            string
-		input, expected error
-	}{
-		{
-			name: "nil",
-		},
-		{
-			name:     "LinkError ErrInvalid",
-			input:    &os.LinkError{Err: fs.ErrInvalid},
-			expected: syscall.EINVAL,
-		},
-		{
-			name:     "PathError ErrInvalid",
-			input:    &os.PathError{Err: fs.ErrInvalid},
-			expected: syscall.EINVAL,
-		},
-		{
-			name:     "SyscallError ErrInvalid",
-			input:    &os.SyscallError{Err: fs.ErrInvalid},
-			expected: syscall.EINVAL,
-		},
-		{
-			name:     "PathError ErrPermission",
-			input:    &os.PathError{Err: os.ErrPermission},
-			expected: syscall.EPERM,
-		},
-		{
-			name:     "PathError ErrExist",
-			input:    &os.PathError{Err: os.ErrExist},
-			expected: syscall.EEXIST,
-		},
-		{
-			name:     "PathError syscall.ErrnotExist",
-			input:    &os.PathError{Err: os.ErrNotExist},
-			expected: syscall.ENOENT,
-		},
-		{
-			name:     "PathError ErrClosed",
-			input:    &os.PathError{Err: os.ErrClosed},
-			expected: syscall.EBADF,
-		},
-		{
-			name:     "PathError unknown == syscall.EIO",
-			input:    &os.PathError{Err: errors.New("ice cream")},
-			expected: syscall.EIO,
-		},
-		{
-			name:     "unknown == syscall.EIO",
-			input:    errors.New("ice cream"),
-			expected: syscall.EIO,
-		},
-		{
-			name:     "very wrapped unknown == syscall.EIO",
-			input:    fmt.Errorf("%w", fmt.Errorf("%w", fmt.Errorf("%w", errors.New("ice cream")))),
-			expected: syscall.EIO,
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			errno := UnwrapOSError(tc.input)
-			require.Equal(t, tc.expected, errno)
-		})
-	}
-}
-
-func TestStatPath(t *testing.T) {
-	t.Parallel()
-
-	// Set up the test files
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-
-	testFS := NewDirFS(tmpDir)
-
-	_, err := StatPath(testFS, "cat")
-	require.Equal(t, syscall.ENOENT, err)
-	_, err = StatPath(testFS, "sub/cat")
-	require.Equal(t, syscall.ENOENT, err)
-
-	s, err := StatPath(testFS, "sub/test.txt")
-	require.NoError(t, err)
-	require.False(t, s.IsDir())
-
-	s, err = StatPath(testFS, "sub")
-	require.NoError(t, err)
-	require.True(t, s.IsDir())
-}
-
 // Test_FileSync doesn't guarantee sync works because the operating system may
 // sync anyway. There is no test in Go for os.File Sync, but closest is similar
 // to below. Effectively, this only tests that things don't error.
diff --git a/internal/testing/require/require.go b/internal/testing/require/require.go
index 35755265..b926c11c 100644
--- a/internal/testing/require/require.go
+++ b/internal/testing/require/require.go
@@ -13,6 +13,7 @@ import (
 	"reflect"
 	"runtime"
 	"strings"
+	"syscall"
 	"unicode"
 	"unicode/utf8"
 )
@@ -126,6 +127,19 @@ func Error(t TestingT, err error, formatWithArgs ...interface{}) {
 	}
 }
 
+// EqualErrno should be used for functions that return syscall.Errno or nil.
+func EqualErrno(t TestingT, expected syscall.Errno, err error, formatWithArgs ...interface{}) {
+	if err == nil {
+		fail(t, "expected a syscall.Errno, but was nil", "", formatWithArgs...)
+		return
+	}
+	if se, ok := err.(syscall.Errno); !ok {
+		fail(t, fmt.Sprintf("expected %v to be a syscall.Errno", err), "", formatWithArgs...)
+	} else if se != expected {
+		fail(t, fmt.Sprintf("expected Errno %#[1]v(%[1]s), but was %#[2]v(%[2]s)", expected, err), "", formatWithArgs...)
+	}
+}
+
 // ErrorIs fails if the err is nil or errors.Is fails against the expected.
 //
 //   - formatWithArgs are optional. When the first is a string that contains '%', it is treated like fmt.Sprintf.
diff --git a/internal/testing/require/require_errno_test.go b/internal/testing/require/require_errno_test.go
new file mode 100644
index 00000000..c70da689
--- /dev/null
+++ b/internal/testing/require/require_errno_test.go
@@ -0,0 +1,66 @@
+package require
+
+import (
+	"io"
+	"runtime"
+	"syscall"
+	"testing"
+)
+
+func TestEqualErrno(t *testing.T) {
+	// This specifically chooses ENOENT and EIO as outside windows, they tend
+	// to have the same errno literal and text message.
+	if runtime.GOOS == "windows" {
+		t.Skipf("error literals are different on windows")
+	}
+
+	tests := []struct {
+		name        string
+		require     func(TestingT)
+		expectedLog string
+	}{
+		{
+			name: "EqualErrno passes on equal",
+			require: func(t TestingT) {
+				EqualErrno(t, syscall.ENOENT, syscall.ENOENT)
+			},
+		},
+		{
+			name: "EqualErrno fails on nil",
+			require: func(t TestingT) {
+				EqualErrno(t, syscall.ENOENT, nil)
+			},
+			expectedLog: "expected a syscall.Errno, but was nil",
+		},
+		{
+			name: "EqualErrno fails on not Errno",
+			require: func(t TestingT) {
+				EqualErrno(t, syscall.ENOENT, io.EOF)
+			},
+			expectedLog: `expected EOF to be a syscall.Errno`,
+		},
+		{
+			name: "EqualErrno fails on not equal",
+			require: func(t TestingT) {
+				EqualErrno(t, syscall.ENOENT, syscall.EIO)
+			},
+			expectedLog: `expected Errno 0x2(no such file or directory), but was 0x5(input/output error)`,
+		},
+		{
+			name: "EqualErrno fails on not equal with format",
+			require: func(t TestingT) {
+				EqualErrno(t, syscall.ENOENT, syscall.EIO, "pay me %d", 5)
+			},
+			expectedLog: `expected Errno 0x2(no such file or directory), but was 0x5(input/output error): pay me 5`,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			m := &mockT{t: t}
+			tc.require(m)
+			m.require(tc.expectedLog)
+		})
+	}
+}
diff --git a/internal/wasi_snapshot_preview1/errno_test.go b/internal/wasi_snapshot_preview1/errno_test.go
index a9e90d47..a40c1073 100644
--- a/internal/wasi_snapshot_preview1/errno_test.go
+++ b/internal/wasi_snapshot_preview1/errno_test.go
@@ -3,8 +3,6 @@ package wasi_snapshot_preview1
 import (
 	"syscall"
 	"testing"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
 func TestToErrno(t *testing.T) {
@@ -94,7 +92,7 @@ func TestToErrno(t *testing.T) {
 			expected: ErrnoPerm,
 		},
 		{
-			name:     "syscall.Errno unexpected == ErrnoIo",
+			name:     "syscall.EqualErrno unexpected == ErrnoIo",
 			input:    syscall.Errno(0xfe),
 			expected: ErrnoIo,
 		},
@@ -103,8 +101,9 @@ func TestToErrno(t *testing.T) {
 	for _, tt := range tests {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
-			errno := ToErrno(tc.input)
-			require.Equal(t, tc.expected, errno)
+			if errno := ToErrno(tc.input); errno != tc.expected {
+				t.Fatalf("expected %s but got %s", ErrnoName(tc.expected), ErrnoName(errno))
+			}
 		})
 	}
 }