Repository: tetratelabs/wazero
Pull request #1174 (2023-02-27 23:37:58)

sysfs: drops os.File special casing for fs.FS to pass wasi-testsuite
This adds a wazero adapter which passes wasi-testsuite 100pct on darwin, linux and windows. While the main change was adding inodes to the wasi `fd_readdir` dirents, there was a lot of incidental work needed.

Most of the work was troubleshooting in nature, around windows specifically, but also wrapping of files. This backfills a lot of tests and reworked how wrapping works, particularly around windows.

To accommodate this, we drop os.File special casing except for sysfs.DirFS.

### Why don't we attempt to pass wasi-testsuite on user-defined `fs.FS`?

While most cases work fine on an `os.File` based implementation, we won't promise wasi-testsuite compatibility on user defined wrappers of `os.DirFS`. The only option for real systems is to use our `sysfs.FS`.

There are a lot of areas where windows behaves differently, despite the `os.File` abstraction. This goes well beyond file locking concerns (e.g. `EBUSY` errors on open files). For example, errors like `ACCESS_DENIED` aren't properly mapped to `EPERM`. There are trickier parts too. `FileInfo.Sys()` doesn't return enough information to build inodes needed for WASI. To rebuild them requires the full path to the underlying file, not just its directory name, and there's no way for us to get that information. At one point we tried, but in practice things became tangled and functionality such as read-only wrappers became untenable. Finally, there are version-specific behaviors which are difficult to maintain even in our own code. For example, go 1.20 opens files in a different way than versions before it.


codefromthecrypt: will start on windows as soon as I see if the testsuite agrees with me on linux and darwin ;)
codefromthecrypt: ok I've organized pretty much what works and doesn't work. I'll work on the windows side when my laptop stops windows update ;)
codefromthecrypt: I'll raise an issue post merge about that composite fs doesn't return inodes properly. this really can't happen until we have our own file type as trying now is really messy
codefromthecrypt: > huge milestone! (last piece?)


