Repository: tetratelabs/wazero
Pull request #1113 (2023-02-09 17:44:12)

sysfs: consolidates errno coersion and maps EAGAIN and EINTR
This consolidates the sharable code around syscall.Errno conversion from wasi and gojs and backfills test for the consolidated part.

This also unwraps os.SyscallErr and handles EAGAIN and EINTR.


codefromthecrypt: I will sort out the windows stuff which are likely legit
mathetake: glad we had setup stadlib tests :D
mathetake: ```
PS C:\Users\takeshi\wazero> wazero run -hostlogging filesystem -mount=:/ -mount=:/tmp .\moremoremore\main.wasm -- "-test.v"
==> wasi_snapshot_preview1.fd_prestat_get(fd=3)
<== (prestat={pr_name_len=1},errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_dir_name(fd=3)
<== (path=/,errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_get(fd=4)
<== (prestat={pr_name_len=4},errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_dir_name(fd=4)
<== (path=/tmp,errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_get(fd=5)
<== (prestat=,errno=EBADF)
==> wasi_snapshot_preview1.path_filestat_get(fd=4,flags=SYMLINK_FOLLOW,path=not-exist/really-not-exist)
<== (filestat=,errno=ENOTDIR)
=== RUN   TestStatBadDir
--- FAIL: TestStatBadDir (0.00s)
    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
```

reproduced on my windows
mathetake: note to me

```
// TODO delete this file
package main

import (
	"flag"
	"os"
	"testing"

	"github.com/tetratelabs/wazero/internal/testing/require"
)

func Test_tmp(t *testing.T) {
	args := []string{
		"run",
		"-hostlogging=filesystem",
		"-mount=:/",
		"-mount=:/tmp",
		"main.wasm",
		"--",
		"-test.v",
	}
	exitCode := _runMain(t, args)
	require.Equal(t, 0, exitCode)
}

func _runMain(t *testing.T, args []string) int {
	t.Helper()
	oldArgs := os.Args
	t.Cleanup(func() {
		os.Args = oldArgs
	})
	os.Args = append([]string{"wazero"}, args...)

	var exitCode int
	stdOut := os.Stdout
	stdErr := os.Stderr
	var exited bool
	func() {
		defer func() {
			if r := recover(); r != nil {
				exited = true
			}
		}()
		flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
		doMain(stdOut, stdErr, func(code int) {
			exitCode = code
			panic(code)
		})
	}()

	require.True(t, exited)

	return exitCode
}
```

Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index e3ec00d1..1eb7e903 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -1407,7 +1407,7 @@ func pathFilestatGetFn(_ context.Context, mod api.Module, params []uint64) Errno
 	if err != nil {
 		return ToErrno(err)
 	}
-	stat, err := f.Stat()
+	stat, err := sysfs.StatFile(f)
 	if err != nil {
 		return ToErrno(err)
 	}
diff --git a/internal/gojs/errno.go b/internal/gojs/errno.go
index b7d6a707..90fb1d76 100644
--- a/internal/gojs/errno.go
+++ b/internal/gojs/errno.go
@@ -1,10 +1,9 @@
 package gojs
 
 import (
-	"errors"
-	"io/fs"
-	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/sysfs"
 )
 
 // Errno is a (GOARCH=wasm) error, which must match a key in mapJSError.
