Repository: tetratelabs/wazero
Pull request #1563 (2023-07-06 07:32:35)

fs: pares down to functions used in wasip1 and wasip2
This removes support for any functions only used in gojs. It retains functions used in wasip1 and a couple additions that we already test that may end up in wasip2, notably:
* cache dev (we already cache ino) for `is_same_object`
  * See https://github.com/WebAssembly/wasi-filesystem/pulls
* chmod for `change-file-permissions-at` and `change-directory-permissions-at`
  * See https://github.com/WebAssembly/wasi-filesystem/blob/1c65f71df8e4fd6eda6970035cd276cba03c5bcb/wit/types.wit#L651-L685


Patch:
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 940053f1..8cc40de3 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -259,9 +259,7 @@ jobs:
         uses: actions/checkout@v3
 
       - name: Install wazero
-        run: |
-          go install ./cmd/wazero
-        shell: bash
+        run: go install ./cmd/wazero
 
       - name: Checkout wasi-testsuite
         uses: actions/checkout@v3
@@ -291,37 +289,8 @@ jobs:
             -f ../.github/wasi_testsuite_skip.json \
             -r ../.github/wasi_testsuite_adapter.py
 
-  build_gojs_test_binaries:
-    name: Build Go (js) test binary
-    runs-on: ubuntu-22.04
-
-    steps:
-      - uses: actions/cache@v3
-        id: binary-cache
-        with:
-          # Use share the cache containing archives across OSes.
-          enableCrossOsArchive: true
-          key: gojs-test-binary-${{ env.GO_VERSION }}
-          path:
-            ~/bin/
-
-      - uses: actions/setup-go@v3
-        if: steps.binary-cache.outputs.cache-hit != 'true'
-        with:
-          go-version: ${{ env.GO_VERSION }}
-
-      - name: Build gojs test binaries
-        if: steps.binary-cache.outputs.cache-hit != 'true'
-        env:
-          GOOS: js
-          GOARCH: wasm
-        run: | # TODO: add more packages?
-          mkdir ~/bin && cd ~/bin
-          go test -c -o os.wasm os
-
   gojs_stdlib:
     name: Go (js) (${{ matrix.os }})
-    needs: build_gojs_test_binaries
     runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false # don't fail fast as sometimes failures are arch/OS specific
@@ -329,17 +298,6 @@ jobs:
         os: [ubuntu-22.04, macos-12] # GOOS=js isn't supposed to work on windows. See #1222
 
     steps:
-      - uses: actions/cache@v3
-        id: binary-cache
-        with:
-          # Use share the cache containing archives across OSes.
-          enableCrossOsArchive: true
-          # We need this cache to run tests.
-          fail-on-cache-miss: true
-          key: gojs-test-binary-${{ env.GO_VERSION }}
-          path:
-            ~/bin/
-
       - uses: actions/setup-go@v3
         with:
           go-version: ${{ env.GO_VERSION }}
@@ -349,11 +307,18 @@ jobs:
 
       - name: Install wazero
         run: go install ./cmd/wazero
-        shell: bash
+
+      - name: Build gojs test binaries
+        env:
+          GOOS: js
+          GOARCH: wasm
+        run: | # Only test os package as this is being replaced by GOOS=wasip1
+          mkdir ~/bin && cd ~/bin
+          go test -c -o os.wasm os
 
       - name: Run tests
-        run: |
-          cd $(go env GOROOT)/src/os; wazero run -mount=/:/ ~/bin/os.wasm -test.v
+        run: |  # skip tests that use functionality not also used in GOOS=wasip1
+          cd $(go env GOROOT)/src/os; wazero run -mount=/:/ ~/bin/os.wasm -test.v -test.skip '^Test(Chmod|Truncate|LongPath|Chown|FileChown).*$'
 
   go_tests:
     # Due to the embedding of the GOROOT of the building env(https://github.com/golang/go/blob/3c59639b902fada0a2e5a6a35bafd10fc9183b89/src/os/os_test.go#L112),
diff --git a/internal/fsapi/file.go b/internal/fsapi/file.go
index aad1b53d..ef17fa64 100644
--- a/internal/fsapi/file.go
+++ b/internal/fsapi/file.go
@@ -1,7 +1,6 @@
 package fsapi
 
 import (
-	"io/fs"
 	"syscall"
 	"time"
 )
