Repository: tetratelabs/wazero
Pull request #979 (2022-12-30 03:59:07)

renames writefs to syscallfs and implements utimes in gojs
This renames the internal writefs package to syscallfs as it is largely dependent on syscall signatures. This also implements utimes in gojs. WASI will be a follow-up change as it requires more infrastructure. Notably, we also need non-TinyGo tests because TinyGo doesn't yet support os.Chtimes or corresponding syscalls.



Patch:
diff --git a/experimental/writefs/writefs.go b/experimental/writefs/writefs.go
index c974b2de..4dc41893 100644
--- a/experimental/writefs/writefs.go
+++ b/experimental/writefs/writefs.go
@@ -10,19 +10,20 @@ package writefs
 import (
 	"io/fs"
 
-	"github.com/tetratelabs/wazero/internal/writefs"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 )
 
 // DirFS creates a writeable filesystem at the given path on the host filesystem.
 //
 // This is like os.DirFS, but allows creation and deletion of files and
-// directories, which aren't yet supported in fs.FS.
+// directories, as well as timestamp modifications. None of which are supported
+// in fs.FS.
 //
 // # Isolation
 //
 // Symbolic links can escape the root path as files are opened via os.OpenFile
 // which cannot restrict following them.
 func DirFS(dir string) fs.FS {
-	// writefs.FS is intentionally internal as it is still evolving
-	return writefs.DirFS(dir)
+	// writefs.DirFS is intentionally internal as it is still evolving
+	return syscallfs.DirFS(dir)
 }
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 66bf697b..c2579074 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -10,6 +10,7 @@ import (
 	"path"
 
 	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
 	. "github.com/tetratelabs/wazero/internal/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/wasm"
@@ -272,15 +273,15 @@ var blockFilestat = []byte{
 func writeFilestat(buf []byte, stat fs.FileInfo) {
 	filetype := getWasiFiletype(stat.Mode())
 	filesize := uint64(stat.Size())
-	mtim := stat.ModTime().UnixNano()
+	atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec := platform.StatTimes(stat)
 
 	// memory is re-used, so ensure the result is defaulted.
 	copy(buf, blockFilestat[:32])
 	buf[16] = filetype
-	le.PutUint64(buf[32:], filesize)     // filesize
-	le.PutUint64(buf[40:], uint64(mtim)) // atim
-	le.PutUint64(buf[48:], uint64(mtim)) // mtim
-	le.PutUint64(buf[56:], uint64(mtim)) // ctim
+	le.PutUint64(buf[32:], filesize)                       // filesize
+	le.PutUint64(buf[40:], uint64(atimeSec*1e9+atimeNsec)) // atim
+	le.PutUint64(buf[48:], uint64(mtimeSec*1e9+mtimeNsec)) // mtim
+	le.PutUint64(buf[56:], uint64(ctimeSec*1e9+ctimeNsec)) // ctim
 }
 
 // fdFilestatSetSize is the WASI function named FdFilestatSetSizeName which
diff --git a/internal/gojs/custom/fs.go b/internal/gojs/custom/fs.go
index 9420b4cc..b56f4e42 100644
--- a/internal/gojs/custom/fs.go
+++ b/internal/gojs/custom/fs.go
@@ -15,6 +15,7 @@ const (
 	NameFsMkdir   = "mkdir"
 	NameFsRmdir   = "rmdir"
 	NameFsUnlink  = "unlink"
+	NameFsUtimes  = "utimes"
 )
 
 // FsNameSection are the functions defined in the object named NameFs. Results
@@ -76,4 +77,9 @@ var FsNameSection = map[string]*Names{
 		ParamNames:  []string{"path", NameCallback},
 		ResultNames: []string{"err", "ok"},
 	},
+	NameFsUtimes: {
+		Name:        NameFsUtimes,
+		ParamNames:  []string{"path", "atime", "mtime", NameCallback},
+		ResultNames: []string{"err", "ok"},
+	},
 }
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 8e983cdd..fd956e29 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -11,6 +11,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
 	"github.com/tetratelabs/wazero/internal/gojs/goos"
+	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -35,7 +36,8 @@ var (
 		addFunction(custom.NameFsReaddir, &jsfsReaddir{}).
 		addFunction(custom.NameFsMkdir, &jsfsMkdir{}).
 		addFunction(custom.NameFsRmdir, &jsfsRmdir{}).
-		addFunction(custom.NameFsUnlink, &jsfsUnlink{})
+		addFunction(custom.NameFsUnlink, &jsfsUnlink{}).
+		addFunction(custom.NameFsUtimes, &jsfsUtimes{})
 
 	// TODO: stub all these with syscall.ENOSYS
 	//	* _, err := fsCall("chmod", path, mode) // syscall.Chmod
@@ -43,7 +45,6 @@ var (
 	//	* _, err := fsCall("chown", path, uint32(uid), uint32(gid)) // syscall.Chown
 	//	* _, err := fsCall("fchown", fd, uint32(uid), uint32(gid)) // syscall.Fchown
 	//	* _, err := fsCall("lchown", path, uint32(uid), uint32(gid)) // syscall.Lchown
-	//	* _, err := fsCall("utimes", path, atime, mtime) // syscall.UtimesNano
 	//	* _, err := fsCall("rename", from, to) // syscall.Rename
 	//	* _, err := fsCall("truncate", path, length) // syscall.Truncate
 	//	* _, err := fsCall("ftruncate", fd, length) // syscall.Ftruncate
@@ -165,7 +166,10 @@ func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
 		ret.isDir = stat.IsDir()
 		ret.mode = getJsMode(stat.Mode())
 		ret.size = stat.Size()
-		ret.mtimeMs = stat.ModTime().UnixMilli()
+		atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec := platform.StatTimes(stat)
+		ret.atimeMs = atimeSec*1e3 + atimeNsec/1e6
+		ret.mtimeMs = mtimeSec*1e3 + mtimeNsec/1e6
+		ret.ctimeMs = ctimeSec*1e3 + ctimeNsec/1e6
 		return ret, nil
 	}
 }
@@ -480,6 +484,29 @@ func syscallUnlink(mod api.Module, name string) (interface{}, error) {
 	return err != nil, err
 }
 
+// jsfsUtimes implements the following
+//
+//	_, err := fsCall("utimes", path, atime, mtime) // syscall.UtimesNano
+type jsfsUtimes struct{}
+
+// invoke implements jsFn.invoke
+func (*jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	name := args[0].(string)
+	atimeSec := toInt64(args[1])
+	mtimeSec := toInt64(args[2])
+	callback := args[3].(funcWrapper)
+
+	ok, err := syscallUtimes(mod, name, atimeSec, mtimeSec)
+	return callback.invoke(ctx, mod, goos.RefJsfs, err, ok) // note: error first
+}
+
+// syscallUtimes is like syscall.Utimes
+func syscallUtimes(mod api.Module, name string, atimeSec, mtimeSec int64) (interface{}, error) {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	err := fsc.Utimes(name, atimeSec, 0, mtimeSec, 0)
+	return err != nil, err
+}
+
 // jsSt is pre-parsed from fs_js.go setStat to avoid thrashing
 type jsSt struct {
 	isDir   bool
diff --git a/internal/platform/mmap_windows.go b/internal/platform/mmap_windows.go
index 1dd88dcc..cf6d419b 100644
--- a/internal/platform/mmap_windows.go
+++ b/internal/platform/mmap_windows.go
@@ -1,5 +1,3 @@
-//go:build windows
-
 package platform
 
 import (
diff --git a/internal/platform/stat.go b/internal/platform/stat.go
new file mode 100644
index 00000000..6fb76de2
--- /dev/null
+++ b/internal/platform/stat.go
@@ -0,0 +1,19 @@
+package platform
+
+import "os"
+
+// StatTimes returns platform-specific values if os.FileInfo Sys is available.
+// Otherwise, it returns the mod time for all values.
+func StatTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+	if t.Sys() == nil { // possibly fake filesystem
+		return mtimes(t)
+	}
+	return statTimes(t)
+}
+
+func mtimes(t os.FileInfo) (int64, int64, int64, int64, int64, int64) {
+	mtime := t.ModTime().UnixNano()
+	mtimeSec := mtime / 1e9
+	mtimeNsec := mtime % 1e9
+	return mtimeSec, mtimeNsec, mtimeSec, mtimeNsec, mtimeSec, mtimeNsec
+}
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
new file mode 100644
index 00000000..a3f70ca0
--- /dev/null
+++ b/internal/platform/stat_bsd.go
@@ -0,0 +1,16 @@
+//go:build darwin || freebsd
+
+package platform
+
+import (
+	"os"
+	"syscall"
+)
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNSec, mtimeSec, mtimeNSec, ctimeSec, ctimeNSec int64) {
+	d := t.Sys().(*syscall.Stat_t)
+	atime := d.Atimespec
+	mtime := d.Mtimespec
+	ctime := d.Ctimespec
+	return atime.Sec, atime.Nsec, mtime.Sec, mtime.Nsec, ctime.Sec, ctime.Nsec
+}
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
new file mode 100644
index 00000000..1e00a5d7
--- /dev/null
+++ b/internal/platform/stat_linux.go
@@ -0,0 +1,14 @@
+package platform
+
+import (
+	"os"
+	"syscall"
+)
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNSec, mtimeSec, mtimeNSec, ctimeSec, ctimeNSec int64) {
+	d := t.Sys().(*syscall.Stat_t)
+	atime := d.Atim
+	mtime := d.Mtim
+	ctime := d.Ctim
+	return atime.Sec, atime.Nsec, mtime.Sec, mtime.Nsec, ctime.Sec, ctime.Nsec
+}
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
new file mode 100644
index 00000000..20807132
--- /dev/null
+++ b/internal/platform/stat_unsupported.go
@@ -0,0 +1,9 @@
+//go:build !(darwin || linux || freebsd || windows)
+
+package platform
+
+import "os"
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNSec, mtimeSec, mtimeNSec, ctimeSec, ctimeNSec int64) {
+	return mtimes(t)
+}
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
new file mode 100644
index 00000000..8dee4a23
--- /dev/null
+++ b/internal/platform/stat_windows.go
@@ -0,0 +1,14 @@
+package platform
+
+import (
+	"os"
+	"syscall"
+)
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+	d := t.Sys().(*syscall.Win32FileAttributeData)
+	atime := d.LastAccessTime.Nanoseconds()
+	mtime := d.LastWriteTime.Nanoseconds()
+	ctime := d.CreationTime.Nanoseconds()
+	return atime / 1e9, atime % 1e9, mtime / 1e9, mtime % 1e9, ctime / 1e9, ctime % 1e9
+}
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 7504b0e7..6663e751 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -14,7 +14,7 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
-	"github.com/tetratelabs/wazero/internal/writefs"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 )
 
 const (
@@ -303,7 +303,7 @@ func (s fileModeStat) IsDir() bool        { return false }
 // directory or an error.
 func (c *FSContext) Mkdir(name string, perm fs.FileMode) (newFD uint32, err error) {
 	name = c.cleanPath(name)
-	if wfs, ok := c.fs.(writefs.FS); ok {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
 		if err = wfs.Mkdir(name, perm); err != nil {
 			return
 		}
@@ -319,7 +319,7 @@ func (c *FSContext) Mkdir(name string, perm fs.FileMode) (newFD uint32, err erro
 func (c *FSContext) OpenFile(name string, flags int, perm fs.FileMode) (newFD uint32, err error) {
 	create := flags&os.O_CREATE != 0
 	var f fs.File
-	if wfs, ok := c.fs.(writefs.FS); ok {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
 		name = c.cleanPath(name)
 		f, err = wfs.OpenFile(name, flags, perm)
 	} else if create {
@@ -343,7 +343,7 @@ func (c *FSContext) OpenFile(name string, flags int, perm fs.FileMode) (newFD ui
 
 // Rmdir is like syscall.Rmdir.
 func (c *FSContext) Rmdir(name string) (err error) {
-	if wfs, ok := c.fs.(writefs.FS); ok {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
 		name = c.cleanPath(name)
 		return wfs.Rmdir(name)
 	}
@@ -362,7 +362,7 @@ func (c *FSContext) StatPath(name string) (fs.FileInfo, error) {
 
 // Unlink is like syscall.Unlink.
 func (c *FSContext) Unlink(name string) (err error) {
-	if wfs, ok := c.fs.(writefs.FS); ok {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
 		name = c.cleanPath(name)
 		return wfs.Unlink(name)
 	}
@@ -370,6 +370,16 @@ func (c *FSContext) Unlink(name string) (err error) {
 	return
 }
 
+// Utimes is like syscall.Utimes.
+func (c *FSContext) Utimes(name string, atimeSec, atimeNsec, mtimeSec, mtimeNsec int64) (err error) {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
+		name = c.cleanPath(name)
+		return wfs.Utimes(name, atimeSec, atimeNsec, mtimeSec, mtimeNsec)
+	}
+	err = syscall.ENOSYS
+	return
+}
+
 func (c *FSContext) openFile(name string) (fs.File, error) {
 	return c.fs.Open(c.cleanPath(name))
 }
diff --git a/internal/writefs/syscall.go b/internal/syscallfs/syscall.go
similarity index 93%
rename from internal/writefs/syscall.go
rename to internal/syscallfs/syscall.go
index 8d4fd1d0..1d797f44 100644
--- a/internal/writefs/syscall.go
+++ b/internal/syscallfs/syscall.go
@@ -1,6 +1,6 @@
 //go:build !windows
 
-package writefs
+package syscallfs
 
 import "syscall"
 
diff --git a/internal/writefs/syscall_windows.go b/internal/syscallfs/syscall_windows.go
similarity index 98%
rename from internal/writefs/syscall_windows.go
rename to internal/syscallfs/syscall_windows.go
index 2f8d8941..8afc07d8 100644
--- a/internal/writefs/syscall_windows.go
+++ b/internal/syscallfs/syscall_windows.go
@@ -1,4 +1,4 @@
-package writefs
+package syscallfs
 
 import (
 	"io/fs"
diff --git a/internal/writefs/writefs.go b/internal/syscallfs/syscallfs.go
similarity index 71%
rename from internal/writefs/writefs.go
rename to internal/syscallfs/syscallfs.go
index 9907d7fd..a3319186 100644
--- a/internal/writefs/writefs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -1,4 +1,4 @@
-package writefs
+package syscallfs
 
 import (
 	"io/fs"
@@ -7,7 +7,8 @@ import (
 	"syscall"
 )
 
-// FS is a fs.FS which can also create new files or directories.
+// FS is a writeable fs.FS bridge backed by syscall functions needed for ABI
+// including WASI and runtime.GOOS=js.
 //
 // Any unsupported method should return syscall.ENOSYS.
 //
@@ -23,6 +24,22 @@ type FS interface {
 	// system.
 	Mkdir(name string, perm fs.FileMode) error
 
+	// Utimes is similar to syscall.UtimesNano, except the path is relative to
+	// this file system.
+	//
+	// # Errors
+	//
+	// The following errors are expected:
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` doesn't exist
+	//
+	// # Notes
+	//
+	//   - To set wall clock time, retrieve it first from sys.Walltime.
+	//   - syscall.UtimesNano cannot change the ctime. Also, neither WASI nor
+	//     runtime.GOOS=js support changing it. Hence, ctime it is absent here.
+	Utimes(path string, atimeSec, atimeNsec, mtimeSec, mtimeNsec int64) error
+
 	// Rmdir is similar to syscall.Rmdir, except the path is relative to this
 	// file system.
 	//
@@ -100,3 +117,15 @@ func (dir dirFS) Unlink(name string) error {
 
 	return adjustUnlinkError(err)
 }
+
+// Utimes implements FS.Utimes
+func (dir dirFS) Utimes(name string, atimeSec, atimeNsec, mtimeSec, mtimeNsec int64) error {
+	if !fs.ValidPath(name) {
+		return syscall.EINVAL
+	}
+
+	return syscall.UtimesNano(path.Join(string(dir), name), []syscall.Timespec{
+		{Sec: atimeSec, Nsec: atimeNsec},
+		{Sec: mtimeSec, Nsec: mtimeNsec},
+	})
+}


Test patch:
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 22d86b8e..9bdb75eb 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/experimental/writefs"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -25,7 +26,7 @@ sub mode drwxr-xr-x
 test.txt mode -rw-r--r--
 contents: animals
 
-empty: 
+empty:
 `, stdout)
 }
 
@@ -41,7 +42,17 @@ func Test_writefs(t *testing.T) {
 
 	require.Zero(t, stderr)
 	require.EqualError(t, err, `module "" closed with exit_code(0)`)
-	require.Equal(t, `/tmp/dir mode drwx------
+
+	if platform.CompilerSupported() {
+		//  Note: as of Go 1.19, only the Sec field is set on update in fs_js.go.
+		require.Equal(t, `/tmp/dir mode drwx------
+/tmp/dir/file mode -rw-------
+times: 123 0 567 0
+`, stdout)
+	} else { // only mtimes will return.
+		require.Equal(t, `/tmp/dir mode drwx------
 /tmp/dir/file mode -rw-------
+times: 567 0 567 0
 `, stdout)
+	}
 }
diff --git a/internal/gojs/testdata/fs/main.go b/internal/gojs/testdata/fs/main.go
index 4053214e..ef12dbd7 100644
--- a/internal/gojs/testdata/fs/main.go
+++ b/internal/gojs/testdata/fs/main.go
@@ -53,5 +53,5 @@ func testAdHoc() {
 	if err != nil {
 		log.Panicln(err)
 	}
-	fmt.Println("empty:", string(b))
+	fmt.Println("empty:" + string(b))
 }
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index 92b27481..3b7674fa 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -1,11 +1,13 @@
 package writefs
 
 import (
+	"errors"
 	"fmt"
 	"log"
 	"os"
 	"path"
 	"syscall"
+	"time"
 )
 
 func Main() {
@@ -41,6 +43,28 @@ func Main() {
 		log.Panicln("unexpected error", err)
 	}
 
+	// Test updating the mod time of a file, noting JS has millis precision.
+	atime := time.Unix(123, 4*1e6)
+	mtime := time.Unix(567, 8*1e6)
+
+	// Ensure errors propagate
+	if err = os.Chtimes("noexist", atime, mtime); !errors.Is(err, syscall.ENOENT) {
+		log.Panicln("unexpected error", err)
+	}
+
+	// Now, try a real update.
+	if err = os.Chtimes(dir, atime, mtime); err != nil {
+		log.Panicln("unexpected error", err)
+	}
+
+	// Ensure the times translated properly.
+	if stat, err := os.Stat(dir); err != nil {
+		log.Panicln("unexpected error", err)
+	} else {
+		atimeSec, atimeNsec, mtimeSec, mtimeNsec, _, _ := statTimes(stat)
+		fmt.Println("times:", atimeSec, atimeNsec, mtimeSec, mtimeNsec)
+	}
+
 	// Test unlinking a file
 	if err = syscall.Rmdir(file); err != syscall.ENOTDIR {
 		log.Panicln("unexpected error", err)
diff --git a/internal/gojs/testdata/writefs/times.go b/internal/gojs/testdata/writefs/times.go
new file mode 100644
index 00000000..ad4dfd2b
--- /dev/null
+++ b/internal/gojs/testdata/writefs/times.go
@@ -0,0 +1,13 @@
+//go:build !js
+
+package writefs
+
+import (
+	"os"
+
+	"github.com/tetratelabs/wazero/internal/platform"
+)
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+	return platform.StatTimes(t) // allow the file to compile and run outside JS
+}
diff --git a/internal/gojs/testdata/writefs/times_js.go b/internal/gojs/testdata/writefs/times_js.go
new file mode 100644
index 00000000..2a744eeb
--- /dev/null
+++ b/internal/gojs/testdata/writefs/times_js.go
@@ -0,0 +1,11 @@
+package writefs
+
+import (
+	"os"
+	"syscall"
+)
+
+func statTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+	d := t.Sys().(*syscall.Stat_t)
+	return d.Atime, d.AtimeNsec, d.Mtime, d.MtimeNsec, d.Ctime, d.CtimeNsec
+}
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
new file mode 100644
index 00000000..eafd326e
--- /dev/null
+++ b/internal/platform/stat_test.go
@@ -0,0 +1,56 @@
+package platform
+
+import (
+	"os"
+	"path"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func Test_StatTimes(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	file := path.Join(tmpDir, "file")
+	err := os.WriteFile(file, []byte{}, 0o700)
+	require.NoError(t, err)
+
+	type test struct {
+		name                                     string
+		atimeSec, atimeNsec, mtimeSec, mtimeNsec int64
+	}
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	tests := []test{
+		{name: "positive", atimeSec: 123, atimeNsec: 4 * 1e3, mtimeSec: 567, mtimeNsec: 8 * 1e3},
+		{name: "zero"},
+	}
+
+	// linux and freebsd report inaccurate results when the input ts is negative.
+	if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+		tests = append(tests,
+			test{name: "negative", atimeSec: -123, atimeNsec: -4 * 1e3, mtimeSec: -567, mtimeNsec: -8 * 1e3},
+		)
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			err := os.Chtimes(file, time.Unix(tc.atimeSec, tc.atimeNsec), time.Unix(tc.mtimeSec, tc.mtimeNsec))
+			require.NoError(t, err)
+
+			stat, err := os.Stat(file)
+			require.NoError(t, err)
+
+			atimeSec, atimeNsec, mtimeSec, mtimeNsec, _, _ := StatTimes(stat)
+			if CompilerSupported() {
+				require.Equal(t, atimeSec, tc.atimeSec)
+				require.Equal(t, atimeNsec, tc.atimeNsec)
+			} // else only mtimes will return.
+			require.Equal(t, mtimeSec, tc.mtimeSec)
+			require.Equal(t, mtimeNsec, tc.mtimeNsec)
+		})
+	}
+}
diff --git a/internal/writefs/writefs_test.go b/internal/syscallfs/syscallfs_test.go
similarity index 61%
rename from internal/writefs/writefs_test.go
rename to internal/syscallfs/syscallfs_test.go
index 430008d7..3bb7d817 100644
--- a/internal/writefs/writefs_test.go
+++ b/internal/syscallfs/syscallfs_test.go
@@ -1,4 +1,4 @@
-package writefs
+package syscallfs
 
 import (
 	"errors"
@@ -10,6 +10,7 @@ import (
 	"testing"
 	"testing/fstest"
 
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -140,3 +141,64 @@ func TestDirFS_Unlink(t *testing.T) {
 		require.Error(t, err)
 	})
 }
+
+func TestDirFS_Utimes(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	testFS := DirFS(tmpDir)
+
+	file := "file"
+	err := os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700)
+	require.NoError(t, err)
+
+	dir := "dir"
+	err = os.Mkdir(path.Join(tmpDir, dir), 0o700)
+	require.NoError(t, err)
+
+	t.Run("doesn't exist", func(t *testing.T) {
+		err := testFS.Utimes("nope", 123, 4, 567, 8)
+		require.Equal(t, syscall.ENOENT, err)
+	})
+
+	type test struct {
+		name                                     string
+		path                                     string
+		atimeSec, atimeNsec, mtimeSec, mtimeNsec int64
+	}
+
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	tests := []test{
+		{name: "file positive", path: file, atimeSec: 123, atimeNsec: 4 * 1e3, mtimeSec: 567, mtimeNsec: 8 * 1e3},
+		{name: "dir positive", path: dir, atimeSec: 123, atimeNsec: 4 * 1e3, mtimeSec: 567, mtimeNsec: 8 * 1e3},
+		{name: "file zero", path: file},
+		{name: "dir zero", path: dir},
+	}
+
+	// linux and freebsd report inaccurate results when the input ts is negative.
+	if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+		tests = append(tests,
+			test{name: "file negative", path: file, atimeSec: -123, atimeNsec: -4 * 1e3, mtimeSec: -567, mtimeNsec: -8 * 1e3},
+			test{name: "dir negative", path: dir, atimeSec: -123, atimeNsec: -4 * 1e3, mtimeSec: -567, mtimeNsec: -8 * 1e3},
+		)
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			err := testFS.Utimes(tc.path, tc.atimeSec, tc.atimeNsec, tc.mtimeSec, tc.mtimeNsec)
+			require.NoError(t, err)
+
+			stat, err := os.Stat(path.Join(tmpDir, tc.path))
+			require.NoError(t, err)
+
+			atimeSec, atimeNsec, mtimeSec, mtimeNsec, _, _ := platform.StatTimes(stat)
+			if platform.CompilerSupported() {
+				require.Equal(t, atimeSec, tc.atimeSec)
+				require.Equal(t, atimeNsec, tc.atimeNsec)
+			} // else only mtimes will return.
+			require.Equal(t, mtimeSec, tc.mtimeSec)
+			require.Equal(t, mtimeNsec, tc.mtimeNsec)
+		})
+	}
+}