So, yeah it is a great milestone to be maybe the only runtime that passes wasi-testsuite on all platforms (not sure if others do, as they aren't testing windows).

This is the last for this release candidate. There are a couple corner cases we haven't finished for GOOS=js which once complete reveals the entire feature surface needed to make our file type. These will continue after the cut and maybe our internal structure can be stable by 1.0.

Patch:
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 155783e4..111806db 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -279,10 +279,9 @@ jobs:
       - name: Checkout wasi-testsuite
         uses: actions/checkout@v3
         with:
-          # TODO: after https://github.com/WebAssembly/wasi-testsuite/pull/55 is merged and synced,
-          # Use the upstream repo and point to prod/testsuite-base branch (or specific commit in there).
-          repository: tetratelabs/wasi-testsuite
-          ref: wazero-python-adapter
+          repository: WebAssembly/wasi-testsuite
+          ref: prod/testsuite-base
+          path: wasi-testsuite
 
       - name: Initialize Python environment
         uses: actions/setup-python@v4
@@ -291,14 +290,15 @@ jobs:
           cache: pip
 
       - name: Install dependencies
-        working-directory: test-runner
+        working-directory: wasi-testsuite/test-runner
         run: |
           python3 -m pip install -r requirements.txt
 
       - name: Run all wasi-testsuite
+        working-directory: wasi-testsuite
         run: |
           python3 test-runner/wasi_test_runner.py \
             -t ./tests/assemblyscript/testsuite/ \
             ./tests/c/testsuite/ \
             ./tests/rust/testsuite/ \
-            -r ./adapters/wazero.py
+            -r ../.github/wasi_testsuite_adapter.py
diff --git a/RATIONALE.md b/RATIONALE.md
index 1cb45515..b6bbec11 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -465,6 +465,24 @@ Unfortunately, (WASI Snapshot Preview 1)[https://github.com/WebAssembly/WASI/blo
 This section describes how Wazero interprets and implements the semantics of several WASI APIs that may be interpreted differently by different wasm runtimes.
 Those APIs may affect the portability of a WASI application.
 
+### Why don't we attempt to pass wasi-testsuite on user-defined `fs.FS`?
+
+While most cases work fine on an `os.File` based implementation, we won't
+promise wasi-testsuite compatibility on user defined wrappers of `os.DirFS`.
+The only option for real systems is to use our `sysfs.FS`.
+
+There are a lot of areas where windows behaves differently, despite the
+`os.File` abstraction. This goes well beyond file locking concerns (e.g.
+`EBUSY` errors on open files). For example, errors like `ACCESS_DENIED` aren't
+properly mapped to `EPERM`. There are trickier parts too. `FileInfo.Sys()`
+doesn't return enough information to build inodes needed for WASI. To rebuild
+them requires the full path to the underlying file, not just its directory
+name, and there's no way for us to get that information. At one point we tried,
+but in practice things became tangled and functionality such as read-only
+wrappers became untenable. Finally, there are version-specific behaviors which
+are difficult to maintain even in our own code. For example, go 1.20 opens
+files in a different way than versions before it.
+
 ### Why aren't WASI rules enforced?
 
 The [snapshot-01](https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md) version of WASI has a
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index c708d665..5767b1e2 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -851,13 +851,14 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) Errno {
 
 	// Check if we have maxDirEntries, and read more from the FS as needed.
 	if entryCount := len(dirents); entryCount < maxDirEntries {
+		// Note: platform.Readdir does not return io.EOF as it is
+		// inconsistently returned (e.g. darwin does, but linux doesn't).
 		l, err := platform.Readdir(rd, maxDirEntries-entryCount)
 		if errno = ToErrno(err); errno != ErrnoSuccess {
 			return errno
 		}
 
-		// Zero length read is possible on an empty directory or on io.EOF,
-		// which coerces to nil in WASI because there's no Errno for it.
+		// Zero length read is possible on an empty or exhausted directory.
 		if len(l) > 0 {
 			dir.CountRead += uint64(len(l))
 			dirents = append(dirents, l...)
@@ -893,18 +894,26 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	return ErrnoSuccess
 }
 
-// dotDirents returns "." and "..", where "." has a real stat because
-// wasi-testsuite does inode validation.
+// dotDirents returns "." and "..", where "." because wasi-testsuite does inode
+// validation.
 func dotDirents(f *sys.FileEntry) ([]*platform.Dirent, error) {
-	ino, ft, err := f.CachedStat()
+	dotIno, ft, err := f.CachedStat()
 	if err != nil {
 		return nil, err
 	} else if ft.Type() != fs.ModeDir {
 		return nil, syscall.ENOTDIR
 	}
+	dotDotIno := uint64(0)
+	if !f.IsPreopen && f.Name != "." {
+		var st platform.Stat_t
+		if err = f.FS.Stat(pathutil.Dir(f.Name), &st); err != nil {
+			return nil, err
+		}
+		dotDotIno = st.Ino
+	}
 	return []*platform.Dirent{
-		{Name: ".", Ino: ino, Type: fs.ModeDir},
-		{Name: "..", Type: fs.ModeDir},
+		{Name: ".", Ino: dotIno, Type: fs.ModeDir},
+		{Name: "..", Ino: dotDotIno, Type: fs.ModeDir},
 	}, nil
 }
 
@@ -1022,7 +1031,7 @@ func writeDirents(
 		e := dirents[i]
 		nameLen := uint32(len(e.Name))
 
-		writeDirent(buf[pos:], d_next, nameLen, e.IsDir())
+		writeDirent(buf[pos:], d_next, e.Ino, nameLen, e.IsDir())
 		pos += DirentSize
 
 		copy(buf[pos:], e.Name)
@@ -1037,16 +1046,16 @@ func writeDirents(
 	// Write a dirent without its name
 	dirent := make([]byte, DirentSize)
 	e := dirents[i]
-	writeDirent(dirent, d_next, uint32(len(e.Name)), e.IsDir())
+	writeDirent(dirent, d_next, e.Ino, uint32(len(e.Name)), e.IsDir())
 
 	// Potentially truncate it
 	copy(buf[pos:], dirent)
 }
 
 // writeDirent writes DirentSize bytes
-func writeDirent(buf []byte, dNext uint64, dNamlen uint32, dType bool) {
+func writeDirent(buf []byte, dNext uint64, ino uint64, dNamlen uint32, dType bool) {
 	le.PutUint64(buf, dNext)        // d_next
-	le.PutUint64(buf[8:], 0)        // no d_ino
+	le.PutUint64(buf[8:], ino)      // d_ino
 	le.PutUint32(buf[16:], dNamlen) // d_namlen
 
 	filetype := FILETYPE_REGULAR_FILE
diff --git a/internal/platform/dir.go b/internal/platform/dir.go
index d6d58ec6..d9c338e3 100644
--- a/internal/platform/dir.go
+++ b/internal/platform/dir.go
@@ -1,29 +1,39 @@
 package platform
 
 import (
+	"fmt"
 	"io"
 	"io/fs"
 	"syscall"
 )
 
-// readdirnamesFile is implemented by os.File in dir.go
-// Note: we use this until we finalize our own FD-scoped file.
-type readdirnamesFile interface {
-	Readdirnames(n int) (names []string, err error)
-}
-
-// Readdirnames is like the function on os.File, but for fs.File. This returns
-// syscall.ENOTDIR if not a directory or syscall.EIO if closed or read
-// redundantly.
+// Readdirnames reads the names of the directory associated with file and
+// returns a slice of up to n strings in an arbitrary order. This is a stateful
+// function, so subsequent calls return any next values.
+//
+// If n > 0, Readdirnames returns at most n entries or an error.
+// If n <= 0, Readdirnames returns all remaining entries or an error.
+//
+// # Errors
+//
+// If the error is non-nil, it is a syscall.Errno. io.EOF is not returned
+// because implementations return that value inconsistently.
+//
+// For portability reasons, no error is returned when the file is closed or
+// removed while open.
+// See https://github.com/ziglang/zig/blob/0.10.1/lib/std/fs.zig#L635-L637
 func Readdirnames(f fs.File, n int) (names []string, err error) {
 	switch f := f.(type) {
 	case readdirnamesFile:
 		names, err = f.Readdirnames(n)
+		if err = adjustReaddirErr(err); err != nil {
+			return
+		}
 	case fs.ReadDirFile:
 		var entries []fs.DirEntry
 		entries, err = f.ReadDir(n)
-		if err != nil {
-			break
+		if err = adjustReaddirErr(err); err != nil {
+			return
 		}
 		names = make([]string, 0, len(entries))
 		for _, e := range entries {
@@ -55,6 +65,10 @@ type Dirent struct {
 	Type fs.FileMode
 }
 
+func (d *Dirent) String() string {
+	return fmt.Sprintf("name=%s, type=%v, ino=%d", d.Name, d.Type, d.Ino)
+}
+
 // IsDir returns true if the Type is fs.ModeDir.
 func (d *Dirent) IsDir() bool {
 	return d.Type == fs.ModeDir
@@ -69,9 +83,8 @@ func (d *Dirent) IsDir() bool {
 //
 // # Errors
 //
-// Any value of n can result in io.EOF, which is the result of calling this on
-// an already exhausted directory. Otherwise, if the error is non-nil, it is a
-// syscall.Errno.
+// If the error is non-nil, it is a syscall.Errno. io.EOF is not returned
+// because implementations return that value inconsistently.
 //
 // For portability reasons, no error is returned when the file is closed or
 // removed while open.
@@ -80,18 +93,26 @@ func Readdir(f fs.File, n int) (dirents []*Dirent, err error) {
 	// ^^ case format is to match POSIX and similar to os.File.Readdir
 
 	switch f := f.(type) {
+	case readdirFile:
+		var fis []fs.FileInfo
+		fis, err = f.Readdir(n)
+		if err = adjustReaddirErr(err); err != nil {
+			return
+		}
+		dirents = make([]*Dirent, 0, len(fis))
+
+		// linux/darwin won't have to fan out to lstat, but windows will.
+		var ino uint64
+		for _, t := range fis {
+			if ino, err = inoFromFileInfo(f, t); err != nil {
+				return
+			}
+			dirents = append(dirents, &Dirent{Name: t.Name(), Ino: ino, Type: t.Mode().Type()})
+		}
 	case fs.ReadDirFile:
 		var entries []fs.DirEntry
 		entries, err = f.ReadDir(n)
-		if err == io.EOF { // retain io.EOF for the caller
-		} else if err = UnwrapOSError(err); err != nil {
-			// Ignore errors when the file was closed or removed.
-			switch err {
-			case syscall.EIO, syscall.EBADF: // closed while open
-				err = nil
-			case syscall.ENOENT: // Linux error when removed while open
-				err = nil
-			}
+		if err = adjustReaddirErr(err); err != nil {
 			return
 		}
 		dirents = make([]*Dirent, 0, len(entries))
@@ -104,3 +125,19 @@ func Readdir(f fs.File, n int) (dirents []*Dirent, err error) {
 	}
 	return
 }
+
+func adjustReaddirErr(err error) error {
+	if err == io.EOF {
+		return nil // e.g. Readdir on darwin returns io.EOF, but linux doesn't.
+	} else if err = UnwrapOSError(err); err != nil {
+		// Ignore errors when the file was closed or removed.
+		switch err {
+		case syscall.EIO, syscall.EBADF: // closed while open
+			return nil
+		case syscall.ENOENT: // Linux error when removed while open
+			return nil
+		}
+		return err
+	}
+	return err
+}
diff --git a/internal/platform/file.go b/internal/platform/file.go
new file mode 100644
index 00000000..c247854c
--- /dev/null
+++ b/internal/platform/file.go
@@ -0,0 +1,50 @@
+package platform
+
+import (
+	"io"
+	"io/fs"
+)
+
+// ReadFile declares all read interfaces defined on os.File used by wazero.
+type ReadFile interface {
+	fdFile // for the number of links.
+	readdirnamesFile
+	readdirFile
+	fs.ReadDirFile
+	io.ReaderAt // for pread
+	io.Seeker   // fallback for ReaderAt for embed:fs
+}
+
+// File declares all interfaces defined on os.File used by wazero.
+type File interface {
+	ReadFile
+	io.Writer
+	io.WriterAt // for pwrite
+	chmodFile
+	syncFile
+	truncateFile
+}
+
+// The following interfaces are used until we finalize our own FD-scoped file.
+type (
+	// PathFile is implemented on files that retain the path to their pre-open.
+	PathFile interface {
+		Path() string
+	}
+	// fdFile is implemented by os.File in file_unix.go and file_windows.go
+	fdFile interface{ Fd() (fd uintptr) }
+	// readdirnamesFile is implemented by os.File in dir.go
+	readdirnamesFile interface {
+		Readdirnames(n int) (names []string, err error)
+	}
+	// readdirFile is implemented by os.File in dir.go
+	readdirFile interface {
+		Readdir(n int) ([]fs.FileInfo, error)
+	}
+	// chmodFile is implemented by os.File in file_posix.go
+	chmodFile interface{ Chmod(fs.FileMode) error }
+	// syncFile is implemented by os.File in file_posix.go
+	syncFile interface{ Sync() error }
+	// truncateFile is implemented by os.File in file_posix.go
+	truncateFile interface{ Truncate(size int64) error }
+)
diff --git a/internal/platform/open_file_windows.go b/internal/platform/open_file_windows.go
index 67c08d51..ab27e603 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/platform/open_file_windows.go
@@ -3,8 +3,6 @@ package platform
 import (
 	"io/fs"
 	"os"
-	"runtime"
-	"strings"
 	"syscall"
 	"unsafe"
 )
@@ -29,18 +27,26 @@ const (
 	O_NOFOLLOW  = 1 << 30
 )
 
-func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
+func OpenFile(path string, flag int, perm fs.FileMode) (File, error) {
+	if f, err := openFile(path, flag, perm); err != nil {
+		return nil, err
+	} else {
+		return &windowsWrappedFile{File: f, path: path, flag: flag, perm: perm}, nil
+	}
+}
+
+func openFile(path 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))
+	fd, err := open(path, flag|syscall.O_CLOEXEC, uint32(perm))
 	if err == nil {
-		return os.NewFile(uintptr(fd), name), nil
+		return os.NewFile(uintptr(fd), path), nil
 	}
 
 	// TODO: Set FILE_SHARE_DELETE for directory as well.
-	f, err := os.OpenFile(name, flag, perm)
+	f, err := os.OpenFile(path, flag, perm)
 	if err = UnwrapOSError(err); err == nil {
 		return f, nil
 	}
@@ -51,7 +57,7 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 	case syscall.ENOTDIR:
 		err = syscall.ENOENT
 	case syscall.ENOENT:
-		if isSymlink(name) {
+		if isSymlink(path) {
 			// 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.
@@ -145,7 +151,7 @@ func open(path string, mode int, perm uint32) (fd syscall.Handle, err error) {
 		}
 	}
 
-	if isGo120 {
+	if IsGo120 {
 		// This shouldn't be included before 1.20 to have consistent behavior.
 		// https://github.com/golang/go/commit/0f0aa5d8a6a0253627d58b3aa083b24a1091933f
 		if createmode == syscall.OPEN_EXISTING && access == syscall.GENERIC_READ {
@@ -157,5 +163,3 @@ func open(path string, mode int, perm uint32) (fd syscall.Handle, err error) {
 	h, e := syscall.CreateFile(pathp, access, sharemode, sa, createmode, attrs, 0)
 	return h, e
 }
-
-var isGo120 = strings.Contains(runtime.Version(), "go1.20")
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
index 0156e35c..99141d05 100644
--- a/internal/platform/platform.go
+++ b/internal/platform/platform.go
@@ -8,8 +8,12 @@ import (
 	"errors"
 	"io"
 	"runtime"
+	"strings"
 )
 
+// TODO: IsAtLeastGo120
+var IsGo120 = strings.Contains(runtime.Version(), "go1.20")
+
 // archRequirementsVerified is set by platform-specific init to true if the platform is supported
 var archRequirementsVerified bool
 
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
index cfc2d57c..c2e1ffc6 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/platform/stat_bsd.go
@@ -28,6 +28,13 @@ func statFile(f fs.File, st *Stat_t) error {
 	return defaultStatFile(f, st)
 }
 
+func inoFromFileInfo(_ readdirFile, t fs.FileInfo) (ino uint64, err error) {
+	if d, ok := t.Sys().(*syscall.Stat_t); ok {
+		ino = d.Ino
+	}
+	return
+}
+
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
 		st.Ino = d.Ino
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
index d975db9c..55487192 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/platform/stat_linux.go
@@ -31,6 +31,13 @@ func statFile(f fs.File, st *Stat_t) error {
 	return defaultStatFile(f, st)
 }
 
+func inoFromFileInfo(_ readdirFile, t fs.FileInfo) (ino uint64, err error) {
+	if d, ok := t.Sys().(*syscall.Stat_t); ok {
+		ino = (d.Ino)
+	}
+	return
+}
+
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
 		st.Ino = uint64(d.Ino)
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
index 980779ae..397c137b 100644
--- a/internal/platform/stat_unsupported.go
+++ b/internal/platform/stat_unsupported.go
@@ -27,6 +27,10 @@ func statFile(f fs.File, st *Stat_t) error {
 	return defaultStatFile(f, st)
 }
 
+func inoFromFileInfo(readdirFile, fs.FileInfo) (ino uint64, err error) {
+	return
+}
+
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	fillStatFromDefaultFileInfo(st, t)
 }
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
index 5e5ffbfe..f44a4835 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/platform/stat_windows.go
@@ -4,6 +4,7 @@ package platform
 
 import (
 	"io/fs"
+	"path"
 	"syscall"
 )
 
@@ -45,10 +46,6 @@ func statPath(createFileAttrs uint32, path string, st *Stat_t) (err error) {
 	return statHandle(h, st)
 }
 
-// fdFile 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 fdFile interface{ Fd() (fd uintptr) }
-
 func statFile(f fs.File, st *Stat_t) (err error) {
 	if of, ok := f.(fdFile); ok {
 		// Attempt to get the stat by handle, which works for normal files
@@ -63,6 +60,18 @@ func statFile(f fs.File, st *Stat_t) (err error) {
 	return defaultStatFile(f, st)
 }
 
+// inoFromFileInfo uses stat to get the inode information of the file.
+func inoFromFileInfo(f readdirFile, t fs.FileInfo) (ino uint64, err error) {
+	if pf, ok := f.(PathFile); ok {
+		var st Stat_t
+		inoPath := path.Clean(path.Join(pf.Path(), t.Name()))
+		if err = lstat(inoPath, &st); err == nil {
+			ino = st.Ino
+		}
+	}
+	return // not in Win32FileAttributeData
+}
+
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	if d, ok := t.Sys().(*syscall.Win32FileAttributeData); ok {
 		st.Ino = 0 // not in Win32FileAttributeData
diff --git a/internal/platform/sync.go b/internal/platform/sync.go
index e110ffd4..cf00b0fe 100644
--- a/internal/platform/sync.go
+++ b/internal/platform/sync.go
@@ -1,7 +1,11 @@
 package platform
 
+import "io/fs"
+
 // Fdatasync is like syscall.Fdatasync except that's only defined in linux.
-// This returns syscall.ENOSYS when unimplemented.
-func Fdatasync(fd uintptr) (err error) {
-	return fdatasync(fd)
+//
+// Note: This returns with no error instead of syscall.ENOSYS when
+// unimplemented. This prevents fake filesystems from erring.
+func Fdatasync(f fs.File) (err error) {
+	return fdatasync(f)
 }
diff --git a/internal/platform/sync_linux.go b/internal/platform/sync_linux.go
index ec67198f..c634431d 100644
--- a/internal/platform/sync_linux.go
+++ b/internal/platform/sync_linux.go
@@ -2,8 +2,19 @@
 
 package platform
 
-import "syscall"
+import (
+	"io/fs"
+	"syscall"
+)
 
-func fdatasync(fd uintptr) error {
-	return syscall.Fdatasync(int(fd))
+func fdatasync(f fs.File) (err error) {
+	if fd, ok := f.(fdFile); ok {
+		return syscall.Fdatasync(int(fd.Fd()))
+	}
+
+	// Attempt to sync everything, even if we only need to sync the data.
+	if s, ok := f.(syncFile); ok {
+		err = s.Sync()
+	}
+	return
 }
diff --git a/internal/platform/sync_unsupported.go b/internal/platform/sync_unsupported.go
index 4f039f5e..8fc60518 100644
--- a/internal/platform/sync_unsupported.go
+++ b/internal/platform/sync_unsupported.go
@@ -2,8 +2,14 @@
 
 package platform
 
-import "syscall"
+import (
+	"io/fs"
+)
 
-func fdatasync(fd uintptr) error {
-	return syscall.ENOSYS
+func fdatasync(f fs.File) error {
+	// Attempt to sync everything, even if we only need to sync the data.
+	if s, ok := f.(syncFile); ok {
+		return s.Sync()
+	}
+	return nil
 }
diff --git a/internal/platform/wrap_windows.go b/internal/platform/wrap_windows.go
new file mode 100644
index 00000000..5b9399ff
--- /dev/null
+++ b/internal/platform/wrap_windows.go
@@ -0,0 +1,132 @@
+package platform
+
+import (
+	"io/fs"
+	"syscall"
+)
+
+// windowsWrappedFile deals with errno portability issues in Windows. This code
+// is likely to change as we complete WASI and GOOS=js.
+//
+// If we don't map to syscall.Errno, wasm will crash in odd way attempting the
+// same. This approach is an alternative to making our own fs.File public type.
+// We aren't doing that yet, as mapping problems are generally contained to
+// Windows. Hence, file is intentionally not exported.
+//
+// Note: Don't test for this type as it is wrapped when using sysfs.NewReadFS.
+type windowsWrappedFile struct {
+	File
+	path           string
+	flag           int
+	perm           fs.FileMode
+	dirInitialized bool
+
+	fileType *fs.FileMode
+
+	// closed is true when closed was called. This ensures proper syscall.EBADF
+	// TODO: extract a base wrapper type to cover all cases.
+	closed bool
+}
+
+// Path implements PathFile
+func (w *windowsWrappedFile) Path() string {
+	return w.path
+}
+
+// Readdir implements readdirFile.
+func (w *windowsWrappedFile) Readdir(n int) (fis []fs.FileInfo, err error) {
+	if err = w.requireFile("Readdir", false, true); err != nil {
+		return
+	} else if err = w.maybeInitDir(); err != nil {
+		return
+	}
+
+	return w.File.Readdir(n)
+}
+
+// ReadDir implements fs.ReadDirFile.
+func (w *windowsWrappedFile) ReadDir(n int) (dirents []fs.DirEntry, err error) {
+	if err = w.requireFile("ReadDir", false, true); err != nil {
+		return
+	} else if err = w.maybeInitDir(); err != nil {
+		return
+	}
+
+	return w.File.ReadDir(n)
+}
+
+// Write implements io.Writer
+func (w *windowsWrappedFile) Write(p []byte) (n int, err error) {
+	if err = w.requireFile("Write", false, false); err != nil {
+		return
+	}
+
+	return w.File.Write(p)
+}
+
+// Close implements io.Closer
+func (w *windowsWrappedFile) Close() (err error) {
+	if w.closed {
+		return
+	}
+
+	if err = w.File.Close(); err != nil {
+		w.closed = true
+	}
+	return
+}
+
+func (w *windowsWrappedFile) maybeInitDir() error {
+	if w.dirInitialized {
+		return nil
+	}
+
+	// On Windows, once the directory is opened, changes to the directory are
+	// not visible on ReadDir on that already-opened file handle.
+	//
+	// To provide consistent behavior with other platforms, we re-open it.
+	if err := w.File.Close(); err != nil {
+		return err
+	}
+	newW, err := openFile(w.path, w.flag, w.perm)
+	if err != nil {
+		return &fs.PathError{Op: "OpenFile", Path: w.path, Err: err}
+	}
+	w.File = newW
+	w.dirInitialized = true
+	return nil
+}
+
+// requireFile is used to making syscalls which will fail.
+func (w *windowsWrappedFile) requireFile(op string, readOnly, isDir bool) error {
+	var ft fs.FileMode
+	var err error
+	if w.closed {
+		err = syscall.EBADF
+	} else if readOnly && w.flag&syscall.O_RDONLY == 0 {
+		err = syscall.EBADF
+	} else if ft, err = w.getFileType(); err == nil && isDir != ft.IsDir() {
+		if isDir {
+			err = syscall.ENOTDIR
+		} else {
+			err = syscall.EBADF
+		}
+	} else if err == nil {
+		return nil
+	}
+	return &fs.PathError{Op: op, Path: w.path, Err: err}
+}
+
+// getFileType caches the file type as this cannot change on an open file.
+func (w *windowsWrappedFile) getFileType() (fs.FileMode, error) {
+	if w.fileType == nil {
+		var st Stat_t
+		if err := StatFile(w.File, &st); err != nil {
+			return 0, nil
+		}
+		ft := st.Mode & fs.ModeType
+		w.fileType = &ft
+		return ft, nil
+	}
+	return *w.fileType, nil
+}
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index aaecbf28..dda320ba 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -11,8 +11,8 @@ import (
 	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-// Adapt adapts the input to FS unless it is already one. NewDirFS should be
-// used instead, if the input is os.DirFS.
+// Adapt adapts the input to FS unless it is already one. Use NewDirFS instead
+// of os.DirFS as it handles interop issues such as windows support.
 //
 // Note: This performs no flag verification on FS.OpenFile. fs.FS cannot read
 // flags as there is no parameter to pass them through with. Moreover, fs.FS
@@ -44,14 +44,7 @@ func (a *adapter) Open(name string) (fs.File, error) {
 func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	path = cleanPath(path)
 	f, err := a.fs.Open(path)
-
-	if err != nil {
-		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
-	}
-	return f, nil
+	return f, platform.UnwrapOSError(err)
 }
 
 // Stat implements FS.Stat
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 9cfa16fc..5d54c6d9 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -42,11 +42,7 @@ func (d *dirFS) Open(name string) (fs.File, error) {
 
 // OpenFile implements FS.OpenFile
 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 platform.OpenFile(d.join(path), flag, perm)
 }
 
 // Lstat implements FS.Lstat
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 65fc7fcb..4caa7a96 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -16,8 +16,6 @@ import (
 func NewReadFS(fs FS) FS {
 	if _, ok := fs.(*readFS); ok {
 		return fs
-	} else if _, ok = fs.(*adapter); ok {
-		return fs // fs.FS is always read-only
 	} else if _, ok = fs.(UnimplementedFS); ok {
 		return fs // unimplemented is read-only
 	}
@@ -77,6 +75,21 @@ func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, err
 //
 // This technique was adapted from similar code in zipkin-go.
 func maskForReads(f fs.File) fs.File {
+	// Handle the most common types
+	rf, ok := f.(platform.ReadFile)
+	pf, pok := f.(platform.PathFile)
+	switch {
+	case ok && !pok:
+		return struct {
+			platform.ReadFile
+		}{rf}
+	case ok && pok:
+		return struct {
+			platform.ReadFile
+			platform.PathFile
+		}{rf, pf}
+	}
+
 	// The below are the types wazero casts into.
 	// Note: os.File implements this even for normal files.
 	d, i0 := f.(fs.ReadDirFile)
@@ -137,3 +150,8 @@ func (r *readFS) Lstat(path string, lstat *platform.Stat_t) error {
 func (r *readFS) Stat(path string, stat *platform.Stat_t) error {
 	return r.fs.Stat(path, stat)
 }
+
+// Readlink implements FS.Readlink
+func (r *readFS) Readlink(path string, buf []byte) (n int, err error) {
+	return r.fs.Readlink(path, buf)
+}
diff --git a/internal/sysfs/syscall.go b/internal/sysfs/syscall.go
deleted file mode 100644
index 38ebab47..00000000
--- a/internal/sysfs/syscall.go
+++ /dev/null
@@ -1,11 +0,0 @@
-//go:build !windows
-
-package sysfs
-
-import (
-	"io/fs"
-)
-
-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
deleted file mode 100644
index bc87c8c5..00000000
--- a/internal/sysfs/syscall_windows.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package sysfs
-
-import (
-	"io/fs"
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/platform"
-)
-
-// 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.
-//
-// If we don't map to syscall.Errno, wasm will crash in odd way attempting the
-// same. This approach is an alternative to making our own fs.File public type.
-// We aren't doing that yet, as mapping problems are generally contained to
-// Windows. Hence, file is intentionally not exported.
-func maybeWrapFile(f file, fs FS, path string, flag int, perm fs.FileMode) file {
-	return &windowsWrappedFile{f, fs, path, flag, perm, false}
-}
-
-type windowsWrappedFile struct {
-	file
-	fs                 FS
-	path               string
-	flag               int
-	perm               fs.FileMode
-	readDirInitialized bool
-}
-
-// ReadDir implements fs.ReadDirFile.
-func (w *windowsWrappedFile) ReadDir(n int) ([]fs.DirEntry, error) {
-	if !w.readDirInitialized {
-		// On Windows, once the directory is opened, changes to the directory
-		// is not visible on ReadDir on that already-opened file handle.
-		//
-		// In order to provide consistent behavior with other platforms, we re-open it.
-		if err := w.Close(); err != nil {
-			return nil, err
-		}
-		newW, err := w.fs.OpenFile(w.path, w.flag, w.perm)
-		if err != nil {
-			return nil, err
-		}
-		*w = *newW.(*windowsWrappedFile)
-		w.readDirInitialized = true
-	}
-	return w.file.ReadDir(n)
-}
-
-// Write implements io.Writer
-func (w *windowsWrappedFile) Write(p []byte) (n int, err error) {
-	n, err = w.file.Write(p)
-	if err == nil {
-		return
-	}
-
-	// os.File.Wrap wraps the syscall error in a path error
-	if pe, ok := err.(*fs.PathError); ok {
-		if pe.Err = platform.UnwrapOSError(pe.Err); pe.Err == syscall.EPERM {
-			// go1.20 returns access denied, not invalid handle, writing to a directory.
-			var stat platform.Stat_t
-			if statErr := w.fs.Stat(w.path, &stat); statErr == nil && stat.Mode.IsDir() {
-				pe.Err = syscall.EBADF
-			}
-		}
-	}
-	return
-}
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index dd650923..b4806025 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -255,41 +255,6 @@ type FS interface {
 	Utimes(path string, atimeNsec, mtimeNsec int64) error
 }
 
-// readFile declares all read interfaces defined on os.File used by wazero.
-type readFile interface {
-	fdFile // for the number of links.
-	readdirnamesFile
-	fs.ReadDirFile
-	io.ReaderAt // for pread
-	io.Seeker   // fallback for ReaderAt for embed:fs
-}
-
-// file declares all interfaces defined on os.File used by wazero.
-type file interface {
-	readFile
-	io.Writer
-	io.WriterAt // for pwrite
-	chmodFile
-	syncFile
-	truncateFile
-}
-
-// The following interfaces are used until we finalize our own FD-scoped file.
-type (
-	// chmodFile is implemented by os.File in file_posix.go
-	chmodFile interface{ Chmod(fs.FileMode) error }
-	// fdFile is implemented by os.File in file_unix.go and file_windows.go
-	fdFile interface{ Fd() (fd uintptr) }
-	// syncFile is implemented by os.File in file_posix.go
-	syncFile interface{ Sync() error }
-	// readdirnamesFile is implemented by os.File in dir.go
-	readdirnamesFile interface {
-		Readdirnames(n int) (names []string, err error)
-	}
-	// truncateFile is implemented by os.File in file_posix.go
-	truncateFile interface{ Truncate(size int64) error }
-)
-
 // ReaderAtOffset gets an io.Reader from a fs.File that reads from an offset,
 // yet doesn't affect the underlying position. This is used to implement
 // syscall.Pread.
@@ -309,17 +274,7 @@ func ReaderAtOffset(f fs.File, offset int64) io.Reader {
 
 // FileDatasync is like syscall.Fdatasync except that's only defined in linux.
 func FileDatasync(f fs.File) (err error) {
-	if fd, ok := f.(fdFile); ok {
-		if err := platform.Fdatasync(fd.Fd()); err != syscall.ENOSYS {
-			return err
-		}
-	}
-
-	// Attempt to sync everything, even if we only need to sync the data.
-	if s, ok := f.(syncFile); ok {
-		err = s.Sync()
-	}
-	return
+	return platform.Fdatasync(f)
 }
 
 type enosysReader struct{}


Test patch:
diff --git a/.github/wasi_testsuite_adapter.py b/.github/wasi_testsuite_adapter.py
new file mode 100644
index 00000000..c0496fff
--- /dev/null
+++ b/.github/wasi_testsuite_adapter.py
@@ -0,0 +1,47 @@
+# adapter for wazero until/unless https://github.com/WebAssembly/wasi-testsuite/pull/55
+
+import argparse
+import subprocess
+import sys
+import os
+import shlex
+
+# shlex.split() splits according to shell quoting rules
+WAZERO = shlex.split(os.getenv("TEST_RUNTIME_EXE", "wazero"))
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--version", action="store_true")
+parser.add_argument("--test-file", action="store")
+parser.add_argument("--arg", action="append", default=[])
+parser.add_argument("--env", action="append", default=[])
+parser.add_argument("--dir", action="append", default=[])
+
+args = parser.parse_args()
+
+if args.version:
+    version = subprocess.run(
+        WAZERO + ["version"], capture_output=True, text=True
+    ).stdout.strip()
+    if version == "dev":
+        version = "0.0.0"
+    print("wazero", version)
+    sys.exit(0)
+
+TEST_FILE = args.test_file
+TEST_DIR = os.path.dirname(TEST_FILE)
+PROG_ARGS = []
+if args.arg:
+    PROG_ARGS = ["--"] + args.arg
+ENV_ARGS = [f"-env={e}" for e in args.env]
+cwd = os.getcwd()
+DIR_ARGS = [f"-mount={cwd}/{dir}:{dir}" for dir in args.dir]
+
+PROG = (
+    WAZERO
+    + ["run", "-hostlogging=filesystem"]
+    + ENV_ARGS
+    + DIR_ARGS
+    + [TEST_FILE]
+    + PROG_ARGS
+)
+sys.exit(subprocess.run(PROG, cwd=TEST_DIR).returncode)
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 7ef4fd5c..8505e223 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -9,6 +9,7 @@ import (
 	"math"
 	"os"
 	"path"
+	"runtime"
 	"testing"
 	"time"
 
@@ -20,6 +21,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
+	"github.com/tetratelabs/wazero/internal/u64"
 	. "github.com/tetratelabs/wazero/internal/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -4451,10 +4453,69 @@ func requireOpenFile(t *testing.T, tmpDir string, pathName string, data []byte,
 	return mod, fd, log, r
 }
 
+// Test_fdReaddir_dotEntriesHaveRealInodes because wasi-testsuite requires it.
+func Test_fdReaddir_dotEntriesHaveRealInodes(t *testing.T) {
+	if runtime.GOOS == "windows" && !platform.IsGo120 {
+		t.Skip("windows before go 1.20 has trouble reading the inode information on directories.")
+	}
+
+	root := t.TempDir()
+	mod, r, _ := requireProxyModule(t, wazero.NewModuleConfig().
+		WithFSConfig(wazero.NewFSConfig().WithDirMount(root, "/")),
+	)
+	defer r.Close(testCtx)
+
+	mem := mod.Memory()
+
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	preopen := fsc.RootFS()
+
+	const readDirTarget = "dir"
+	mem.Write(0, []byte(readDirTarget))
+	requireErrnoResult(t, ErrnoSuccess, mod, PathCreateDirectoryName,
+		uint64(sys.FdPreopen), uint64(0), uint64(len(readDirTarget)))
+
+	// Open the directory, before writing files!
+	dirFd, err := fsc.OpenFile(preopen, readDirTarget, os.O_RDONLY, 0)
+	require.NoError(t, err)
+
+	// get the real inode of the current directory
+	var st platform.Stat_t
+	require.NoError(t, preopen.Stat(readDirTarget, &st))
+	dirents := []byte{1, 0, 0, 0, 0, 0, 0, 0}         // d_next = 1
+	dirents = append(dirents, u64.LeBytes(st.Ino)...) // d_ino
+	dirents = append(dirents, 1, 0, 0, 0)             // d_namlen = 1 character
+	dirents = append(dirents, 3, 0, 0, 0)             // d_type = directory
+	dirents = append(dirents, '.')                    // name
+
+	// get the real inode of the parent directory
+	require.NoError(t, preopen.Stat(".", &st))
+	dirents = append(dirents, 2, 0, 0, 0, 0, 0, 0, 0) // d_next = 2
+	dirents = append(dirents, u64.LeBytes(st.Ino)...) // d_ino
+	dirents = append(dirents, 2, 0, 0, 0)             // d_namlen = 2 characters
+	dirents = append(dirents, 3, 0, 0, 0)             // d_type = directory
+	dirents = append(dirents, '.', '.')               // name
+
+	// Try to list them!
+	resultBufused := uint32(0) // where to write the amount used out of bufLen
+	buf := uint32(8)           // where to start the dirents
+	requireErrnoResult(t, ErrnoSuccess, mod, FdReaddirName,
+		uint64(dirFd), uint64(buf), uint64(0x2000), 0, uint64(resultBufused))
+
+	used, _ := mem.ReadUint32Le(resultBufused)
+
+	results, _ := mem.Read(buf, used)
+	require.Equal(t, dirents, results)
+}
+
 // Test_fdReaddir_opened_file_written ensures that writing files to the already-opened directory
 // is visible. This is significant on Windows.
 // https://github.com/ziglang/zig/blob/2ccff5115454bab4898bae3de88f5619310bc5c1/lib/std/fs/test.zig#L156-L184
 func Test_fdReaddir_opened_file_written(t *testing.T) {
+	if runtime.GOOS == "windows" && !platform.IsGo120 {
+		t.Skip("windows before go 1.20 has trouble reading the inode information on directories.")
+	}
+
 	root := t.TempDir()
 	mod, r, _ := requireProxyModule(t, wazero.NewModuleConfig().
 		WithFSConfig(wazero.NewFSConfig().WithDirMount(root, "/")),
@@ -4480,7 +4541,32 @@ func Test_fdReaddir_opened_file_written(t *testing.T) {
 	require.NoError(t, err)
 	defer f.Close()
 
-	// Try list them!
+	// get the real inode of the current directory
+	var st platform.Stat_t
+	require.NoError(t, preopen.Stat(readDirTarget, &st))
+	dirents := []byte{1, 0, 0, 0, 0, 0, 0, 0}         // d_next = 1
+	dirents = append(dirents, u64.LeBytes(st.Ino)...) // d_ino
+	dirents = append(dirents, 1, 0, 0, 0)             // d_namlen = 1 character
+	dirents = append(dirents, 3, 0, 0, 0)             // d_type = directory
+	dirents = append(dirents, '.')                    // name
+
+	// get the real inode of the parent directory
+	require.NoError(t, preopen.Stat(".", &st))
+	dirents = append(dirents, 2, 0, 0, 0, 0, 0, 0, 0) // d_next = 2
+	dirents = append(dirents, u64.LeBytes(st.Ino)...) // d_ino
+	dirents = append(dirents, 2, 0, 0, 0)             // d_namlen = 2 characters
+	dirents = append(dirents, 3, 0, 0, 0)             // d_type = directory
+	dirents = append(dirents, '.', '.')               // name
+
+	// get the real inode of the file
+	require.NoError(t, platform.StatFile(f, &st))
+	dirents = append(dirents, 3, 0, 0, 0, 0, 0, 0, 0)  // d_next = 3
+	dirents = append(dirents, u64.LeBytes(st.Ino)...)  // d_ino
+	dirents = append(dirents, 5, 0, 0, 0)              // d_namlen = 5 characters
+	dirents = append(dirents, 4, 0, 0, 0)              // d_type = regular_file
+	dirents = append(dirents, 'a', 'f', 'i', 'l', 'e') // name
+
+	// Try to list them!
 	resultBufused := uint32(0) // where to write the amount used out of bufLen
 	buf := uint32(8)           // where to start the dirents
 	requireErrnoResult(t, ErrnoSuccess, mod, FdReaddirName,
@@ -4489,11 +4575,5 @@ func Test_fdReaddir_opened_file_written(t *testing.T) {
 	used, _ := mem.ReadUint32Le(resultBufused)
 
 	results, _ := mem.Read(buf, used)
-	require.Equal(t, append(append(direntDot, direntDotDot...),
-		3, 0, 0, 0, 0, 0, 0, 0, // d_next = 3
-		0, 0, 0, 0, 0, 0, 0, 0, // d_ino = 0
-		5, 0, 0, 0, // d_namlen = 4 character
-		4, 0, 0, 0, // d_type = regular_file
-		'a', 'f', 'i', 'l', 'e', // name
-	), results)
+	require.Equal(t, dirents, results)
 }
diff --git a/internal/platform/dir_test.go b/internal/platform/dir_test.go
index 72eebf09..5e506b29 100644
--- a/internal/platform/dir_test.go
+++ b/internal/platform/dir_test.go
@@ -1,7 +1,6 @@
 package platform_test
 
 import (
-	"io"
 	"io/fs"
 	"os"
 	"path"
@@ -34,29 +33,55 @@ func TestReaddirnames(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			dirF, err := tc.fs.Open(".")
+			dotF, err := tc.fs.Open(".")
 			require.NoError(t, err)
-			defer dirF.Close()
+			defer dotF.Close()
 
 			t.Run("dir", func(t *testing.T) {
-				names, err := platform.Readdirnames(dirF, -1)
+				names, err := platform.Readdirnames(dotF, -1)
 				require.NoError(t, err)
 				sort.Strings(names)
 				require.Equal(t, []string{"animals.txt", "dir", "empty.txt", "emptydir", "sub"}, names)
 
 				// read again even though it is exhausted
-				_, err = platform.Readdirnames(dirF, 100)
-				require.EqualErrno(t, syscall.EIO, err)
+				_, err = platform.Readdirnames(dotF, 100)
+				require.NoError(t, err)
 			})
 
-			// windows and fstest.MapFS allow you to read a closed dir
-			if runtime.GOOS != "windows" && tc.name != "fstest.MapFS" {
-				t.Run("closed dir", func(t *testing.T) {
-					require.NoError(t, dirF.Close())
-					_, err := platform.Readdirnames(dirF, -1)
-					require.EqualErrno(t, syscall.EIO, err)
-				})
-			}
+			// Don't err if something else closed the directory while reading.
+			t.Run("closed dir", func(t *testing.T) {
+				require.NoError(t, dotF.Close())
+				_, err := platform.Readdir(dotF, -1)
+				require.NoError(t, err)
+			})
+
+			dirF, err := tc.fs.Open("dir")
+			require.NoError(t, err)
+			defer dirF.Close()
+
+			t.Run("partial", func(t *testing.T) {
+				names1, err := platform.Readdirnames(dirF, 1)
+				require.NoError(t, err)
+				require.Equal(t, 1, len(names1))
+
+				names2, err := platform.Readdirnames(dirF, 1)
+				require.NoError(t, err)
+				require.Equal(t, 1, len(names2))
+
+				// read exactly the last entry
+				names3, err := platform.Readdirnames(dirF, 1)
+				require.NoError(t, err)
+				require.Equal(t, 1, len(names3))
+
+				names := []string{names1[0], names2[0], names3[0]}
+				sort.Strings(names)
+
+				require.Equal(t, []string{"-", "a-", "ab-"}, names)
+
+				// no error reading an exhausted directory
+				_, err = platform.Readdirnames(dirF, 1)
+				require.NoError(t, err)
+			})
 
 			fileF, err := tc.fs.Open("empty.txt")
 			require.NoError(t, err)
@@ -88,26 +113,29 @@ func TestReaddir(t *testing.T) {
 	dirFS := os.DirFS(tmpDir)
 
 	tests := []struct {
-		name string
-		fs   fs.FS
+		name      string
+		fs        fs.FS
+		expectIno bool
 	}{
-		{name: "os.DirFS", fs: dirFS},         // To test readdirFile
-		{name: "fstest.MapFS", fs: fstest.FS}, // To test adaptation of ReadDirFile
+		{name: "os.DirFS", fs: dirFS, expectIno: runtime.GOOS != "windows"}, // To test readdirFile
+		{name: "fstest.MapFS", fs: fstest.FS, expectIno: false},             // To test adaptation of ReadDirFile
 	}
 
 	for _, tc := range tests {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
-			dirF, err := tc.fs.Open(".")
+			dotF, err := tc.fs.Open(".")
 			require.NoError(t, err)
-			defer dirF.Close()
+			defer dotF.Close()
 
 			t.Run("dir", func(t *testing.T) {
-				dirents, err := platform.Readdir(dirF, -1)
+				dirents, err := platform.Readdir(dotF, -1)
 				require.NoError(t, err) // no io.EOF when -1 is used
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
+				requireIno(t, dirents, tc.expectIno)
+
 				require.Equal(t, []*platform.Dirent{
 					{Name: "animals.txt", Type: 0},
 					{Name: "dir", Type: fs.ModeDir},
@@ -117,15 +145,15 @@ func TestReaddir(t *testing.T) {
 				}, dirents)
 
 				// read again even though it is exhausted
-				dirents, err = platform.Readdir(dirF, 100)
-				require.Equal(t, io.EOF, err)
+				dirents, err = platform.Readdir(dotF, 100)
+				require.NoError(t, err)
 				require.Zero(t, len(dirents))
 			})
 
 			// Don't err if something else closed the directory while reading.
 			t.Run("closed dir", func(t *testing.T) {
-				require.NoError(t, dirF.Close())
-				_, err := platform.Readdir(dirF, -1)
+				require.NoError(t, dotF.Close())
+				_, err := platform.Readdir(dotF, -1)
 				require.NoError(t, err)
 			})
 
@@ -138,11 +166,11 @@ func TestReaddir(t *testing.T) {
 				require.EqualErrno(t, syscall.ENOTDIR, err)
 			})
 
-			dirF, err = tc.fs.Open("dir")
+			dirF, err := tc.fs.Open("dir")
 			require.NoError(t, err)
 			defer dirF.Close()
 
-			t.Run("partial read", func(t *testing.T) {
+			t.Run("partial", func(t *testing.T) {
 				dirents1, err := platform.Readdir(dirF, 1)
 				require.NoError(t, err)
 				require.Equal(t, 1, len(dirents1))
@@ -159,14 +187,17 @@ func TestReaddir(t *testing.T) {
 				dirents := []*platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
 				sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
 
+				requireIno(t, dirents, tc.expectIno)
+
 				require.Equal(t, []*platform.Dirent{
 					{Name: "-", Type: 0},
 					{Name: "a-", Type: fs.ModeDir},
 					{Name: "ab-", Type: 0},
 				}, dirents)
 
+				// no error reading an exhausted directory
 				_, err = platform.Readdir(dirF, 1)
-				require.Equal(t, io.EOF, err)
+				require.NoError(t, err)
 			})
 
 			subdirF, err := tc.fs.Open("sub")
@@ -209,3 +240,14 @@ func TestReaddir(t *testing.T) {
 		// don't validate the contents as due to caching it might be present.
 	})
 }
+
+func requireIno(t *testing.T, dirents []*platform.Dirent, expectIno bool) {
+	for _, e := range dirents {
+		if expectIno {
+			require.NotEqual(t, uint64(0), e.Ino, "%+v", e)
+			e.Ino = 0
+		} else {
+			require.Zero(t, e.Ino, "%+v", e)
+		}
+	}
+}
diff --git a/internal/platform/open_file_test.go b/internal/platform/open_file_test.go
index 3ab2e256..f1f98640 100644
--- a/internal/platform/open_file_test.go
+++ b/internal/platform/open_file_test.go
@@ -2,8 +2,7 @@ package platform
 
 import (
 	"os"
-	"path"
-	"path/filepath"
+	pathutil "path/filepath"
 	"syscall"
 	"testing"
 
@@ -11,16 +10,16 @@ import (
 )
 
 func TestOpenFile_Errors(t *testing.T) {
-	tmp := t.TempDir()
+	tmpDir := 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)
+		_, err := OpenFile(pathutil.Join(tmpDir, "not-really-exist.txt"), os.O_RDONLY, 0o600)
 		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
 	t.Run("try creating on existing file must be EEXIST", func(t *testing.T) {
-		filepath := path.Join(tmp, "file.txt")
+		filepath := pathutil.Join(tmpDir, "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)
@@ -29,10 +28,34 @@ func TestOpenFile_Errors(t *testing.T) {
 		require.EqualErrno(t, syscall.EEXIST, err)
 	})
 
+	t.Run("writing to a read-only file is EBADF", func(t *testing.T) {
+		path := pathutil.Join(tmpDir, "file")
+		require.NoError(t, os.WriteFile(path, nil, 0o600))
+
+		f, err := OpenFile(path, os.O_RDONLY, 0)
+		defer require.NoError(t, f.Close())
+		require.NoError(t, err)
+
+		_, err = f.Write([]byte{1, 2, 3, 4})
+		require.EqualErrno(t, syscall.EBADF, UnwrapOSError(err))
+	})
+
+	t.Run("writing to a directory is EBADF", func(t *testing.T) {
+		path := pathutil.Join(tmpDir, "diragain")
+		require.NoError(t, os.Mkdir(path, 0o755))
+
+		f, err := OpenFile(path, os.O_RDONLY, 0)
+		defer require.NoError(t, f.Close())
+		require.NoError(t, err)
+
+		_, err = f.Write([]byte{1, 2, 3, 4})
+		require.EqualErrno(t, syscall.EBADF, UnwrapOSError(err))
+	})
+
 	// This is similar to https://github.com/WebAssembly/wasi-testsuite/blob/dc7f8d27be1030cd4788ebdf07d9b57e5d23441e/tests/rust/src/bin/dangling_symlink.rs
 	t.Run("dangling symlinks", func(t *testing.T) {
-		target := filepath.Join(tmp, "target")
-		symlink := filepath.Join(tmp, "dangling_symlink_symlink.cleanup")
+		target := pathutil.Join(tmpDir, "target")
+		symlink := pathutil.Join(tmpDir, "dangling_symlink_symlink.cleanup")
 
 		err := os.Symlink(target, symlink)
 		require.NoError(t, err)
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
index 2f54d50f..52cf4b4f 100644
--- a/internal/platform/stat_test.go
+++ b/internal/platform/stat_test.go
@@ -5,7 +5,6 @@ import (
 	"os"
 	pathutil "path"
 	"runtime"
-	"strings"
 	"syscall"
 	"testing"
 	"time"
@@ -317,7 +316,7 @@ func TestStatFile_dev_inode(t *testing.T) {
 
 func requireDirectoryDevIno(t *testing.T, st Stat_t) {
 	// windows before go 1.20 has trouble reading the inode information on directories.
-	if runtime.GOOS != "windows" || strings.HasPrefix(runtime.Version(), "go1.20") {
+	if runtime.GOOS != "windows" || IsGo120 {
 		require.NotEqual(t, uint64(0), st.Dev)
 		require.NotEqual(t, uint64(0), st.Ino)
 	} else {
diff --git a/internal/platform/sync_test.go b/internal/platform/sync_test.go
index b2464996..f50380b7 100644
--- a/internal/platform/sync_test.go
+++ b/internal/platform/sync_test.go
@@ -25,7 +25,7 @@ func Test_Fdatasync(t *testing.T) {
 	require.NoError(t, err)
 
 	// Sync the data.
-	if err = Fdatasync(f.Fd()); err == syscall.ENOSYS {
+	if err = Fdatasync(f); err == syscall.ENOSYS {
 		return // don't continue if it isn't supported.
 	}
 	require.NoError(t, err)
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index fa28e4b9..008ef4f0 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -5,6 +5,7 @@ import (
 	"io/fs"
 	"os"
 	pathutil "path"
+	"runtime"
 	"syscall"
 	"testing"
 
@@ -93,9 +94,14 @@ func TestAdapt_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 	testFS := Adapt(os.DirFS(tmpDir))
 
-	testOpen_Read(t, tmpDir, testFS)
+	// We can't correct operating system portability issues with os.DirFS on
+	// windows. Use syscall.DirFS instead!
+	if runtime.GOOS != "windows" {
+		testOpen_Read(t, testFS, true)
+	}
 
 	t.Run("path outside root invalid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 0fdda0a0..aed16128 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -34,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.EqualErrno(t, syscall.ENOTDIR, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOTDIR, platform.UnwrapOSError(err))
 	})
 }
 
@@ -160,7 +160,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, platform.UnwrapOSError(err))
 
 		s, err := os.Stat(file2Path)
 		require.NoError(t, err)
@@ -181,7 +181,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, platform.UnwrapOSError(err))
 
 		s, err := os.Stat(dir2Path)
 		require.NoError(t, err)
@@ -249,7 +249,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, platform.UnwrapOSError(err))
 
 		// Show the file inside that directory moved
 		s, err := os.Stat(pathutil.Join(dir2Path, file1))
@@ -306,7 +306,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.EqualErrno(t, syscall.ENOENT, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.ENOENT, platform.UnwrapOSError(err))
 
 		// Show the file1 overwrote file2
 		b, err := os.ReadFile(file2Path)
@@ -511,10 +511,11 @@ func TestDirFS_OpenFile(t *testing.T) {
 	// Create a subdirectory, so we can test reads outside the FS root.
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	testFS := NewDirFS(tmpDir)
 
-	testOpen_Read(t, tmpDir, testFS)
+	testOpen_Read(t, testFS, true)
 
 	testOpen_O_RDWR(t, tmpDir, testFS)
 
@@ -704,41 +705,9 @@ func TestDirFS_Symlink(t *testing.T) {
 }
 
 func TestDirFS_Readlink(t *testing.T) {
-	// Set up the test files
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	testFS := NewDirFS(tmpDir)
-
-	testLinks := []struct {
-		old, dst string
-	}{
-		// Same dir.
-		{old: "animals.txt", dst: "symlinked-animals.txt"},
-		{old: "sub/test.txt", dst: "sub/symlinked-test.txt"},
-		// Parent to sub.
-		{old: "animals.txt", dst: "sub/symlinked-animals.txt"},
-		// Sub to parent.
-		{old: "sub/test.txt", dst: "symlinked-zoo.txt"},
-	}
-
-	buf := make([]byte, 200)
-	for _, tl := range testLinks {
-		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)
-		require.Equal(t, tl.old, string(buf[:n]))
-		require.Equal(t, len(tl.old), n)
-	}
-
-	t.Run("errors", func(t *testing.T) {
-		_, err := testFS.Readlink("sub/test.txt", buf)
-		require.Error(t, err)
-		_, err = testFS.Readlink("", buf)
-		require.Error(t, err)
-		_, err = testFS.Readlink("animals.txt", buf)
-		require.Error(t, err)
-	})
+	testReadlink(t, testFS, testFS)
 }
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 2034edec..df13b4d8 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -4,6 +4,7 @@ import (
 	"io/fs"
 	"os"
 	pathutil "path"
+	"runtime"
 	"syscall"
 	"testing"
 
@@ -15,10 +16,12 @@ func TestNewReadFS(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	// Doesn't double-wrap file systems that are already read-only
-	adapted := Adapt(os.DirFS(tmpDir))
-	require.Equal(t, adapted, NewReadFS(adapted))
 	require.Equal(t, UnimplementedFS{}, NewReadFS(UnimplementedFS{}))
 
+	// Wraps a fs.FS because it allows access to Write
+	adapted := Adapt(os.DirFS(tmpDir))
+	require.NotEqual(t, adapted, NewReadFS(adapted))
+
 	// Wraps a writeable file system
 	writeable := NewDirFS(tmpDir)
 	readFS := NewReadFS(writeable)
@@ -124,11 +127,35 @@ func TestReadFS_Utimes(t *testing.T) {
 }
 
 func TestReadFS_Open_Read(t *testing.T) {
+	t.Parallel()
+
 	tmpDir := t.TempDir()
-	writeable := NewDirFS(tmpDir)
-	testFS := NewReadFS(writeable)
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	type test struct {
+		name      string
+		fs        FS
+		expectIno bool
+	}
+
+	tests := []test{
+		{name: "DirFS", fs: NewReadFS(NewDirFS(tmpDir)), expectIno: true},
+		{name: "fstest.MapFS", fs: NewReadFS(Adapt(fstest.FS)), expectIno: false},
+	}
 
-	testOpen_Read(t, tmpDir, testFS)
+	// We can't correct operating system portability issues with os.DirFS on
+	// windows. Use syscall.DirFS instead!
+	if runtime.GOOS != "windows" {
+		tests = append(tests, test{name: "os.DirFS", fs: NewReadFS(Adapt(os.DirFS(tmpDir))), expectIno: true})
+	}
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			testOpen_Read(t, tc.fs, tc.expectIno)
+		})
+	}
 }
 
 func TestReadFS_Stat(t *testing.T) {
@@ -140,6 +167,15 @@ func TestReadFS_Stat(t *testing.T) {
 	testStat(t, testFS)
 }
 
+func TestReadFS_Readlink(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	writeable := NewDirFS(tmpDir)
+	testFS := NewReadFS(writeable)
+	testReadlink(t, testFS, writeable)
+}
+
 func TestReadFS_TestFS(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
index 34d2c24f..1e6390ed 100644
--- a/internal/sysfs/rootfs_test.go
+++ b/internal/sysfs/rootfs_test.go
@@ -137,13 +137,14 @@ func TestRootFS_Open(t *testing.T) {
 	// Create a subdirectory, so we can test reads outside the FS root.
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
 
 	testRootFS := NewDirFS(tmpDir)
 	testDirFS := NewDirFS(t.TempDir())
 	testFS, err := NewRootFS([]FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
 	require.NoError(t, err)
 
-	testOpen_Read(t, tmpDir, testFS)
+	testOpen_Read(t, testFS, true)
 
 	testOpen_O_RDWR(t, tmpDir, testFS)
 
diff --git a/internal/sysfs/sysfs_bench_test.go b/internal/sysfs/sysfs_bench_test.go
index bf43390b..8d8a4155 100644
--- a/internal/sysfs/sysfs_bench_test.go
+++ b/internal/sysfs/sysfs_bench_test.go
@@ -9,7 +9,7 @@ import (
 
 func BenchmarkReaderAtOffset(b *testing.B) {
 	dirFS := os.DirFS("testdata")
-	embedFS, err := fs.Sub(readerAtFS, "testdata")
+	embedFS, err := fs.Sub(testdata, "testdata")
 	if err != nil {
 		b.Fatal(err)
 	}
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index f736f09a..34fc9f4d 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -4,14 +4,12 @@ import (
 	"bytes"
 	"embed"
 	_ "embed"
-	"errors"
 	"io"
 	"io/fs"
 	"os"
 	"path"
 	"runtime"
 	"sort"
-	"strings"
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
@@ -59,7 +57,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.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
+		require.EqualErrno(t, syscall.EBADF, platform.UnwrapOSError(err))
 	}
 
 	// Verify stat on the file
@@ -68,26 +66,7 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
 	require.Equal(t, fs.FileMode(0o444), stat.Mode().Perm())
 }
 
-func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
-	file := "file"
-	fileContents := []byte{1, 2, 3, 4}
-	err := os.WriteFile(path.Join(tmpDir, file), fileContents, 0o700)
-	require.NoError(t, err)
-
-	dir := "dir"
-	dirRealPath := path.Join(tmpDir, dir)
-	err = os.Mkdir(dirRealPath, 0o700)
-	require.NoError(t, err)
-
-	file1 := "file1"
-	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)
-
+func testOpen_Read(t *testing.T, testFS FS, expectIno bool) {
 	t.Run("doesn't exist", func(t *testing.T) {
 		_, err := testFS.OpenFile("nope", os.O_RDONLY, 0)
 
@@ -95,46 +74,124 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 		require.EqualErrno(t, syscall.ENOENT, err)
 	})
 
-	t.Run(". opens root", func(t *testing.T) {
+	t.Run("readdir . opens root", func(t *testing.T) {
 		f, err := testFS.OpenFile(".", os.O_RDONLY, 0)
 		require.NoError(t, err)
 		defer f.Close()
 
-		entries := requireReadDir(t, f)
-		require.Equal(t, 3, len(entries))
-		require.True(t, entries[0].IsDir())
-		require.Equal(t, dir, entries[0].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())
+		dirents := requireReaddir(t, f, -1, expectIno)
+
+		require.Equal(t, []*platform.Dirent{
+			{Name: "animals.txt", Type: 0},
+			{Name: "dir", Type: fs.ModeDir},
+			{Name: "empty.txt", Type: 0},
+			{Name: "emptydir", Type: fs.ModeDir},
+			{Name: "sub", Type: fs.ModeDir},
+		}, dirents)
 	})
 
-	t.Run("empty dir exists", func(t *testing.T) {
-		f, err := testFS.OpenFile(emptydir, os.O_RDONLY, 0)
+	t.Run("readdirnames . opens root", func(t *testing.T) {
+		f, err := testFS.OpenFile(".", os.O_RDONLY, 0)
+		require.NoError(t, err)
+		defer f.Close()
+
+		names := requireReaddirnames(t, f, -1)
+		require.Equal(t, []string{"animals.txt", "dir", "empty.txt", "emptydir", "sub"}, names)
+	})
+
+	t.Run("readdir empty", func(t *testing.T) {
+		f, err := testFS.OpenFile("emptydir", os.O_RDONLY, 0)
 		require.NoError(t, err)
 		defer f.Close()
 
-		entries := requireReadDir(t, f)
+		entries := requireReaddir(t, f, -1, expectIno)
 		require.Zero(t, len(entries))
 	})
 
-	t.Run("dir exists", func(t *testing.T) {
-		f, err := testFS.OpenFile(dir, os.O_RDONLY, 0)
+	t.Run("readdirnames empty", 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.Equal(t, 1, len(entries))
-		require.False(t, entries[0].IsDir())
-		require.Equal(t, file1, entries[0].Name())
+		names := requireReaddirnames(t, f, -1)
+		require.Zero(t, len(names))
+	})
+
+	t.Run("readdir partial", func(t *testing.T) {
+		dirF, err := testFS.OpenFile("dir", os.O_RDONLY, 0)
+		require.NoError(t, err)
+		defer dirF.Close()
+
+		dirents1, err := platform.Readdir(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(dirents1))
+
+		dirents2, err := platform.Readdir(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(dirents2))
+
+		// read exactly the last entry
+		dirents3, err := platform.Readdir(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(dirents3))
+
+		dirents := []*platform.Dirent{dirents1[0], dirents2[0], dirents3[0]}
+		sort.Slice(dirents, func(i, j int) bool { return dirents[i].Name < dirents[j].Name })
+
+		requireIno(t, dirents, expectIno)
+
+		require.Equal(t, []*platform.Dirent{
+			{Name: "-", Type: 0},
+			{Name: "a-", Type: fs.ModeDir},
+			{Name: "ab-", Type: 0},
+		}, dirents)
+
+		// no error reading an exhausted directory
+		_, err = platform.Readdir(dirF, 1)
+		require.NoError(t, err)
+	})
+
+	// TODO: consolidate duplicated tests from platform once we have our own
+	// file type
+	t.Run("readdirnames partial", func(t *testing.T) {
+		dirF, err := testFS.OpenFile("dir", os.O_RDONLY, 0)
+		require.NoError(t, err)
+		defer dirF.Close()
+
+		names1, err := platform.Readdirnames(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(names1))
+
+		names2, err := platform.Readdirnames(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(names2))
+
+		// read exactly the last entry
+		names3, err := platform.Readdirnames(dirF, 1)
+		require.NoError(t, err)
+		require.Equal(t, 1, len(names3))
+
+		names := []string{names1[0], names2[0], names3[0]}
+		sort.Strings(names)
+
+		require.Equal(t, []string{"-", "a-", "ab-"}, names)
+
+		// no error reading an exhausted directory
+		_, err = platform.Readdirnames(dirF, 1)
+		require.NoError(t, err)
 	})
 
 	t.Run("file exists", func(t *testing.T) {
-		f, err := testFS.OpenFile(file, os.O_RDONLY, 0)
+		f, err := testFS.OpenFile("animals.txt", os.O_RDONLY, 0)
 		require.NoError(t, err)
 		defer f.Close()
 
+		fileContents := []byte(`bear
+cat
+shark
+dinosaur
+human
+`)
 		// Ensure it implements io.ReaderAt
 		r, ok := f.(io.ReaderAt)
 		require.True(t, ok)
@@ -154,15 +211,6 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 		b, err := io.ReadAll(f)
 		require.NoError(t, err)
 		require.Equal(t, fileContents[1:], b)
-
-		if w, ok := f.(io.Writer); ok {
-			_, err := w.Write([]byte("hello"))
-			if runtime.GOOS == "windows" {
-				require.EqualErrno(t, syscall.EPERM, errors.Unwrap(err))
-			} else {
-				require.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
-			}
-		}
 	})
 
 	// Make sure O_RDONLY isn't treated bitwise as it is usually zero.
@@ -171,10 +219,36 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 		// currently supported in WASI or GOOS=js
 		const O_NOATIME = 0x40000
 
-		f, err := testFS.OpenFile(file, os.O_RDONLY|O_NOATIME, 0)
+		f, err := testFS.OpenFile("animals.txt", os.O_RDONLY|O_NOATIME, 0)
 		require.NoError(t, err)
 		defer f.Close()
 	})
+
+	t.Run("writing to a read-only file is EBADF", func(t *testing.T) {
+		f, err := testFS.OpenFile("animals.txt", os.O_RDONLY, 0)
+		defer require.NoError(t, f.Close())
+		require.NoError(t, err)
+
+		if w, ok := f.(io.Writer); ok {
+			_, err = w.Write([]byte{1, 2, 3, 4})
+			require.EqualErrno(t, syscall.EBADF, platform.UnwrapOSError(err))
+		} else {
+			t.Skip("not an io.Writer")
+		}
+	})
+
+	t.Run("writing to a directory is EBADF", func(t *testing.T) {
+		f, err := testFS.OpenFile("sub", os.O_RDONLY, 0)
+		defer require.NoError(t, f.Close())
+		require.NoError(t, err)
+
+		if w, ok := f.(io.Writer); ok {
+			_, err = w.Write([]byte{1, 2, 3, 4})
+			require.EqualErrno(t, syscall.EBADF, platform.UnwrapOSError(err))
+		} else {
+			t.Skip("not an io.Writer")
+		}
+	})
 }
 
 func testLstat(t *testing.T, testFS FS) {
@@ -253,28 +327,70 @@ func testStat(t *testing.T, testFS FS) {
 	require.NoError(t, err)
 	require.True(t, stat.Mode.IsDir())
 	// windows before go 1.20 has trouble reading the inode information on directories.
-	if runtime.GOOS != "windows" || strings.HasPrefix(runtime.Version(), "go1.20") {
+	if runtime.GOOS != "windows" || platform.IsGo120 {
 		require.NotEqual(t, uint64(0), stat.Dev)
 		require.NotEqual(t, uint64(0), stat.Ino)
 	}
 }
 
-// requireReadDir ensures the input file is a directory, and returns its
+// 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"))
-		require.EqualErrno(t, syscall.EBADF, errors.Unwrap(err))
-	}
-	// Ensure it implements fs.ReadDirFile
-	dir, ok := f.(fs.ReadDirFile)
-	require.True(t, ok)
-	entries, err := dir.ReadDir(-1)
+func requireReaddir(t *testing.T, f fs.File, n int, expectIno bool) []*platform.Dirent {
+	entries, err := platform.Readdir(f, n)
 	require.NoError(t, err)
-	sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
+	sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
+	if _, ok := f.(*openRootDir); ok {
+		// TODO: get inodes to work on the root directory of a composite FS
+		requireIno(t, entries, false)
+	} else {
+		requireIno(t, entries, expectIno)
+	}
 	return entries
 }
 
+// requireReaddirnames ensures the input file is a directory, and returns its
+// entries.
+func requireReaddirnames(t *testing.T, f fs.File, n int) []string {
+	names, err := platform.Readdirnames(f, n)
+	require.NoError(t, err)
+	sort.Strings(names)
+	return names
+}
+
+func testReadlink(t *testing.T, readFS, writeFS FS) {
+	testLinks := []struct {
+		old, dst string
+	}{
+		// Same dir.
+		{old: "animals.txt", dst: "symlinked-animals.txt"},
+		{old: "sub/test.txt", dst: "sub/symlinked-test.txt"},
+		// Parent to sub.
+		{old: "animals.txt", dst: "sub/symlinked-animals.txt"},
+		// Sub to parent.
+		{old: "sub/test.txt", dst: "symlinked-zoo.txt"},
+	}
+
+	buf := make([]byte, 200)
+	for _, tl := range testLinks {
+		err := writeFS.Symlink(tl.old, tl.dst) // not os.Symlink for windows compat
+		require.NoError(t, err, "%v", tl)
+
+		n, err := readFS.Readlink(tl.dst, buf)
+		require.NoError(t, err)
+		require.Equal(t, tl.old, string(buf[:n]))
+		require.Equal(t, len(tl.old), n)
+	}
+
+	t.Run("errors", func(t *testing.T) {
+		_, err := readFS.Readlink("sub/test.txt", buf)
+		require.Error(t, err)
+		_, err = readFS.Readlink("", buf)
+		require.Error(t, err)
+		_, err = readFS.Readlink("animals.txt", buf)
+		require.Error(t, err)
+	})
+}
+
 func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 	file := "file"
 	err := os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700)
@@ -351,14 +467,14 @@ func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 }
 
 var (
-	//go:embed testdata/*.txt
-	readerAtFS   embed.FS
+	//go:embed testdata
+	testdata     embed.FS
 	readerAtFile = "wazero.txt"
 	emptyFile    = "empty.txt"
 )
 
 func TestReaderAtOffset(t *testing.T) {
-	embedFS, err := fs.Sub(readerAtFS, "testdata")
+	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
 	d, err := embedFS.Open(readerAtFile)
@@ -433,7 +549,7 @@ func TestReaderAtOffset(t *testing.T) {
 }
 
 func TestReaderAtOffset_empty(t *testing.T) {
-	embedFS, err := fs.Sub(readerAtFS, "testdata")
+	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
 	d, err := embedFS.Open(readerAtFile)
@@ -486,7 +602,7 @@ func TestReaderAtOffset_empty(t *testing.T) {
 }
 
 func TestReaderAtOffset_Unsupported(t *testing.T) {
-	embedFS, err := fs.Sub(readerAtFS, "testdata")
+	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
 	f, err := embedFS.Open(emptyFile)
@@ -629,7 +745,7 @@ func TestWriterAtOffset_Unsupported(t *testing.T) {
 // to below. Effectively, this only tests that things don't error.
 func Test_FileSync(t *testing.T) {
 	testSync(t, func(f fs.File) error {
-		return f.(syncFile).Sync()
+		return f.(interface{ Sync() error }).Sync()
 	})
 }
 
@@ -664,3 +780,14 @@ func testSync(t *testing.T, sync func(fs.File) error) {
 	// It may be the case that sync worked.
 	require.Equal(t, expected, buf.String())
 }
+
+func requireIno(t *testing.T, dirents []*platform.Dirent, expectIno bool) {
+	for _, e := range dirents {
+		if expectIno {
+			require.NotEqual(t, uint64(0), e.Ino, "%+v", e)
+			e.Ino = 0
+		} else {
+			require.Zero(t, e.Ino, "%+v", e)
+		}
+	}
+}