@@ -30,8 +29,8 @@ import (
 //   - A writable filesystem abstraction is not yet implemented as of Go 1.20.
 //     See https://github.com/golang/go/issues/45757
 type File interface {
-	// Ino returns the inode (Stat_t.Ino) of this file, zero if unknown or an
-	// error there was an error retrieving it.
+	// Dev returns the device ID (Stat_t.Dev) of this file, zero if unknown or
+	// an error retrieving it.
 	//
 	// # Errors
 	//
@@ -40,9 +39,37 @@ type File interface {
 	//
 	// # Notes
 	//
-	//   - Some implementations implement this with a cached call to Stat.
+	//   - Implementations should cache this result.
+	//   - This combined with Ino can implement os.SameFile.
+	Dev() (uint64, syscall.Errno)
+
+	// Ino returns the serial number (Stat_t.Ino) of this file, zero if unknown
+	// or an error retrieving it.
+	//
+	// # Errors
+	//
+	// Possible errors are those from Stat, except syscall.ENOSYS should not
+	// be returned. Zero should be returned if there is no implementation.
+	//
+	// # Notes
+	//
+	//   - Implementations should cache this result.
+	//   - This combined with Dev can implement os.SameFile.
 	Ino() (uint64, syscall.Errno)
 
+	// IsDir returns true if this file is a directory or an error there was an
+	// error retrieving this information.
+	//
+	// # Errors
+	//
+	// Possible errors are those from Stat, except syscall.ENOSYS should not
+	// be returned. false should be returned if there is no implementation.
+	//
+	// # Notes
+	//
+	//   - Implementations should cache this result.
+	IsDir() (bool, syscall.Errno)
+
 	// IsNonblock returns true if the file was opened with O_NONBLOCK, or
 	// SetNonblock was successfully enabled on this file.
 	//
@@ -107,18 +134,6 @@ type File interface {
 	//   - Windows allows you to stat a closed directory.
 	Stat() (Stat_t, syscall.Errno)
 
-	// IsDir returns true if this file is a directory or an error there was an
-	// error retrieving this information.
-	//
-	// # Errors
-	//
-	// Possible errors are those from Stat.
-	//
-	// # Notes
-	//
-	//   - Some implementations implement this with a cached call to Stat.
-	IsDir() (bool, syscall.Errno)
-
 	// Read attempts to read all bytes in the file into `buf`, and returns the
 	// count read even on error.
 	//
@@ -313,38 +328,6 @@ type File interface {
 	//   - As this is commonly missing, some implementations dispatch to Sync.
 	Datasync() syscall.Errno
 
-	// Chmod changes the mode of the file.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EBADF: the file or directory was closed.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Fchmod and `fchmod` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchmod.html
-	//   - Windows ignores the execute bit, and any permissions come back as
-	//     group and world. For example, chmod of 0400 reads back as 0444, and
-	//     0700 0666. Also, permissions on directories aren't supported at all.
-	Chmod(fs.FileMode) syscall.Errno
-
-	// Chown changes the owner and group of a file.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EBADF: the file or directory was closed.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Fchown and `fchown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/fchown.html
-	//   - This always returns syscall.ENOSYS on windows.
-	Chown(uid, gid int) syscall.Errno
-
 	// Utimens set file access and modification times of this file, at
 	// nanosecond precision.
 	//
diff --git a/internal/fsapi/fs.go b/internal/fsapi/fs.go
index 9a46bfe6..5f325d92 100644
--- a/internal/fsapi/fs.go
+++ b/internal/fsapi/fs.go
@@ -26,15 +26,6 @@ import (
 // A writable filesystem abstraction is not yet implemented as of Go 1.20. See
 // https://github.com/golang/go/issues/45757
 type FS interface {
-	// String should return a human-readable format of the filesystem
-	//
-	// For example, if this filesystem is backed by the real directory
-	// "/tmp/wasm", the expected value is "/tmp/wasm".
-	//
-	// When the host filesystem isn't a real filesystem, substitute a symbolic,
-	// human-readable name. e.g. "virtual"
-	String() string
-
 	// OpenFile opens a file. It should be closed via Close on File.
 	//
 	// # Errors
@@ -69,8 +60,6 @@ type FS interface {
 	//   - This is like `open` in POSIX. See
 	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html
 	OpenFile(path string, flag int, perm fs.FileMode) (File, syscall.Errno)
-	// ^^ TODO: Consider syscall.Open, though this implies defining and
-	// coercing flags and perms similar to what is done in os.OpenFile.
 
 	// Lstat gets file status without following symbolic links.
 	//
@@ -130,8 +119,6 @@ type FS interface {
 	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/mkdir.html
 	//   - Implications of permissions are described in Chmod notes.
 	Mkdir(path string, perm fs.FileMode) syscall.Errno
-	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
-	// coercing flags and perms similar to what is done in os.Mkdir.
 
 	// Chmod changes the mode of the file.
 	//
@@ -153,42 +140,6 @@ type FS interface {
 	//     0700 0666. Also, permissions on directories aren't supported at all.
 	Chmod(path string, perm fs.FileMode) syscall.Errno
 
-	// Chown changes the owner and group of a file.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` does not exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Chown, except the `path` is relative to this
-	//     file system.
-	//   - This is like `chown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/chown.html
-	//   - This always returns syscall.ENOSYS on windows.
-	Chown(path string, uid, gid int) syscall.Errno
-
-	// Lchown changes the owner and group of a symbolic link.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` does not exist.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Lchown, except the `path` is relative to this
-	//     file system.
-	//   - This is like `lchown` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/lchown.html
-	//   - Windows will always return syscall.ENOSYS
-	Lchown(path string, uid, gid int) syscall.Errno
-
 	// Rename renames file or directory.
 	//
 	// # Errors
@@ -316,25 +267,6 @@ type FS interface {
 	//     separator.
 	Readlink(path string) (string, syscall.Errno)
 
-	// Truncate truncates a file to a specified length.
-	//
-	// # Errors
-	//
-	// A zero syscall.Errno is success. The below are expected otherwise:
-	//   - syscall.ENOSYS: the implementation does not support this function.
-	//   - syscall.EINVAL: `path` is invalid or size is negative.
-	//   - syscall.ENOENT: `path` doesn't exist.
-	//   - syscall.EISDIR: `path` is a directory.
-	//   - syscall.EACCES: `path` doesn't have write access.
-	//
-	// # Notes
-	//
-	//   - This is like syscall.Truncate, except the path is relative to this
-	//     filesystem.
-	//   - This is like `truncate` in POSIX. See
-	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/truncate.html
-	Truncate(path string, size int64) syscall.Errno
-
 	// Utimens set file access and modification times on a path relative to
 	// this file system, at nanosecond precision.
 	//
diff --git a/internal/fsapi/stat.go b/internal/fsapi/stat.go
index 3901a6af..bd5584c3 100644
--- a/internal/fsapi/stat.go
+++ b/internal/fsapi/stat.go
@@ -3,7 +3,7 @@ package fsapi
 import "io/fs"
 
 // Stat_t is similar to syscall.Stat_t, and fields frequently used by
-// WebAssembly ABI including WASI snapshot-01, GOOS=js and wasi-filesystem.
+// WebAssembly ABI including wasip1 and wasi-filesystem (a.k.a. wasip2).
 //
 // # Note
 //
diff --git a/internal/fsapi/unimplemented.go b/internal/fsapi/unimplemented.go
index 7a57468b..d69f41fa 100644
--- a/internal/fsapi/unimplemented.go
+++ b/internal/fsapi/unimplemented.go
@@ -50,16 +50,6 @@ func (UnimplementedFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	return syscall.ENOSYS
 }
 
-// Chown implements FS.Chown
-func (UnimplementedFS) Chown(path string, uid, gid int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Lchown implements FS.Lchown
-func (UnimplementedFS) Lchown(path string, uid, gid int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
 // Rename implements FS.Rename
 func (UnimplementedFS) Rename(from, to string) syscall.Errno {
 	return syscall.ENOSYS
@@ -101,11 +91,21 @@ func (UnimplementedFS) Truncate(string, int64) syscall.Errno {
 // This should be embedded to have forward compatible implementations.
 type UnimplementedFile struct{}
 
+// Dev implements File.Dev
+func (UnimplementedFile) Dev() (uint64, syscall.Errno) {
+	return 0, 0
+}
+
 // Ino implements File.Ino
 func (UnimplementedFile) Ino() (uint64, syscall.Errno) {
 	return 0, 0
 }
 
+// IsDir implements File.IsDir
+func (UnimplementedFile) IsDir() (bool, syscall.Errno) {
+	return false, 0
+}
+
 // IsAppend implements File.IsAppend
 func (UnimplementedFile) IsAppend() bool {
 	return false
@@ -131,11 +131,6 @@ func (UnimplementedFile) Stat() (Stat_t, syscall.Errno) {
 	return Stat_t{}, syscall.ENOSYS
 }
 
-// IsDir implements File.IsDir
-func (UnimplementedFile) IsDir() (bool, syscall.Errno) {
-	return false, syscall.ENOSYS
-}
-
 // Read implements File.Read
 func (UnimplementedFile) Read([]byte) (int, syscall.Errno) {
 	return 0, syscall.ENOSYS
@@ -186,16 +181,6 @@ func (UnimplementedFile) Datasync() syscall.Errno {
 	return 0 // not syscall.ENOSYS
 }
 
-// Chmod implements File.Chmod
-func (UnimplementedFile) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chown implements File.Chown
-func (UnimplementedFile) Chown(int, int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
 // Utimens implements File.Utimens
 func (UnimplementedFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.ENOSYS
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 3ecd5d0c..6635af0d 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -464,16 +464,16 @@ type jsfsFchmod struct{}
 
 func (jsfsFchmod) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
 	fd := goos.ValueToInt32(args[0])
-	mode := custom.FromJsMode(goos.ValueToUint32(args[1]), 0)
+	_ = args[1] // mode
 	callback := args[2].(funcWrapper)
 
 	// Check to see if the file descriptor is available
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 	var errno syscall.Errno
-	if f, ok := fsc.LookupFile(fd); !ok {
+	if _, ok := fsc.LookupFile(fd); !ok {
 		errno = syscall.EBADF
 	} else {
-		errno = f.File.Chmod(mode)
+		errno = syscall.ENOSYS // We only support functions used in wasip1
 	}
 
 	return jsfsInvoke(ctx, mod, callback, errno)
@@ -487,13 +487,12 @@ type jsfsChown struct {
 }
 
 func (c *jsfsChown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := util.ResolvePath(c.proc.cwd, args[0].(string))
-	uid := goos.ValueToInt32(args[1])
-	gid := goos.ValueToInt32(args[2])
+	_ = args[0] // path
+	_ = args[1] // uid
+	_ = args[2] // gid
 	callback := args[3].(funcWrapper)
 
-	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
-	errno := fsc.RootFS().Chown(path, int(uid), int(gid))
+	errno := syscall.ENOSYS // We only support functions used in wasip1
 
 	return jsfsInvoke(ctx, mod, callback, errno)
 }
@@ -505,17 +504,17 @@ type jsfsFchown struct{}
 
 func (jsfsFchown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
 	fd := goos.ValueToInt32(args[0])
-	uid := goos.ValueToUint32(args[1])
-	gid := goos.ValueToUint32(args[2])
+	_ = args[1] // uid
+	_ = args[2] // gid
 	callback := args[3].(funcWrapper)
 
 	// Check to see if the file descriptor is available
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 	var errno syscall.Errno
-	if f, ok := fsc.LookupFile(fd); !ok {
+	if _, ok := fsc.LookupFile(fd); !ok {
 		errno = syscall.EBADF
 	} else {
-		errno = f.File.Chown(int(uid), int(gid))
+		errno = syscall.ENOSYS // We only support functions used in wasip1
 	}
 
 	return jsfsInvoke(ctx, mod, callback, errno)
@@ -529,13 +528,12 @@ type jsfsLchown struct {
 }
 
 func (l *jsfsLchown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := util.ResolvePath(l.proc.cwd, args[0].(string))
-	uid := goos.ValueToUint32(args[1])
-	gid := goos.ValueToUint32(args[2])
+	_ = args[0] // path
+	_ = args[1] // uid
+	_ = args[2] // gid
 	callback := args[3].(funcWrapper)
 
-	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
-	errno := fsc.RootFS().Lchown(path, int(uid), int(gid))
+	errno := syscall.ENOSYS // We only support functions used in wasip1
 
 	return jsfsInvoke(ctx, mod, callback, errno)
 }
@@ -548,12 +546,11 @@ type jsfsTruncate struct {
 }
 
 func (t *jsfsTruncate) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := util.ResolvePath(t.proc.cwd, args[0].(string))
-	length := toInt64(args[1])
+	_ = args[0] // path
+	_ = args[1] // length
 	callback := args[2].(funcWrapper)
 
-	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
-	errno := fsc.RootFS().Truncate(path, length)
+	errno := syscall.ENOSYS // We only support functions used in wasip1
 
 	return jsfsInvoke(ctx, mod, callback, errno)
 }
diff --git a/internal/sys/lazy.go b/internal/sys/lazy.go
index 5ce6ae22..024b855b 100644
--- a/internal/sys/lazy.go
+++ b/internal/sys/lazy.go
@@ -1,7 +1,6 @@
 package sys
 
 import (
-	"io/fs"
 	"os"
 	"syscall"
 
@@ -18,6 +17,15 @@ type lazyDir struct {
 	f  fsapi.File
 }
 
+// Dev implements the same method as documented on fsapi.File
+func (r *lazyDir) Dev() (uint64, syscall.Errno) {
+	if f, ok := r.file(); !ok {
+		return 0, syscall.EBADF
+	} else {
+		return f.Dev()
+	}
+}
+
 // Ino implements the same method as documented on fsapi.File
 func (r *lazyDir) Ino() (uint64, syscall.Errno) {
 	if f, ok := r.file(); !ok {
@@ -27,6 +35,17 @@ func (r *lazyDir) Ino() (uint64, syscall.Errno) {
 	}
 }
 
+// IsDir implements the same method as documented on fsapi.File
+func (r *lazyDir) IsDir() (bool, syscall.Errno) {
+	// Note: we don't return a constant because we don't know if this is really
+	// backed by a dir, until the first call.
+	if f, ok := r.file(); !ok {
+		return false, syscall.EBADF
+	} else {
+		return f.IsDir()
+	}
+}
+
 // IsAppend implements the same method as documented on fsapi.File
 func (r *lazyDir) IsAppend() bool {
 	return false
@@ -82,24 +101,6 @@ func (r *lazyDir) Datasync() syscall.Errno {
 	}
 }
 
-// Chmod implements the same method as documented on fsapi.File
-func (r *lazyDir) Chmod(mode fs.FileMode) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chmod(mode)
-	}
-}
-
-// Chown implements the same method as documented on fsapi.File
-func (r *lazyDir) Chown(uid, gid int) syscall.Errno {
-	if f, ok := r.file(); !ok {
-		return syscall.EBADF
-	} else {
-		return f.Chown(uid, gid)
-	}
-}
-
 // Utimens implements the same method as documented on fsapi.File
 func (r *lazyDir) Utimens(times *[2]syscall.Timespec) syscall.Errno {
 	if f, ok := r.file(); !ok {
diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go
index 022ca697..c225c8dc 100644
--- a/internal/sysfs/adapter.go
+++ b/internal/sysfs/adapter.go
@@ -9,7 +9,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/fsapi"
 )
 
-// Adapt adapts the input to api.FS unless it is already one. Use NewDirFS instead
+// Adapt adapts the input to fsapi.FS unless it is already one. Use NewDirFS instead
 // of os.DirFS as it handles interop issues such as windows support.
 //
 // Note: This performs no flag verification on OpenFile. fsapi.FS cannot read
@@ -36,12 +36,12 @@ func (a *adapter) String() string {
 	return fmt.Sprintf("%v", a.fs)
 }
 
-// OpenFile implements the same method as documented on api.FS
+// OpenFile implements the same method as documented on fsapi.FS
 func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
 	return OpenFSFile(a.fs, cleanPath(path), flag, perm)
 }
 
-// Stat implements the same method as documented on api.FS
+// Stat implements the same method as documented on fsapi.FS
 func (a *adapter) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	f, errno := a.OpenFile(path, syscall.O_RDONLY, 0)
 	if errno != 0 {
@@ -51,11 +51,11 @@ func (a *adapter) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return f.Stat()
 }
 
-// Lstat implements the same method as documented on api.FS
+// Lstat implements the same method as documented on fsapi.FS
 func (a *adapter) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
-	// At this time, we make the assumption that api.FS instances do not support
+	// At this time, we make the assumption that fsapi.FS instances do not support
 	// symbolic links, therefore Lstat is the same as Stat. This is obviously
-	// not true but until api.FS has a solid story for how to handle symlinks we
+	// not true but until fsapi.FS has a solid story for how to handle symlinks we
 	// are better off not making a decision that would be difficult to revert
 	// later on.
 	//
diff --git a/internal/sysfs/chown.go b/internal/sysfs/chown.go
deleted file mode 100644
index 93c774c9..00000000
--- a/internal/sysfs/chown.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package sysfs
-
-import (
-	"os"
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/platform"
-)
-
-// Chown is like os.Chown, except it returns a syscall.Errno, not a
-// fs.PathError. For example, this returns syscall.ENOENT if the path doesn't
-// exist. A syscall.Errno of zero is success.
-//
-// Note: This always returns syscall.ENOSYS on windows.
-// See https://linux.die.net/man/3/chown
-func Chown(path string, uid, gid int) syscall.Errno {
-	err := os.Chown(path, uid, gid)
-	return platform.UnwrapOSError(err)
-}
-
-// Lchown is like os.Lchown, except it returns a syscall.Errno, not a
-// fs.PathError. For example, this returns syscall.ENOENT if the path doesn't
-// exist. A syscall.Errno of zero is success.
-//
-// Note: This always returns syscall.ENOSYS on windows.
-// See https://linux.die.net/man/3/lchown
-func Lchown(path string, uid, gid int) syscall.Errno {
-	err := os.Lchown(path, uid, gid)
-	return platform.UnwrapOSError(err)
-}
diff --git a/internal/sysfs/chown_unix.go b/internal/sysfs/chown_unix.go
deleted file mode 100644
index 5907a9d9..00000000
--- a/internal/sysfs/chown_unix.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build !windows
-
-package sysfs
-
-import (
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/platform"
-)
-
-func fchown(fd uintptr, uid, gid int) syscall.Errno {
-	return platform.UnwrapOSError(syscall.Fchown(int(fd), uid, gid))
-}
diff --git a/internal/sysfs/chown_unsupported.go b/internal/sysfs/chown_unsupported.go
deleted file mode 100644
index 5e7fd79b..00000000
--- a/internal/sysfs/chown_unsupported.go
+++ /dev/null
@@ -1,11 +0,0 @@
-//go:build windows
-
-package sysfs
-
-import "syscall"
-
-// fchown is not supported on windows. For example, syscall.Fchown returns
-// syscall.EWINDOWS, which is the same as syscall.ENOSYS.
-func fchown(fd uintptr, uid, gid int) syscall.Errno {
-	return syscall.ENOSYS
-}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 5f2645de..8c89e1d9 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -36,22 +36,22 @@ func (d *dirFS) String() string {
 	return d.dir
 }
 
-// OpenFile implements the same method as documented on api.FS
+// OpenFile implements the same method as documented on fsapi.FS
 func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
 	return OpenOSFile(d.join(path), flag, perm)
 }
 
-// Lstat implements the same method as documented on api.FS
+// Lstat implements the same method as documented on fsapi.FS
 func (d *dirFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return lstat(d.join(path))
 }
 
-// Stat implements the same method as documented on api.FS
+// Stat implements the same method as documented on fsapi.FS
 func (d *dirFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return stat(d.join(path))
 }
 
-// Mkdir implements the same method as documented on api.FS
+// Mkdir implements the same method as documented on fsapi.FS
 func (d *dirFS) Mkdir(path string, perm fs.FileMode) (errno syscall.Errno) {
 	err := os.Mkdir(d.join(path), perm)
 	if errno = platform.UnwrapOSError(err); errno == syscall.ENOTDIR {
@@ -60,29 +60,19 @@ func (d *dirFS) Mkdir(path string, perm fs.FileMode) (errno syscall.Errno) {
 	return
 }
 
-// Chmod implements the same method as documented on api.FS
+// Chmod implements the same method as documented on fsapi.FS
 func (d *dirFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	err := os.Chmod(d.join(path), perm)
 	return platform.UnwrapOSError(err)
 }
 
-// Chown implements the same method as documented on api.FS
-func (d *dirFS) Chown(path string, uid, gid int) syscall.Errno {
-	return Chown(d.join(path), uid, gid)
-}
-
-// Lchown implements the same method as documented on api.FS
-func (d *dirFS) Lchown(path string, uid, gid int) syscall.Errno {
-	return Lchown(d.join(path), uid, gid)
-}
-
-// Rename implements the same method as documented on api.FS
+// Rename implements the same method as documented on fsapi.FS
 func (d *dirFS) Rename(from, to string) syscall.Errno {
 	from, to = d.join(from), d.join(to)
 	return Rename(from, to)
 }
 
-// Readlink implements the same method as documented on api.FS
+// Readlink implements the same method as documented on fsapi.FS
 func (d *dirFS) Readlink(path string) (string, syscall.Errno) {
 	// Note: do not use syscall.Readlink as that causes race on Windows.
 	// In any case, syscall.Readlink does almost the same logic as os.Readlink.
@@ -93,24 +83,24 @@ func (d *dirFS) Readlink(path string) (string, syscall.Errno) {
 	return platform.ToPosixPath(dst), 0
 }
 
-// Link implements the same method as documented on api.FS
+// Link implements the same method as documented on fsapi.FS
 func (d *dirFS) Link(oldName, newName string) syscall.Errno {
 	err := os.Link(d.join(oldName), d.join(newName))
 	return platform.UnwrapOSError(err)
 }
 
-// Rmdir implements the same method as documented on api.FS
+// Rmdir implements the same method as documented on fsapi.FS
 func (d *dirFS) Rmdir(path string) syscall.Errno {
 	err := syscall.Rmdir(d.join(path))
 	return platform.UnwrapOSError(err)
 }
 
-// Unlink implements the same method as documented on api.FS
+// Unlink implements the same method as documented on fsapi.FS
 func (d *dirFS) Unlink(path string) (err syscall.Errno) {
 	return Unlink(d.join(path))
 }
 
-// Symlink implements the same method as documented on api.FS
+// Symlink implements the same method as documented on fsapi.FS
 func (d *dirFS) Symlink(oldName, link string) syscall.Errno {
 	// Note: do not resolve `oldName` relative to this dirFS. The link result is always resolved
 	// when dereference the `link` on its usage (e.g. readlink, read, etc).
@@ -119,18 +109,11 @@ func (d *dirFS) Symlink(oldName, link string) syscall.Errno {
 	return platform.UnwrapOSError(err)
 }
 
-// Utimens implements the same method as documented on api.FS
+// Utimens implements the same method as documented on fsapi.FS
 func (d *dirFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
 	return Utimens(d.join(path), times, symlinkFollow)
 }
 
-// Truncate implements the same method as documented on api.FS
-func (d *dirFS) Truncate(path string, size int64) syscall.Errno {
-	// Use os.Truncate as syscall.Truncate doesn't exist on Windows.
-	err := os.Truncate(d.join(path), size)
-	return platform.UnwrapOSError(err)
-}
-
 func (d *dirFS) join(path string) string {
 	switch path {
 	case "", ".", "/":
diff --git a/internal/sysfs/file.go b/internal/sysfs/file.go
index 07deca23..74ef5170 100644
--- a/internal/sysfs/file.go
+++ b/internal/sysfs/file.go
@@ -80,11 +80,6 @@ func (f *stdioFile) IsAppend() bool {
 	return true
 }
 
-// IsDir implements File.IsDir
-func (f *stdioFile) IsDir() (bool, syscall.Errno) {
-	return false, 0
-}
-
 // Stat implements File.Stat
 func (f *stdioFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	return f.st, 0
@@ -125,31 +120,43 @@ type fsFile struct {
 }
 
 type cachedStat struct {
-	// fileType is the same as what's documented on Dirent.
-	fileType fs.FileMode
+	// dev is the same as fsapi.Stat_t Dev.
+	dev uint64
 
-	// ino is the same as what's documented on Dirent.
+	// dev is the same as fsapi.Stat_t Ino.
 	ino uint64
+
+	// isDir is fsapi.Stat_t Mode masked with fs.ModeDir
+	isDir bool
 }
 
-// cachedStat returns the cacheable parts of platform.sys.Stat_t or an error if
-// they couldn't be retrieved.
-func (f *fsFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.Errno) {
+// cachedStat returns the cacheable parts of fsapi.Stat_t or an error if they
+// couldn't be retrieved.
+func (f *fsFile) cachedStat() (dev, ino uint64, isDir bool, errno syscall.Errno) {
 	if f.cachedSt == nil {
 		if _, errno = f.Stat(); errno != 0 {
 			return
 		}
 	}
-	return f.cachedSt.fileType, f.cachedSt.ino, 0
+	return f.cachedSt.dev, f.cachedSt.ino, f.cachedSt.isDir, 0
+}
+
+// Dev implements the same method as documented on fsapi.File
+func (f *fsFile) Dev() (uint64, syscall.Errno) {
+	dev, _, _, errno := f.cachedStat()
+	return dev, errno
 }
 
 // Ino implements the same method as documented on fsapi.File
 func (f *fsFile) Ino() (uint64, syscall.Errno) {
-	if _, ino, errno := f.cachedStat(); errno != 0 {
-		return 0, errno
-	} else {
-		return ino, 0
-	}
+	_, ino, _, errno := f.cachedStat()
+	return ino, errno
+}
+
+// IsDir implements the same method as documented on fsapi.File
+func (f *fsFile) IsDir() (bool, syscall.Errno) {
+	_, _, isDir, errno := f.cachedStat()
+	return isDir, errno
 }
 
 // IsAppend implements the same method as documented on fsapi.File
@@ -162,16 +169,6 @@ func (f *fsFile) SetAppend(bool) (errno syscall.Errno) {
 	return fileError(f, f.closed, syscall.ENOSYS)
 }
 
-// IsDir implements the same method as documented on fsapi.File
-func (f *fsFile) IsDir() (bool, syscall.Errno) {
-	if ft, _, errno := f.cachedStat(); errno != 0 {
-		return false, errno
-	} else if ft.Type() == fs.ModeDir {
-		return true, 0
-	}
-	return false, 0
-}
-
 // Stat implements the same method as documented on fsapi.File
 func (f *fsFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	if f.closed {
@@ -181,7 +178,7 @@ func (f *fsFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	st, errno := statFile(f.file)
 	switch errno {
 	case 0:
-		f.cachedSt = &cachedStat{fileType: st.Mode & fs.ModeType, ino: st.Ino}
+		f.cachedSt = &cachedStat{dev: st.Dev, ino: st.Ino, isDir: st.Mode&fs.ModeDir == fs.ModeDir}
 	case syscall.EIO:
 		errno = syscall.EBADF
 	}
diff --git a/internal/sysfs/osfile.go b/internal/sysfs/osfile.go
index 60ecdee9..2b15928e 100644
--- a/internal/sysfs/osfile.go
+++ b/internal/sysfs/osfile.go
@@ -42,24 +42,33 @@ type osFile struct {
 	cachedSt *cachedStat
 }
 
-// cachedStat returns the cacheable parts of platform.sys.Stat_t or an error if
-// they couldn't be retrieved.
-func (f *osFile) cachedStat() (fileType fs.FileMode, ino uint64, errno syscall.Errno) {
+// cachedStat returns the cacheable parts of fsapi.Stat_t or an error if they
+// couldn't be retrieved.
+func (f *osFile) cachedStat() (dev, ino uint64, isDir bool, errno syscall.Errno) {
 	if f.cachedSt == nil {
 		if _, errno = f.Stat(); errno != 0 {
 			return
 		}
 	}
-	return f.cachedSt.fileType, f.cachedSt.ino, 0
+	return f.cachedSt.dev, f.cachedSt.ino, f.cachedSt.isDir, 0
+}
+
+// Dev implements the same method as documented on fsapi.File
+func (f *osFile) Dev() (uint64, syscall.Errno) {
+	dev, _, _, errno := f.cachedStat()
+	return dev, errno
 }
 
 // Ino implements the same method as documented on fsapi.File
 func (f *osFile) Ino() (uint64, syscall.Errno) {
-	if _, ino, errno := f.cachedStat(); errno != 0 {
-		return 0, errno
-	} else {
-		return ino, 0
-	}
+	_, ino, _, errno := f.cachedStat()
+	return ino, errno
+}
+
+// IsDir implements the same method as documented on fsapi.File
+func (f *osFile) IsDir() (bool, syscall.Errno) {
+	_, _, isDir, errno := f.cachedStat()
+	return isDir, errno
 }
 
 // IsAppend implements File.IsAppend
@@ -113,16 +122,6 @@ func (f *osFile) SetNonblock(enable bool) (errno syscall.Errno) {
 	return 0
 }
 
-// IsDir implements the same method as documented on fsapi.File
-func (f *osFile) IsDir() (bool, syscall.Errno) {
-	if ft, _, errno := f.cachedStat(); errno != 0 {
-		return false, errno
-	} else if ft.Type() == fs.ModeDir {
-		return true, 0
-	}
-	return false, 0
-}
-
 // Stat implements the same method as documented on fsapi.File
 func (f *osFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	if f.closed {
@@ -132,7 +131,7 @@ func (f *osFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	st, errno := statFile(f.file)
 	switch errno {
 	case 0:
-		f.cachedSt = &cachedStat{fileType: st.Mode & fs.ModeType, ino: st.Ino}
+		f.cachedSt = &cachedStat{dev: st.Dev, ino: st.Ino, isDir: st.Mode&fs.ModeDir == fs.ModeDir}
 	case syscall.EIO:
 		errno = syscall.EBADF
 	}
@@ -248,24 +247,6 @@ func (f *osFile) Datasync() syscall.Errno {
 	return datasync(f.file)
 }
 
-// Chmod implements the same method as documented on fsapi.File
-func (f *osFile) Chmod(mode fs.FileMode) syscall.Errno {
-	if f.closed {
-		return syscall.EBADF
-	}
-
-	return platform.UnwrapOSError(f.file.Chmod(mode))
-}
-
-// Chown implements the same method as documented on fsapi.File
-func (f *osFile) Chown(uid, gid int) syscall.Errno {
-	if f.closed {
-		return syscall.EBADF
-	}
-
-	return fchown(f.fd, uid, gid)
-}
-
 // Utimens implements the same method as documented on fsapi.File
 func (f *osFile) Utimens(times *[2]syscall.Timespec) syscall.Errno {
 	if f.closed {
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index b83778f9..6457b991 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -9,7 +9,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/fsapi"
 )
 
-// NewReadFS is used to mask an existing api.FS for reads. Notably, this allows
+// NewReadFS is used to mask an existing fsapi.FS for reads. Notably, this allows
 // the CLI to do read-only mounts of directories the host user can write, but
 // doesn't want the guest wasm to. For example, Python libraries shouldn't be
 // written to at runtime by the python wasm file.
@@ -26,12 +26,7 @@ type readFS struct {
 	fs fsapi.FS
 }
 
-// String implements fmt.Stringer
-func (r *readFS) String() string {
-	return r.fs.String()
-}
-
-// OpenFile implements the same method as documented on api.FS
+// OpenFile implements the same method as documented on fsapi.FS
 func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
 	// TODO: Once the real implementation is complete, move the below to
 	// /RATIONALE.md. Doing this while the type is unstable creates
@@ -75,11 +70,21 @@ type readFile struct {
 	f fsapi.File
 }
 
+// Dev implements the same method as documented on fsapi.File.
+func (r *readFile) Dev() (uint64, syscall.Errno) {
+	return r.f.Dev()
+}
+
 // Ino implements the same method as documented on fsapi.File.
 func (r *readFile) Ino() (uint64, syscall.Errno) {
 	return r.f.Ino()
 }
 
+// IsDir implements the same method as documented on fsapi.File.
+func (r *readFile) IsDir() (bool, syscall.Errno) {
+	return r.f.IsDir()
+}
+
 // IsNonblock implements the same method as documented on fsapi.File.
 func (r *readFile) IsNonblock() bool {
 	return r.f.IsNonblock()
@@ -105,11 +110,6 @@ func (r *readFile) Stat() (fsapi.Stat_t, syscall.Errno) {
 	return r.f.Stat()
 }
 
-// IsDir implements the same method as documented on fsapi.File.
-func (r *readFile) IsDir() (bool, syscall.Errno) {
-	return r.f.IsDir()
-}
-
 // Read implements the same method as documented on fsapi.File.
 func (r *readFile) Read(buf []byte) (int, syscall.Errno) {
 	return r.f.Read(buf)
@@ -155,16 +155,6 @@ func (r *readFile) Datasync() syscall.Errno {
 	return syscall.EBADF
 }
 
-// Chmod implements the same method as documented on fsapi.File.
-func (r *readFile) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.EBADF
-}
-
-// Chown implements the same method as documented on fsapi.File.
-func (r *readFile) Chown(int, int) syscall.Errno {
-	return syscall.EBADF
-}
-
 // Utimens implements the same method as documented on fsapi.File.
 func (r *readFile) Utimens(*[2]syscall.Timespec) syscall.Errno {
 	return syscall.EBADF
@@ -189,72 +179,57 @@ func (r *readFile) PollRead(timeout *time.Duration) (ready bool, errno syscall.E
 	return r.f.PollRead(timeout)
 }
 
-// Lstat implements the same method as documented on api.FS
+// Lstat implements the same method as documented on fsapi.FS
 func (r *readFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return r.fs.Lstat(path)
 }
 
-// Stat implements the same method as documented on api.FS
+// Stat implements the same method as documented on fsapi.FS
 func (r *readFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
 	return r.fs.Stat(path)
 }
 
-// Readlink implements the same method as documented on api.FS
+// Readlink implements the same method as documented on fsapi.FS
 func (r *readFS) Readlink(path string) (dst string, err syscall.Errno) {
 	return r.fs.Readlink(path)
 }
 
-// Mkdir implements the same method as documented on api.FS
+// Mkdir implements the same method as documented on fsapi.FS
 func (r *readFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Chmod implements the same method as documented on api.FS
+// Chmod implements the same method as documented on fsapi.FS
 func (r *readFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Chown implements the same method as documented on api.FS
-func (r *readFS) Chown(path string, uid, gid int) syscall.Errno {
-	return syscall.EROFS
-}
-
-// Lchown implements the same method as documented on api.FS
-func (r *readFS) Lchown(path string, uid, gid int) syscall.Errno {
-	return syscall.EROFS
-}
-
-// Rename implements the same method as documented on api.FS
+// Rename implements the same method as documented on fsapi.FS
 func (r *readFS) Rename(from, to string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Rmdir implements the same method as documented on api.FS
+// Rmdir implements the same method as documented on fsapi.FS
 func (r *readFS) Rmdir(path string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Link implements the same method as documented on api.FS
+// Link implements the same method as documented on fsapi.FS
 func (r *readFS) Link(_, _ string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Symlink implements the same method as documented on api.FS
+// Symlink implements the same method as documented on fsapi.FS
 func (r *readFS) Symlink(_, _ string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Unlink implements the same method as documented on api.FS
+// Unlink implements the same method as documented on fsapi.FS
 func (r *readFS) Unlink(path string) syscall.Errno {
 	return syscall.EROFS
 }
 
-// Utimens implements the same method as documented on api.FS
+// Utimens implements the same method as documented on fsapi.FS
 func (r *readFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
 	return syscall.EROFS
 }
-
-// Truncate implements the same method as documented on api.FS
-func (r *readFS) Truncate(string, int64) syscall.Errno {
-	return syscall.EROFS
-}


Test patch:
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index a04d2d02..1766044e 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -72,19 +72,11 @@ func Main() {
 	if err = f.Sync(); err != nil {
 		log.Panicln(err)
 	}
-	// Next, chmod it (tests Fchmod)
-	if err = f.Chmod(0o400); err != nil {
-		log.Panicln(err)
-	}
-	if stat, err := f.Stat(); err != nil {
-		log.Panicln(err)
-	} else if mode := stat.Mode() & fs.ModePerm; mode != 0o400 {
-		log.Panicln("expected mode = 0o400", mode)
-	}
 
-	// Invoke Chown in a way nothing changes, to ensure it doesn't panic.
-	if err = f.Chown(-1, -1); err != nil {
+	if stat, err := f.Stat(); err != nil {
 		log.Panicln(err)
+	} else if mode := stat.Mode() & fs.ModePerm; mode != 0o600 {
+		log.Panicln("expected mode = 0o600", mode)
 	}
 
 	// Finally, close it.
@@ -92,8 +84,8 @@ func Main() {
 		log.Panicln(err)
 	}
 
-	// Revert to writeable
-	if err = syscall.Chmod(file1, 0o600); err != nil {
+	// Set to read-only
+	if err = syscall.Chmod(file1, 0o400); err != nil {
 		log.Panicln(err)
 	}
 
@@ -103,16 +95,11 @@ func Main() {
 		log.Panicln(err)
 	}
 
-	// Invoke Chown in a way nothing changes, to ensure it doesn't panic.
-	if err = os.Chown(file1, -1, -1); err != nil {
-		log.Panicln(err)
-	}
-
 	if stat.Mode().Type() != 0 {
 		log.Panicln("expected type = 0", stat.Mode().Type())
 	}
-	if stat.Mode().Perm() != 0o600 {
-		log.Panicln("expected perm = 0o600", stat.Mode().Perm())
+	if stat.Mode().Perm() != 0o400 {
+		log.Panicln("expected perm = 0o400", stat.Mode().Perm())
 	}
 
 	// Check the file was truncated.
@@ -122,15 +109,6 @@ func Main() {
 		log.Panicln("unexpected contents:", string(bytes))
 	}
 
-	// Now, truncate it by path
-	if err = os.Truncate(file1, 1); err != nil {
-		log.Panicln(err)
-	} else if bytes, err := os.ReadFile(file1); err != nil {
-		log.Panicln(err)
-	} else if string(bytes) != "w" {
-		log.Panicln("unexpected contents:", string(bytes))
-	}
-
 	// create a hard link
 	link := file1 + "-link"
 	if err = os.Link(file1, link); err != nil {
@@ -180,11 +158,6 @@ func Main() {
 		log.Panicln("expected file != link stat", file1Stat, symlinkStat)
 	}
 
-	// Invoke Lchown in a way nothing changes, to ensure it doesn't panic.
-	if err = os.Lchown(symlink, -1, -1); err != nil {
-		log.Panicln(err)
-	}
-
 	// Test removing a non-empty empty directory
 	if err = syscall.Rmdir(dir); err != syscall.ENOTEMPTY {
 		log.Panicln("unexpected error", err)
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 9689d3d3..00989bf0 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -22,11 +22,6 @@ func TestAdapt_nil(t *testing.T) {
 	require.True(t, ok)
 }
 
-func TestAdapt_String(t *testing.T) {
-	testFS := Adapt(os.DirFS("."))
-	require.Equal(t, ".", testFS.String())
-}
-
 func TestAdapt_MkDir(t *testing.T) {
 	testFS := Adapt(os.DirFS(t.TempDir()))
 
diff --git a/internal/sysfs/chown_unix_test.go b/internal/sysfs/chown_unix_test.go
deleted file mode 100644
index e0fae498..00000000
--- a/internal/sysfs/chown_unix_test.go
+++ /dev/null
@@ -1,168 +0,0 @@
-//go:build !windows
-
-package sysfs
-
-import (
-	"fmt"
-	"os"
-	"path"
-	"syscall"
-	"testing"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-func TestChown(t *testing.T) {
-	tmpDir := t.TempDir()
-
-	dir := path.Join(tmpDir, "dir")
-	require.NoError(t, os.Mkdir(dir, 0o0777))
-
-	dirF := requireOpenFile(t, dir, syscall.O_RDONLY, 0)
-	defer dirF.Close()
-
-	dirSt, errno := dirF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	// Similar to TestChown in os_unix_test.go, we can't expect to change
-	// owner unless root, and with another user. Instead, test gid.
-	gid := os.Getgid()
-	groups, err := os.Getgroups()
-	require.NoError(t, err)
-
-	t.Run("-1 parameters means leave alone", func(t *testing.T) {
-		require.EqualErrno(t, 0, Chown(dir, -1, -1))
-		checkUidGid(t, dir, dirSt.Uid, dirSt.Gid)
-	})
-
-	t.Run("change gid, but not uid", func(t *testing.T) {
-		require.EqualErrno(t, 0, Chown(dir, -1, gid))
-		checkUidGid(t, dir, dirSt.Uid, uint32(gid))
-	})
-
-	// Now, try any other groups of the current user.
-	for _, g := range groups {
-		g := g
-		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
-			// Test using our Chown
-			require.EqualErrno(t, 0, Chown(dir, -1, g))
-			checkUidGid(t, dir, dirSt.Uid, uint32(g))
-
-			// Revert back
-			require.EqualErrno(t, 0, dirF.Chown(-1, gid))
-			checkUidGid(t, dir, dirSt.Uid, uint32(gid))
-		})
-	}
-
-	t.Run("not found", func(t *testing.T) {
-		require.EqualErrno(t, syscall.ENOENT, Chown(path.Join(tmpDir, "a"), -1, gid))
-	})
-}
-
-func TestDefaultFileChown(t *testing.T) {
-	tmpDir := t.TempDir()
-
-	dir := path.Join(tmpDir, "dir")
-	require.NoError(t, os.Mkdir(dir, 0o0777))
-
-	dirF := requireOpenFile(t, dir, syscall.O_RDONLY, 0)
-	defer dirF.Close()
-
-	dirSt, errno := dirF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	// Similar to TestChownFile in os_unix_test.go, we can't expect to change
-	// owner unless root, and with another user. Instead, test gid.
-	gid := os.Getgid()
-	groups, err := os.Getgroups()
-	require.NoError(t, err)
-
-	t.Run("-1 parameters means leave alone", func(t *testing.T) {
-		require.EqualErrno(t, 0, dirF.Chown(-1, -1))
-		checkUidGid(t, dir, dirSt.Uid, dirSt.Gid)
-	})
-
-	t.Run("change gid, but not uid", func(t *testing.T) {
-		require.EqualErrno(t, 0, dirF.Chown(-1, gid))
-		checkUidGid(t, dir, dirSt.Uid, uint32(gid))
-	})
-
-	// Now, try any other groups of the current user.
-	for _, g := range groups {
-		g := g
-		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
-			// Test using our Chown
-			require.EqualErrno(t, 0, dirF.Chown(-1, g))
-			checkUidGid(t, dir, dirSt.Uid, uint32(g))
-
-			// Revert back
-			require.EqualErrno(t, 0, dirF.Chown(-1, gid))
-			checkUidGid(t, dir, dirSt.Uid, uint32(gid))
-		})
-	}
-
-	t.Run("closed", func(t *testing.T) {
-		require.EqualErrno(t, 0, dirF.Close())
-		require.EqualErrno(t, syscall.EBADF, dirF.Chown(-1, gid))
-	})
-}
-
-func TestLchown(t *testing.T) {
-	tmpDir := t.TempDir()
-
-	dir := path.Join(tmpDir, "dir")
-	require.NoError(t, os.Mkdir(dir, 0o0777))
-
-	dirF := requireOpenFile(t, dir, syscall.O_RDONLY, 0)
-	defer dirF.Close()
-
-	dirSt, errno := dirF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	link := path.Join(tmpDir, "link")
-	require.NoError(t, os.Symlink(dir, link))
-
-	linkF := requireOpenFile(t, link, syscall.O_RDONLY, 0)
-	defer linkF.Close()
-
-	linkSt, errno := linkF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	// Similar to TestLchown in os_unix_test.go, we can't expect to change
-	// owner unless root, and with another user. Instead, test gid.
-	gid := os.Getgid()
-	groups, err := os.Getgroups()
-	require.NoError(t, err)
-
-	t.Run("-1 parameters means leave alone", func(t *testing.T) {
-		require.EqualErrno(t, 0, Lchown(link, -1, -1))
-		checkUidGid(t, link, linkSt.Uid, linkSt.Gid)
-	})
-
-	t.Run("change gid, but not uid", func(t *testing.T) {
-		require.EqualErrno(t, 0, Chown(dir, -1, gid))
-		checkUidGid(t, link, linkSt.Uid, uint32(gid))
-		// Make sure the target didn't change.
-		checkUidGid(t, dir, dirSt.Uid, dirSt.Gid)
-	})
-
-	// Now, try any other groups of the current user.
-	for _, g := range groups {
-		g := g
-		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
-			// Test using our Lchown
-			require.EqualErrno(t, 0, Lchown(link, -1, g))
-			checkUidGid(t, link, linkSt.Uid, uint32(g))
-			// Make sure the target didn't change.
-			checkUidGid(t, dir, dirSt.Uid, dirSt.Gid)
-
-			// Revert back
-			require.EqualErrno(t, 0, Lchown(link, -1, gid))
-			checkUidGid(t, link, linkSt.Uid, uint32(gid))
-		})
-	}
-
-	t.Run("not found", func(t *testing.T) {
-		require.EqualErrno(t, syscall.ENOENT, Lchown(path.Join(tmpDir, "a"), -1, gid))
-	})
-}
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 3a67baa7..2e032bf0 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -2,6 +2,7 @@ package sysfs
 
 import (
 	"errors"
+	"fmt"
 	"io/fs"
 	"os"
 	"path"
@@ -58,7 +59,7 @@ func TestDirFS_String(t *testing.T) {
 	testFS := NewDirFS(".")
 
 	// String has the name of the path entered
-	require.Equal(t, ".", testFS.String())
+	require.Equal(t, ".", testFS.(fmt.Stringer).String())
 }
 
 func TestDirFS_Lstat(t *testing.T) {
@@ -720,88 +721,6 @@ func TestDirFS_Stat(t *testing.T) {
 	}
 }
 
-func TestDirFS_Truncate(t *testing.T) {
-	content := []byte("123456")
-
-	tests := []struct {
-		name            string
-		size            int64
-		expectedContent []byte
-		expectedErr     error
-	}{
-		{
-			name:            "one less",
-			size:            5,
-			expectedContent: []byte("12345"),
-		},
-		{
-			name:            "same",
-			size:            6,
-			expectedContent: content,
-		},
-		{
-			name:            "zero",
-			size:            0,
-			expectedContent: []byte(""),
-		},
-		{
-			name:            "larger",
-			size:            106,
-			expectedContent: append(content, make([]byte, 100)...),
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			tmpDir := t.TempDir()
-			testFS := NewDirFS(tmpDir)
-
-			name := "truncate"
-			realPath := path.Join(tmpDir, name)
-			require.NoError(t, os.WriteFile(realPath, content, 0o0666))
-
-			errno := testFS.Truncate(name, tc.size)
-			require.EqualErrno(t, 0, errno)
-
-			actual, err := os.ReadFile(realPath)
-			require.NoError(t, err)
-			require.Equal(t, tc.expectedContent, actual)
-		})
-	}
-
-	tmpDir := t.TempDir()
-	testFS := NewDirFS(tmpDir)
-
-	name := "truncate"
-	realPath := path.Join(tmpDir, name)
-
-	if runtime.GOOS != "windows" {
-		// TODO: os.Truncate on windows can create the file even when it
-		// doesn't exist.
-		t.Run("doesn't exist", func(t *testing.T) {
-			err := testFS.Truncate(name, 0)
-			require.Equal(t, syscall.ENOENT, err)
-		})
-	}
-
-	t.Run("not file", func(t *testing.T) {
-		require.NoError(t, os.Mkdir(realPath, 0o700))
-
-		err := testFS.Truncate(name, 0)
-		require.Equal(t, syscall.EISDIR, err)
-
-		require.NoError(t, os.Remove(realPath))
-	})
-
-	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
-
-	t.Run("negative", func(t *testing.T) {
-		err := testFS.Truncate(name, -1)
-		require.Equal(t, syscall.EINVAL, err)
-	})
-}
-
 func TestDirFS_Readdir(t *testing.T) {
 	root := t.TempDir()
 	testFS := NewDirFS(root)
diff --git a/internal/sysfs/dirfs_unix_test.go b/internal/sysfs/dirfs_unix_test.go
deleted file mode 100644
index 5bb48f10..00000000
--- a/internal/sysfs/dirfs_unix_test.go
+++ /dev/null
@@ -1,126 +0,0 @@
-//go:build !windows
-
-package sysfs
-
-import (
-	"fmt"
-	"os"
-	"path"
-	"syscall"
-	"testing"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-func TestDirFS_Chown(t *testing.T) {
-	tmpDir := t.TempDir()
-	testFS := NewDirFS(tmpDir)
-
-	require.EqualErrno(t, 0, testFS.Mkdir("dir", 0o0777))
-	dirF, errno := testFS.OpenFile("dir", syscall.O_RDONLY, 0)
-	require.EqualErrno(t, 0, errno)
-
-	dirSt, errno := dirF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	// Similar to TestChown in os_unix_test.go, we can't expect to change
-	// owner unless root, and with another user. Instead, test gid.
-	gid := os.Getgid()
-	groups, err := os.Getgroups()
-	require.NoError(t, err)
-
-	t.Run("-1 parameters means leave alone", func(t *testing.T) {
-		require.EqualErrno(t, 0, testFS.Chown("dir", -1, -1))
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
-	})
-
-	t.Run("change gid, but not uid", func(t *testing.T) {
-		require.EqualErrno(t, 0, testFS.Chown("dir", -1, gid))
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(gid))
-	})
-
-	// Now, try any other groups of the current user.
-	for _, g := range groups {
-		g := g
-		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
-			// Test using our Chown
-			require.EqualErrno(t, 0, testFS.Chown("dir", -1, g))
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(g))
-
-			// Revert back
-			require.EqualErrno(t, 0, dirF.Chown(-1, gid))
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, uint32(gid))
-		})
-	}
-
-	t.Run("not found", func(t *testing.T) {
-		require.EqualErrno(t, syscall.ENOENT, testFS.Chown("a", -1, gid))
-	})
-}
-
-func TestDirFS_Lchown(t *testing.T) {
-	tmpDir := t.TempDir()
-	testFS := NewDirFS(tmpDir)
-
-	require.EqualErrno(t, 0, testFS.Mkdir("dir", 0o0777))
-	dirF, errno := testFS.OpenFile("dir", syscall.O_RDONLY, 0)
-	require.EqualErrno(t, 0, errno)
-
-	dirSt, errno := dirF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	require.EqualErrno(t, 0, testFS.Symlink("dir", "link"))
-	linkF, errno := testFS.OpenFile("link", syscall.O_RDONLY, 0)
-	require.EqualErrno(t, 0, errno)
-
-	linkSt, errno := linkF.Stat()
-	require.EqualErrno(t, 0, errno)
-
-	// Similar to TestLchown in os_unix_test.go, we can't expect to change
-	// owner unless root, and with another user. Instead, test gid.
-	gid := os.Getgid()
-	groups, err := os.Getgroups()
-	require.NoError(t, err)
-
-	t.Run("-1 parameters means leave alone", func(t *testing.T) {
-		require.EqualErrno(t, 0, testFS.Lchown("link", -1, -1))
-		checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, linkSt.Gid)
-	})
-
-	t.Run("change gid, but not uid", func(t *testing.T) {
-		require.EqualErrno(t, 0, testFS.Chown("dir", -1, gid))
-		checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(gid))
-		// Make sure the target didn't change.
-		checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
-	})
-
-	// Now, try any other groups of the current user.
-	for _, g := range groups {
-		g := g
-		t.Run(fmt.Sprintf("change to gid %d", g), func(t *testing.T) {
-			// Test using our Lchown
-			require.EqualErrno(t, 0, testFS.Lchown("link", -1, g))
-			checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(g))
-			// Make sure the target didn't change.
-			checkUidGid(t, path.Join(tmpDir, "dir"), dirSt.Uid, dirSt.Gid)
-
-			// Revert back
-			require.EqualErrno(t, 0, testFS.Lchown("link", -1, gid))
-			checkUidGid(t, path.Join(tmpDir, "link"), linkSt.Uid, uint32(gid))
-		})
-	}
-
-	t.Run("not found", func(t *testing.T) {
-		require.EqualErrno(t, syscall.ENOENT, testFS.Lchown("a", -1, gid))
-	})
-}
-
-// checkUidGid uses lstat to ensure the comparison is against the file, not the
-// target of a symbolic link.
-func checkUidGid(t *testing.T, path string, uid, gid uint32) {
-	ls, err := os.Lstat(path)
-	require.NoError(t, err)
-	sys := ls.Sys().(*syscall.Stat_t)
-	require.Equal(t, uid, sys.Uid)
-	require.Equal(t, gid, sys.Gid)
-}
diff --git a/internal/sysfs/file_test.go b/internal/sysfs/file_test.go
index c8f4717c..269aef8c 100644
--- a/internal/sysfs/file_test.go
+++ b/internal/sysfs/file_test.go
@@ -168,10 +168,10 @@ func TestFileIno(t *testing.T) {
 
 			ino, errno := d.Ino()
 			require.EqualErrno(t, 0, errno)
-			if !statSetsIno() {
-				tc.expectedIno = 0
+			// Results are inconsistent, so don't validate the opposite.
+			if statSetsIno() {
+				require.Equal(t, tc.expectedIno, ino)
 			}
-			require.Equal(t, tc.expectedIno, ino)
 		})
 	}
 
@@ -182,10 +182,9 @@ func TestFileIno(t *testing.T) {
 
 		ino, errno := d.Ino()
 		require.EqualErrno(t, 0, errno)
+		// Results are inconsistent, so don't validate the opposite.
 		if statSetsIno() {
 			require.Equal(t, st.Ino, ino)
-		} else {
-			require.Zero(t, ino)
 		}
 	})
 }
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 4f9577e6..784d8854 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -28,14 +28,6 @@ func TestNewReadFS(t *testing.T) {
 	require.NotEqual(t, writeable, readFS)
 }
 
-func TestReadFS_String(t *testing.T) {
-	writeable := NewDirFS("/tmp")
-
-	readFS := NewReadFS(writeable)
-	require.NotEqual(t, writeable, readFS)
-	require.Equal(t, "/tmp", readFS.String())
-}
-
 func TestReadFS_Lstat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
diff --git a/internal/sysfs/stat_test.go b/internal/sysfs/stat_test.go
index 30024517..f5afed99 100644
--- a/internal/sysfs/stat_test.go
+++ b/internal/sysfs/stat_test.go
@@ -9,7 +9,6 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/fsapi"
-	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -86,9 +85,8 @@ func TestStatFile(t *testing.T) {
 	t.Run("dir", func(t *testing.T) {
 		st, errno := tmpDirF.Stat()
 		require.EqualErrno(t, 0, errno)
-
-		require.True(t, st.Mode.IsDir())
-		requireDirectoryDevIno(t, st)
+		requireDir(t, tmpDirF, st)
+		requireDevIno(t, tmpDirF, st)
 	})
 
 	// Windows allows you to stat a closed dir because it is accessed by path,
@@ -128,9 +126,8 @@ func TestStatFile(t *testing.T) {
 	t.Run("subdir", func(t *testing.T) {
 		st, errno := subdirF.Stat()
 		require.EqualErrno(t, 0, errno)
-
-		require.True(t, st.Mode.IsDir())
-		requireDirectoryDevIno(t, st)
+		requireDir(t, subdirF, st)
+		requireDevIno(t, subdirF, st)
 	})
 
 	if runtime.GOOS != "windows" { // windows allows you to stat a closed dir
@@ -215,18 +212,24 @@ func TestStatFile_dev_inode(t *testing.T) {
 	// First, stat the directory
 	st1, errno := d.Stat()
 	require.EqualErrno(t, 0, errno)
-
-	requireDirectoryDevIno(t, st1)
+	requireDir(t, d, st1)
+	requireDevIno(t, d, st1)
 
 	// Now, stat the files in it
 	st1, errno = f1.Stat()
 	require.EqualErrno(t, 0, errno)
+	requireNotDir(t, f1, st1)
+	requireDevIno(t, f1, st1)
 
 	st2, errno := f2.Stat()
 	require.EqualErrno(t, 0, errno)
+	requireNotDir(t, f2, st2)
+	requireDevIno(t, f2, st2)
 
 	st3, errno := l2.Stat()
 	require.EqualErrno(t, 0, errno)
+	requireNotDir(t, l2, st3)
+	requireDevIno(t, l2, st3)
 
 	// The files should be on the same device, but different inodes
 	require.Equal(t, st1.Dev, st2.Dev)
@@ -255,16 +258,37 @@ func TestStatFile_dev_inode(t *testing.T) {
 	require.Equal(t, st1.Ino, st1Again.Ino)
 }
 
-func requireDirectoryDevIno(t *testing.T, st fsapi.Stat_t) {
-	// windows before go 1.20 has trouble reading the inode information on
-	// directories.
-	if runtime.GOOS != "windows" || platform.IsGo120 {
+func requireNotDir(t *testing.T, d fsapi.File, st fsapi.Stat_t) {
+	// Verify cached state is correct
+	isDir, errno := d.IsDir()
+	require.EqualErrno(t, 0, errno)
+	require.False(t, isDir)
+	require.False(t, st.Mode.IsDir())
+}
+
+func requireDir(t *testing.T, d fsapi.File, st fsapi.Stat_t) {
+	// Verify cached state is correct
+	isDir, errno := d.IsDir()
+	require.EqualErrno(t, 0, errno)
+	require.True(t, isDir)
+	require.True(t, st.Mode.IsDir())
+}
+
+func requireDevIno(t *testing.T, f fsapi.File, st fsapi.Stat_t) {
+	// Results are inconsistent, so don't validate the opposite.
+	if statSetsIno() {
 		require.NotEqual(t, uint64(0), st.Dev)
 		require.NotEqual(t, uint64(0), st.Ino)
-	} else {
-		require.Zero(t, st.Dev)
-		require.Zero(t, st.Ino)
 	}
+
+	// Verify the special-cased properties supporting wasip2 "is_same_object"
+	// See https://github.com/WebAssembly/wasi-filesystem/pull/81
+	dev, errno := f.Dev()
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, st.Dev, dev)
+	ino, errno := f.Ino()
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, st.Ino, ino)
 }
 
 // TestStat_uid_gid is similar to os.TestChown
@@ -283,7 +307,7 @@ func TestStat_uid_gid(t *testing.T) {
 		tmpDir := t.TempDir()
 		dir := path.Join(tmpDir, "dir")
 		require.NoError(t, os.Mkdir(dir, 0o0777))
-		require.EqualErrno(t, 0, chgid(dir, gid))
+		require.NoError(t, chgid(dir, gid))
 
 		st, errno := stat(dir)
 		require.EqualErrno(t, 0, errno)
@@ -296,7 +320,7 @@ func TestStat_uid_gid(t *testing.T) {
 		tmpDir := t.TempDir()
 		link := path.Join(tmpDir, "link")
 		require.NoError(t, os.Symlink(tmpDir, link))
-		require.EqualErrno(t, 0, chgid(link, gid))
+		require.NoError(t, chgid(link, gid))
 
 		st, errno := lstat(link)
 		require.EqualErrno(t, 0, errno)
@@ -309,7 +333,7 @@ func TestStat_uid_gid(t *testing.T) {
 		tmpDir := t.TempDir()
 		file := path.Join(tmpDir, "file")
 		require.NoError(t, os.WriteFile(file, nil, 0o0666))
-		require.EqualErrno(t, 0, chgid(file, gid))
+		require.NoError(t, chgid(file, gid))
 
 		st, errno := lstat(file)
 		require.EqualErrno(t, 0, errno)
@@ -321,5 +345,5 @@ func TestStat_uid_gid(t *testing.T) {
 
 func chgid(path string, gid uint32) error {
 	// Note: In Chown, -1 is means leave the uid alone
-	return Chown(path, -1, int(gid))
+	return os.Chown(path, -1, int(gid))
 }
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index e8d62cba..b04c8e33 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -67,6 +67,23 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS fsapi.FS) {
 			require.EqualErrno(t, 0, errno)
 		})
 	}
+
+	t.Run("O_TRUNC", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := NewDirFS(tmpDir)
+
+		name := "truncate"
+		realPath := path.Join(tmpDir, name)
+		require.NoError(t, os.WriteFile(realPath, []byte("123456"), 0o0666))
+
+		f, errno = testFS.OpenFile(name, os.O_RDWR|os.O_TRUNC, 0o444)
+		require.EqualErrno(t, 0, errno)
+		require.EqualErrno(t, 0, f.Close())
+
+		actual, err := os.ReadFile(realPath)
+		require.NoError(t, err)
+		require.Equal(t, 0, len(actual))
+	})
 }
 
 func testOpen_Read(t *testing.T, testFS fsapi.FS, requireFileIno, expectDirIno bool) {
@@ -186,7 +203,8 @@ human
 
 		st, errno := f.Stat()
 		require.EqualErrno(t, 0, errno)
-		// Windows Go 1.18 sometimes, but not always, gets this value.
+
+		// Results are inconsistent, so don't validate the opposite.
 		if requireFileIno {
 			require.NotEqual(t, uint64(0), st.Ino, "%+v", st)
 		}