@@ -22,10 +21,14 @@ func (e *Errno) Error() string {
 // This order match constants from wasi_snapshot_preview1.ErrnoSuccess for
 // easier maintenance.
 var (
+	// ErrnoAgain Resource unavailable, or operation would block.
+	ErrnoAgain = &Errno{"EAGAIN"}
 	// ErrnoBadf Bad file descriptor.
 	ErrnoBadf = &Errno{"EBADF"}
 	// ErrnoExist File exists.
 	ErrnoExist = &Errno{"EEXIST"}
+	// ErrnoIntr Interrupted function.
+	ErrnoIntr = &Errno{"EINTR"}
 	// ErrnoInval Invalid argument.
 	ErrnoInval = &Errno{"EINVAL"}
 	// ErrnoIo I/O error.
@@ -55,37 +58,17 @@ var (
 //
 // This should match wasi_snapshot_preview1.ToErrno for maintenance ease.
 func ToErrno(err error) *Errno {
-	if pe, ok := err.(*os.PathError); ok {
-		err = pe.Unwrap()
-	}
-	if se, ok := err.(syscall.Errno); ok {
-		return errnoFromSyscall(se)
-	}
-	// Below are all the fs.ErrXXX in fs.go. errors.Is is more expensive, so
-	// try it last. Note: Once we have our own file type, we should never see
-	// these.
-	switch {
-	case errors.Is(err, fs.ErrInvalid):
-		return ErrnoInval
-	case errors.Is(err, fs.ErrPermission):
-		return ErrnoPerm
-	case errors.Is(err, fs.ErrExist):
-		return ErrnoExist
-	case errors.Is(err, fs.ErrNotExist):
-		return ErrnoNoent
-	case errors.Is(err, fs.ErrClosed):
-		return ErrnoBadf
-	default:
-		return ErrnoIo
-	}
-}
+	errno := sysfs.UnwrapOSError(err)
 
-func errnoFromSyscall(errno syscall.Errno) *Errno {
 	switch errno {
+	case syscall.EAGAIN:
+		return ErrnoAgain
 	case syscall.EBADF:
 		return ErrnoBadf
 	case syscall.EEXIST:
 		return ErrnoExist
+	case syscall.EINTR:
+		return ErrnoIntr
 	case syscall.EINVAL:
 		return ErrnoInval
 	case syscall.EIO:
diff --git a/internal/platform/open_file_windows.go b/internal/platform/open_file_windows.go
index f19ef9da..a84513ba 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/platform/open_file_windows.go
@@ -1,6 +1,7 @@
 package platform
 
 import (
+	"errors"
 	"io/fs"
 	"os"
 	"syscall"
@@ -33,7 +34,15 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 		return os.NewFile(uintptr(fd), name), nil
 	}
 	// TODO: Set FILE_SHARE_DELETE for directory as well.
-	return os.OpenFile(name, flag, perm)
+	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
+		}
+	}
+	return f, err
 }
 
 // The following is lifted from syscall_windows.go to add support for setting FILE_SHARE_DELETE.
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
index a829b621..bcfb97e1 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/platform/stat_windows.go
@@ -47,7 +47,7 @@ func stat(f fs.File, t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64, nlin
 		// os.Stat does to allow the results on the closed files.
 		// https://github.com/golang/go/blob/go1.20/src/os/stat_windows.go#L86
 		//
-		// TODO: once we have our File/Sta type, this shouldn't be necessary.
+		// TODO: once we have our File/Stat type, this shouldn't be necessary.
 		// But for now, ignore the error to pass the std library test for bad file descriptor.
 		// https://github.com/ziglang/zig/blob/master/lib/std/os/test.zig#L167-L170
 		if err == syscall.Errno(6) {
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index e2e6652b..8d554595 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -187,7 +187,7 @@ func (f *FileEntry) IsDir() bool {
 
 // Stat returns the underlying stat of this file.
 func (f *FileEntry) Stat() (stat fs.FileInfo, err error) {
-	stat, err = f.File.Stat()
+	stat, err = sysfs.StatFile(f.File)
 	if err == nil && stat.IsDir() {
 		f.isDirectory = true
 	}
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index 390c5436..d0962860 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -44,7 +44,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, 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
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 0418594e..0a7b35b8 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -1,6 +1,7 @@
 package sysfs
 
 import (
+	"errors"
 	"io/fs"
 	"os"
 	"syscall"
@@ -44,7 +45,7 @@ func (d *dirFS) Open(name string) (fs.File, error) {
 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)
+		return nil, UnwrapOSError(err)
 	}
 	return maybeWrapFile(f, d, name, flag, perm), nil
 }
@@ -52,14 +53,17 @@ func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, erro
 // Mkdir implements FS.Mkdir
 func (d *dirFS) Mkdir(name string, perm fs.FileMode) error {
 	err := os.Mkdir(d.join(name), perm)
-	err = unwrapOSError(err)
-	return adjustMkdirError(err)
+	if errors.Is(err, syscall.ENOTDIR) {
+		return syscall.ENOENT
+	}
+	return UnwrapOSError(err)
 }
 
 // Rename implements FS.Rename
 func (d *dirFS) Rename(from, to string) error {
 	from, to = d.join(from), d.join(to)
-	return platform.Rename(from, to)
+	err := platform.Rename(from, to)
+	return UnwrapOSError(err)
 }
 
 // Readlink implements FS.Readlink
@@ -68,7 +72,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 = UnwrapOSError(err)
 		return
 	}
 
@@ -83,47 +87,50 @@ func (d *dirFS) Readlink(path string, buf []byte) (n int, err error) {
 }
 
 // Link implements FS.Link.
-func (d *dirFS) Link(oldName, newName string) (err error) {
-	err = os.Link(d.join(oldName), d.join(newName))
-	err = unwrapOSError(err)
-	return
+func (d *dirFS) Link(oldName, newName string) error {
+	err := os.Link(d.join(oldName), d.join(newName))
+	return 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)
 }
 
 // Unlink implements FS.Unlink
-func (d *dirFS) Unlink(name string) error {
-	err := syscall.Unlink(d.join(name))
-	return adjustUnlinkError(err)
+func (d *dirFS) Unlink(name string) (err error) {
+	err = syscall.Unlink(d.join(name))
+	if err = UnwrapOSError(err); err == syscall.EPERM {
+		err = syscall.EISDIR
+	}
+	return
 }
 
 // Symlink implements FS.Symlink
-func (d *dirFS) Symlink(oldName, link string) error {
+func (d *dirFS) Symlink(oldName, link string) (err error) {
 	// Note: do not resolve `oldName` relative to this dirFS. The link result is always resolved
 	// when dereference the `link` on its usage (e.g. readlink, read, etc).
 	// 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))
-	err = unwrapOSError(err)
-	return err
+	err = os.Symlink(oldName, d.join(link))
+	return UnwrapOSError(err)
 }
 
 // Utimes implements FS.Utimes
 func (d *dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
-	return syscall.UtimesNano(d.join(name), []syscall.Timespec{
+	err := syscall.UtimesNano(d.join(name), []syscall.Timespec{
 		syscall.NsecToTimespec(atimeNsec),
 		syscall.NsecToTimespec(mtimeNsec),
 	})
+	return 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)
+	err = UnwrapOSError(err)
 	return adjustTruncateError(err)
 }
 
diff --git a/internal/sysfs/syscall.go b/internal/sysfs/syscall.go
index a076f464..a37da970 100644
--- a/internal/sysfs/syscall.go
+++ b/internal/sysfs/syscall.go
@@ -4,10 +4,9 @@ package sysfs
 
 import (
 	"io/fs"
-	"syscall"
 )
 
-func adjustMkdirError(err error) error {
+func adjustErrno(err error) error {
 	return err
 }
 
@@ -19,13 +18,6 @@ func adjustTruncateError(err error) error {
 	return err
 }
 
-func adjustUnlinkError(err error) error {
-	if err == syscall.EPERM {
-		return syscall.EISDIR
-	}
-	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 2831388a..35685a91 100644
--- a/internal/sysfs/syscall_windows.go
+++ b/internal/sysfs/syscall_windows.go
@@ -1,9 +1,7 @@
 package sysfs
 
 import (
-	"errors"
 	"io/fs"
-	"os"
 	"syscall"
 )
 
@@ -32,11 +30,24 @@ const (
 	// 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)
 )
 
-func adjustMkdirError(err error) error {
-	if err == ERROR_ALREADY_EXISTS {
+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
 }
@@ -45,8 +56,6 @@ func adjustRmdirError(err error) error {
 	switch err {
 	case ERROR_DIRECTORY:
 		return syscall.ENOTDIR
-	case ERROR_DIR_NOT_EMPTY:
-		return syscall.ENOTEMPTY
 	}
 	return err
 }
@@ -58,43 +67,6 @@ func adjustTruncateError(err error) error {
 	return err
 }
 
-func adjustUnlinkError(err error) error {
-	if err == ERROR_ACCESS_DENIED {
-		return syscall.EISDIR
-	}
-	return err
-}
-
-// rename uses os.Rename as `windows.Rename` is internal in Go's source tree.
-func rename(old, new string) (err error) {
-	if err = os.Rename(old, new); err == nil {
-		return
-	}
-	err = errors.Unwrap(err) // unwrap the link error
-	if err == ERROR_ACCESS_DENIED {
-		var newIsDir bool
-		if stat, statErr := os.Stat(new); statErr == nil && stat.IsDir() {
-			newIsDir = true
-		}
-
-		var oldIsDir bool
-		if stat, statErr := os.Stat(old); statErr == nil && stat.IsDir() {
-			oldIsDir = true
-		}
-
-		if oldIsDir && newIsDir {
-			// Windows doesn't let you overwrite a directory. If we aim to
-			// allow this, we'll have to delete here and retry.
-			return syscall.EINVAL
-		} else if newIsDir {
-			err = syscall.EISDIR
-		} else { // use a mappable code
-			err = syscall.EPERM
-		}
-	}
-	return
-}
-
 // 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.
 //
@@ -144,15 +116,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 {
-		switch pe.Err {
-		case ERROR_INVALID_HANDLE:
-			pe.Err = syscall.EBADF
-		case ERROR_ACCESS_DENIED:
+		if pe.Err = 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() {
 				pe.Err = syscall.EBADF
-			} else {
-				pe.Err = syscall.EPERM
 			}
 		}
 	}
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index 724ba1d2..970d63b8 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -6,7 +6,6 @@
 package sysfs
 
 import (
-	"errors"
 	"io"
 	"io/fs"
 	"os"
@@ -77,9 +76,10 @@ type FS interface {
 	// The following errors are expected:
 	//   - syscall.EINVAL: `from` or `to` is invalid.
 	//   - syscall.ENOENT: `from` or `to` don't exist.
-	//   - syscall.ENOTDIR: `from` is a directory and `to` exists, but is a file.
-	//   - syscall.EISDIR: `from` is a file and `to` exists, but is a directory.
-	//   - syscall.ENOTEMPTY: `both from` and `to` are existing directory, but `to` is not empty.
+	//   - syscall.ENOTDIR: `from` is a directory and `to` exists as a file.
+	//   - syscall.EISDIR: `from` is a file and `to` exists as a directory.
+	//   - syscall.ENOTEMPTY: `both from` and `to` are existing directory, but
+	//    `to` is not empty.
 	//
 	// # Notes
 	//
@@ -105,6 +105,8 @@ type FS interface {
 	// Unlink is similar to syscall.Unlink, except the path is relative to this
 	// file system.
 	//
+	// # Errors
+	//
 	// The following errors are expected:
 	//   - syscall.EINVAL: `path` is invalid.
 	//   - syscall.ENOENT: `path` doesn't exist.
@@ -112,7 +114,8 @@ type FS interface {
 	Unlink(path string) error
 
 	// Link is similar to syscall.Link, except the path is relative to this
-	// file system. This creates "hard" link from oldPath to newPath, in contrast to soft link as in Symlink.
+	// file system. This creates "hard" link from oldPath to newPath, in
+	// contrast to soft link as in Symlink.
 	//
 	// # Errors
 	//
@@ -122,8 +125,8 @@ type FS interface {
 	//   - syscall.EISDIR: `newPath` exists, but is a directory.
 	Link(oldPath, newPath string) error
 
-	// Symlink is similar to syscall.Symlink, except the `oldPath` is relative to this
-	// file system. This creates "soft" link from oldPath to newPath,
+	// Symlink is similar to syscall.Symlink, except the `oldPath` is relative
+	// to this file system. This creates "soft" link from oldPath to newPath,
 	// in contrast to hard link as in Link.
 	//
 	// # Errors
@@ -132,25 +135,30 @@ type FS interface {
 	//   - syscall.EPERM: `oldPath` or `newPath` is invalid.
 	//   - syscall.EEXIST: `newPath` exists.
 	//
-	// # Note
+	// # Notes
 	//
-	//   - Only `newPath` is relative to this file system and `oldPath` is kept as-is.
-	//     That is because the link is only resolved relative to the directory when
-	//     dereferencing it (e.g. ReadLink).
+	//   - Only `newPath` is relative to this file system and `oldPath` is kept
+	//     as-is. That is because the link is only resolved relative to the
+	//     directory when dereferencing it (e.g. ReadLink).
 	//     See https://github.com/bytecodealliance/cap-std/blob/v1.0.4/cap-std/src/fs/dir.rs#L404-L409
 	//     for how others implement this.
+	//   - Symlinks in Windows requires `SeCreateSymbolicLinkPrivilege`.
+	//     Otherwise, syscall.EPERM results.
+	//     See https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
 	Symlink(oldPath, linkName string) error
 
-	// Readlink is similar to syscall.Readlink, except the path is relative to this
-	// file system.
+	// Readlink is similar to syscall.Readlink, except the path is relative to
+	// this file system.
+	//
 	// # Errors
 	//
 	// The following errors are expected:
 	//   - syscall.EINVAL: `path` is invalid.
 	//
 	// # Notes
-	//   - On windows, its path separator is different from other platforms, but to provide consistent result to Wasm,
-	//     the implementation is supposed to sanitize the result with the regular "/" separator.
+	//   - On Windows, the path separator is different from other platforms,
+	//     but to provide consistent results to Wasm, this normalizes to a "/"
+	//     separator.
 	Readlink(path string, buf []byte) (n int, err error)
 
 	// Truncate is similar to syscall.Truncate, except the path is relative to
@@ -180,15 +188,23 @@ type FS interface {
 	Utimes(path string, atimeNsec, mtimeNsec int64) error
 }
 
-// StatPath is a convenience that calls FS.OpenFile until there is a stat
-// method.
-func StatPath(fs FS, path string) (fs.FileInfo, 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 f.Stat()
+	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.
@@ -326,13 +342,18 @@ func (r *writerAtOffset) Write(p []byte) (int, error) {
 	return n, err
 }
 
-func unwrapOSError(err error) error {
-	if pe, ok := err.(*fs.PathError); ok {
-		err = pe.Err
-	} else if le, ok := err.(*os.LinkError); ok {
-		err = le.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:
@@ -345,20 +366,21 @@ func unwrapOSError(err error) error {
 		return syscall.ENOENT
 	case fs.ErrClosed:
 		return syscall.EBADF
-	case os.ErrInvalid:
-		return syscall.EINVAL
-	case os.ErrExist:
-		return syscall.EEXIST
-	default:
-		if errors.Is(err, os.ErrExist) {
-			return syscall.EEXIST
-		}
-		if errors.Is(err, os.ErrNotExist) {
-			return syscall.ENOENT
-		}
-		if errors.Is(err, os.ErrPermission) {
-			return syscall.EPERM
-		}
+	}
+	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/wasi_snapshot_preview1/errno.go b/internal/wasi_snapshot_preview1/errno.go
index 3855c86a..838d96be 100644
--- a/internal/wasi_snapshot_preview1/errno.go
+++ b/internal/wasi_snapshot_preview1/errno.go
@@ -1,11 +1,10 @@
 package wasi_snapshot_preview1
 
 import (
-	"errors"
 	"fmt"
-	"io/fs"
-	"os"
 	"syscall"
+
+	"github.com/tetratelabs/wazero/internal/sysfs"
 )
 
 // Errno is neither uint16 nor an alias for parity with wasm.ValueType.
@@ -267,38 +266,18 @@ var errnoToString = [...]string{
 // error codes. For example, wasi-filesystem and GOOS=js don't map to these
 // Errno.
 func ToErrno(err error) Errno {
-	if pe, ok := err.(*os.PathError); ok {
-		err = pe.Unwrap()
-	}
-	if se, ok := err.(syscall.Errno); ok {
-		return errnoFromSyscall(se)
-	}
-	// Below are all the fs.ErrXXX in fs.go. errors.Is is more expensive, so
-	// try it last. Note: Once we have our own file type, we should never see
-	// these.
-	switch {
-	case errors.Is(err, fs.ErrInvalid):
-		return ErrnoInval
-	case errors.Is(err, fs.ErrPermission):
-		return ErrnoPerm
-	case errors.Is(err, fs.ErrExist):
-		return ErrnoExist
-	case errors.Is(err, fs.ErrNotExist):
-		return ErrnoNoent
-	case errors.Is(err, fs.ErrClosed):
-		return ErrnoBadf
-	default:
-		return ErrnoIo
-	}
-}
+	errno := sysfs.UnwrapOSError(err)
 
-func errnoFromSyscall(errno syscall.Errno) Errno {
 	// The below Errno have references in existing WASI code.
 	switch errno {
+	case syscall.EAGAIN:
+		return ErrnoAgain
 	case syscall.EBADF:
 		return ErrnoBadf
 	case syscall.EEXIST:
 		return ErrnoExist
+	case syscall.EINTR:
+		return ErrnoIntr
 	case syscall.EINVAL:
 		return ErrnoInval
 	case syscall.EIO:


Test patch:
diff --git a/internal/gojs/errno_test.go b/internal/gojs/errno_test.go
index fe242434..48f66efa 100644
--- a/internal/gojs/errno_test.go
+++ b/internal/gojs/errno_test.go
@@ -1,10 +1,6 @@
 package gojs
 
 import (
-	"errors"
-	"fmt"
-	"io/fs"
-	"os"
 	"syscall"
 	"testing"
 
@@ -17,6 +13,11 @@ func TestToErrno(t *testing.T) {
 		input    error
 		expected *Errno
 	}{
+		{
+			name:     "syscall.EAGAIN",
+			input:    syscall.EAGAIN,
+			expected: ErrnoAgain,
+		},
 		{
 			name:     "syscall.EBADF",
 			input:    syscall.EBADF,
@@ -27,6 +28,11 @@ func TestToErrno(t *testing.T) {
 			input:    syscall.EEXIST,
 			expected: ErrnoExist,
 		},
+		{
+			name:     "syscall.EINTR",
+			input:    syscall.EINTR,
+			expected: ErrnoIntr,
+		},
 		{
 			name:     "syscall.EINVAL",
 			input:    syscall.EINVAL,
@@ -87,46 +93,6 @@ func TestToErrno(t *testing.T) {
 			input:    syscall.Errno(0xfe),
 			expected: ErrnoIo,
 		},
-		{
-			name:     "PathError ErrInvalid",
-			input:    &os.PathError{Err: fs.ErrInvalid},
-			expected: ErrnoInval,
-		},
-		{
-			name:     "PathError ErrPermission",
-			input:    &os.PathError{Err: fs.ErrPermission},
-			expected: ErrnoPerm,
-		},
-		{
-			name:     "PathError ErrExist",
-			input:    &os.PathError{Err: fs.ErrExist},
-			expected: ErrnoExist,
-		},
-		{
-			name:     "PathError ErrNotExist",
-			input:    &os.PathError{Err: fs.ErrNotExist},
-			expected: ErrnoNoent,
-		},
-		{
-			name:     "PathError ErrClosed",
-			input:    &os.PathError{Err: fs.ErrClosed},
-			expected: ErrnoBadf,
-		},
-		{
-			name:     "PathError unknown == ErrnoIo",
-			input:    &os.PathError{Err: errors.New("ice cream")},
-			expected: ErrnoIo,
-		},
-		{
-			name:     "unknown == ErrnoIo",
-			input:    errors.New("ice cream"),
-			expected: ErrnoIo,
-		},
-		{
-			name:     "very wrapped unknown == ErrnoIo",
-			input:    fmt.Errorf("%w", fmt.Errorf("%w", fmt.Errorf("%w", errors.New("ice cream")))),
-			expected: ErrnoIo,
-		},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/platform/open_file_test.go b/internal/platform/open_file_test.go
new file mode 100644
index 00000000..1f7a4e77
--- /dev/null
+++ b/internal/platform/open_file_test.go
@@ -0,0 +1,30 @@
+package platform
+
+import (
+	"os"
+	"path"
+	"syscall"
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestOpenFile_Errors(t *testing.T) {
+	tmp := t.TempDir()
+
+	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)
+	})
+
+	// This is the same as https://github.com/ziglang/zig/blob/d24ebf1d12cf66665b52136a2807f97ff021d78d/lib/std/os/test.zig#L105-L112
+	t.Run("try creating on existing file must be EEXIST", func(t *testing.T) {
+		filepath := path.Join(tmp, "file.txt")
+		f, err := OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666)
+		defer require.NoError(t, f.Close())
+		require.NoError(t, err)
+
+		_, err = OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666)
+		require.ErrorIs(t, err, syscall.EEXIST)
+	})
+}
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 2079e681..888f2404 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -71,6 +71,11 @@ func TestDirFS_MkDir(t *testing.T) {
 		err := testFS.Mkdir(name, fs.ModeDir)
 		require.Equal(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)
+	})
 }
 
 func TestDirFS_Rename(t *testing.T) {
@@ -577,8 +582,8 @@ func TestDirFS_Readlink(t *testing.T) {
 
 	buf := make([]byte, 200)
 	for _, tl := range testLinks {
-		err := os.Symlink(pathutil.Join(tl.old), pathutil.Join(tmpDir, tl.dst))
-		require.NoError(t, err)
+		err := testFS.Symlink(tl.old, tl.dst) // not os.Symlink for windows compat
+		require.NoError(t, err, "%v", tl)
 
 		n, err := testFS.Readlink(tl.dst, buf)
 		require.NoError(t, err)
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 5c9f7155..4e9cf6f7 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -4,6 +4,7 @@ import (
 	"embed"
 	_ "embed"
 	"errors"
+	"fmt"
 	"io"
 	"io/fs"
 	"os"
@@ -12,9 +13,10 @@ import (
 	"sort"
 	"syscall"
 	"testing"
-	"testing/fstest"
+	gofstest "testing/fstest"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -254,7 +256,7 @@ func TestReaderAtOffset(t *testing.T) {
 	bytes, err := io.ReadAll(d)
 	require.NoError(t, err)
 
-	mapFS := fstest.MapFS{readerAtFile: &fstest.MapFile{Data: bytes}}
+	mapFS := gofstest.MapFS{readerAtFile: &gofstest.MapFile{Data: bytes}}
 
 	// Write a file as can't open "testdata" in scratch tests because they
 	// can't read the original filesystem.
@@ -326,7 +328,7 @@ func TestReaderAtOffset_empty(t *testing.T) {
 	require.NoError(t, err)
 	defer d.Close()
 
-	mapFS := fstest.MapFS{emptyFile: &fstest.MapFile{}}
+	mapFS := gofstest.MapFS{emptyFile: &gofstest.MapFile{}}
 
 	// Write a file as can't open "testdata" in scratch tests because they
 	// can't read the original filesystem.
@@ -509,3 +511,95 @@ func TestWriterAtOffset_Unsupported(t *testing.T) {
 	_, err = ra.Write(buf)
 	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())
+}
diff --git a/internal/wasi_snapshot_preview1/errno_test.go b/internal/wasi_snapshot_preview1/errno_test.go
index 88077ea2..808fc07f 100644
--- a/internal/wasi_snapshot_preview1/errno_test.go
+++ b/internal/wasi_snapshot_preview1/errno_test.go
@@ -1,10 +1,6 @@
 package wasi_snapshot_preview1
 
 import (
-	"errors"
-	"fmt"
-	"io/fs"
-	"os"
 	"syscall"
 	"testing"
 
@@ -17,6 +13,11 @@ func TestToErrno(t *testing.T) {
 		input    error
 		expected Errno
 	}{
+		{
+			name:     "syscall.EAGAIN",
+			input:    syscall.EAGAIN,
+			expected: ErrnoAgain,
+		},
 		{
 			name:     "syscall.EBADF",
 			input:    syscall.EBADF,
@@ -27,6 +28,11 @@ func TestToErrno(t *testing.T) {
 			input:    syscall.EEXIST,
 			expected: ErrnoExist,
 		},
+		{
+			name:     "syscall.EINTR",
+			input:    syscall.EINTR,
+			expected: ErrnoIntr,
+		},
 		{
 			name:     "syscall.EINVAL",
 			input:    syscall.EINVAL,
@@ -87,46 +93,6 @@ func TestToErrno(t *testing.T) {
 			input:    syscall.Errno(0xfe),
 			expected: ErrnoIo,
 		},
-		{
-			name:     "PathError ErrInvalid",
-			input:    &os.PathError{Err: fs.ErrInvalid},
-			expected: ErrnoInval,
-		},
-		{
-			name:     "PathError ErrPermission",
-			input:    &os.PathError{Err: fs.ErrPermission},
-			expected: ErrnoPerm,
-		},
-		{
-			name:     "PathError ErrExist",
-			input:    &os.PathError{Err: fs.ErrExist},
-			expected: ErrnoExist,
-		},
-		{
-			name:     "PathError ErrNotExist",
-			input:    &os.PathError{Err: fs.ErrNotExist},
-			expected: ErrnoNoent,
-		},
-		{
-			name:     "PathError ErrClosed",
-			input:    &os.PathError{Err: fs.ErrClosed},
-			expected: ErrnoBadf,
-		},
-		{
-			name:     "PathError unknown == ErrnoIo",
-			input:    &os.PathError{Err: errors.New("ice cream")},
-			expected: ErrnoIo,
-		},
-		{
-			name:     "unknown == ErrnoIo",
-			input:    errors.New("ice cream"),
-			expected: ErrnoIo,
-		},
-		{
-			name:     "very wrapped unknown == ErrnoIo",
-			input:    fmt.Errorf("%w", fmt.Errorf("%w", fmt.Errorf("%w", errors.New("ice cream")))),
-			expected: ErrnoIo,
-		},
 	}
 
 	for _, tt := range tests {