Repository: tetratelabs/wazero
Pull request #1215 (2023-03-08 06:37:57)

platform: Allows sysfs to implement utimens natively
This moves away from `syscall.UtimesNano` as it has intentionally avoided common features in POSIX, such as handling UTIME_NOW and UTIME_OMIT. When we eventually expose this API, users will be free to override `UTIME_NOW` with a fake clock, possibly the same that was supplied to wazero's `ModuleConfig`.

Notes:
* Once merged, I will implement `path_filestat_set_times` in wasi, which is literally the only fs function left stubbed in WASI.
* Like elsewhere, windows support is dodgy with at least Go 1.18, so we skip tests on that. It may be that Go 1.19 is fine, but for now only supporting 1.20.
* Even on Go 1.20, there is a limitation on windows as we can't update timestamps on an opened directory as it requires O_RDWR which isn't valid for them, also it might not work anyway as other ops require path-based access. This isn't a big deal as we'll shortly after make a file type which caches the path for a natural alternative.

codefromthecrypt: ok only windows left

Patch:
diff --git a/Makefile b/Makefile
index 37ab9900..1e275ed7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,10 @@
 
-gofumpt := mvdan.cc/gofumpt@v0.4.0
-gosimports := github.com/rinchsan/gosimports/cmd/gosimports@v0.3.5
-golangci_lint := github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1
-asmfmt := github.com/klauspost/asmfmt/cmd/asmfmt@v1.3.2
+gofumpt       := mvdan.cc/gofumpt@v0.4.0
+gosimports    := github.com/rinchsan/gosimports/cmd/gosimports@v0.3.7
+golangci_lint := github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2
+asmfmt        := github.com/klauspost/asmfmt/cmd/asmfmt@v1.3.2
 # sync this with netlify.toml!
-hugo          := github.com/gohugoio/hugo@v0.110.0
+hugo          := github.com/gohugoio/hugo@v0.111.2
 
 # Make 3.81 doesn't support '**' globbing: Set explicitly instead of recursion.
 all_sources   := $(wildcard *.go */*.go */*/*.go */*/*/*.go */*/*/*.go */*/*/*/*.go)
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index b8b94980..2fdb95f6 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -435,70 +435,38 @@ func fdFilestatSetTimesFn(_ context.Context, mod api.Module, params []uint64) Er
 		return ErrnoBadf
 	}
 
-	// Unchanging a part of time spec while executing utimes is extremely complex to add support for all platforms,
-	// and actually there's an outstanding issue on Go
-	// - https://github.com/golang/go/issues/32558.
-	// - https://go-review.googlesource.com/c/go/+/219638 (unmerged)
-	//
-	// Here, we emulate the behavior for empty flag (meaning "do not change") by get the current time stamp
-	// by explicitly executing File.Stat() prior to UtimesNano.
-	var atime, mtime int64
-	var nowAtime, statAtime, nowMtime, statMtime bool
+	// times[0] == atim, times[1] == mtim
+	times := [2]syscall.Timespec{}
+
+	// coerce atim into a timespec
 	if set, now := fstFlags&FileStatAdjustFlagsAtim != 0, fstFlags&FileStatAdjustFlagsAtimNow != 0; set && now {
 		return ErrnoInval
 	} else if set {
-		atime = int64(params[1])
+		times[0] = syscall.NsecToTimespec(int64(params[1]))
 	} else if now {
-		nowAtime = true
+		times[0].Nsec = platform.UTIME_NOW
 	} else {
-		statAtime = true
+		times[0].Nsec = platform.UTIME_OMIT
 	}
+
+	// coerce mtim into a timespec
 	if set, now := fstFlags&FileStatAdjustFlagsMtim != 0, fstFlags&FileStatAdjustFlagsMtimNow != 0; set && now {
 		return ErrnoInval
 	} else if set {
-		mtime = int64(params[2])
+		times[1] = syscall.NsecToTimespec(int64(params[2]))
 	} else if now {
-		nowMtime = true
+		times[1].Nsec = platform.UTIME_NOW
 	} else {
-		statMtime = true
-	}
-
-	// Handle if either parameter should be now.
-	if nowAtime || nowMtime {
-		now := sys.WalltimeNanos()
-		if nowAtime {
-			atime = now
-		}
-		if nowMtime {
-			mtime = now
-		}
-	}
-
-	// Handle if either parameter should be taken from stat.
-	if statAtime || statMtime {
-		if statAtime && statMtime {
-			return ErrnoSuccess // no change
-		}
-		// Get the current timestamp via Stat in order to un-change after calling FS.UtimesNano().
-		var st platform.Stat_t
-		if err := f.Stat(&st); err != nil {
-			return ToErrno(err)
-		}
-		if statAtime {
-			atime = st.Atim
-		}
-		if statMtime {
-			mtime = st.Mtim
-		}
+		times[1].Nsec = platform.UTIME_OMIT
 	}
 
 	// Try to update the file timestamps by file-descriptor.
-	err := platform.UtimesNanoFile(f.File, atime, mtime)
+	err := platform.UtimensFile(f.File, &times)
 
 	// Fall back to path based, despite it being less precise.
 	switch err {
 	case syscall.EPERM, syscall.ENOSYS:
-		err = f.FS.UtimesNano(f.Name, atime, mtime)
+		err = f.FS.Utimens(f.Name, &times, true)
 	}
 
 	return ToErrno(err)
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 03b81ca3..f33891d3 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -513,7 +513,7 @@ func (jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{
 
 // jsfsUtimes implements jsFn for the following
 //
-//	_, err := fsCall("utimes", path, atime, mtime) // syscall.UtimesNano
+//	_, err := fsCall("utimes", path, atime, mtime) // syscall.Utimens
 type jsfsUtimes struct{}
 
 func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
@@ -523,7 +523,10 @@ func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{
 	callback := args[3].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.RootFS().UtimesNano(path, atimeSec*1e9, mtimeSec*1e9)
+	times := [2]syscall.Timespec{
+		syscall.NsecToTimespec(atimeSec * 1e9), syscall.NsecToTimespec(mtimeSec * 1e9),
+	}
+	err := fsc.RootFS().Utimens(path, &times, true)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
diff --git a/internal/platform/futimens.go b/internal/platform/futimens.go
new file mode 100644
index 00000000..b908101e
--- /dev/null
+++ b/internal/platform/futimens.go
@@ -0,0 +1,146 @@
+package platform
+
+import (
+	"io/fs"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+const (
+	// UTIME_NOW is a special syscall.Timespec NSec value used to set the
+	// file's timestamp to a value close to, but not greater than the current
+	// system time.
+	UTIME_NOW = _UTIME_NOW
+
+	// UTIME_OMIT is a special syscall.Timespec NSec value used to avoid
+	// setting the file's timestamp.
+	UTIME_OMIT = _UTIME_OMIT
+)
+
+// Utimens set file access and modification times on a path resolved to the
+// current working directory, at nanosecond precision.
+//
+// # Parameters
+//
+// The `times` parameter includes the access and modification timestamps to
+// assign. Special syscall.Timespec NSec values UTIME_NOW and UTIME_OMIT may be
+// specified instead of real timestamps. A nil `times` parameter behaves the
+// same as if both were set to UTIME_NOW.
+//
+// When the `symlinkFollow` parameter is true and the path is a symbolic link,
+// the target of expanding that link is updated.
+//
+// # Errors
+//
+// The following errors are expected:
+//   - syscall.EINVAL: `path` is invalid.
+//   - syscall.EEXIST: `path` exists and is a directory.
+//   - syscall.ENOTDIR: `path` exists and is a file.
+//
+// # Notes
+//
+//   - This is similar to syscall.UtimesNano, except that doesn't have flags to
+//     control expansion of symbolic links. It also doesn't support special
+//     values UTIME_NOW or UTIME_NOW.
+//   - This is like `utimensat` with `AT_FDCWD` in POSIX. See
+//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+func Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	err := utimens(path, times, symlinkFollow)
+	return UnwrapOSError(err)
+}
+
+// UtimensFile is like Utimens, except it works on a file, not a path.
+//
+// # Notes
+//
+//   - Windows requires files to be open with syscall.O_RDWR, which means you
+//     cannot use this to update timestamps on a directory (syscall.EPERM).
+//   - This is like the function `futimens` in POSIX. See
+//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+func UtimensFile(f fs.File, times *[2]syscall.Timespec) error {
+	if f, ok := f.(fdFile); ok {
+		err := futimens(f.Fd(), times)
+		return UnwrapOSError(err)
+	}
+	return syscall.ENOSYS
+}
+
+func timesToPtr(times *[2]syscall.Timespec) unsafe.Pointer { //nolint:unused
+	var _p0 unsafe.Pointer
+	if times != nil {
+		_p0 = unsafe.Pointer(&times[0])
+	} else {
+		_p0 = unsafe.Pointer(&_zero)
+	}
+	return _p0
+}
+
+func utimensPortable(path string, times *[2]syscall.Timespec, symlinkFollow bool) error { //nolint:unused
+	if !symlinkFollow {
+		return syscall.ENOSYS
+	}
+
+	// Handle when both inputs are current system time.
+	if times == nil || times[0].Nsec == UTIME_NOW && times[1].Nsec == UTIME_NOW {
+		ts := nowTimespec()
+		return syscall.UtimesNano(path, []syscall.Timespec{ts, ts})
+	}
+
+	// When both inputs are omitted, there is nothing to change.
+	if times[0].Nsec == UTIME_OMIT && times[1].Nsec == UTIME_OMIT {
+		return nil
+	}
+
+	// Handle when neither input are special values
+	if times[0].Nsec != UTIME_NOW && times[1].Nsec != UTIME_NOW &&
+		times[0].Nsec != UTIME_OMIT && times[1].Nsec != UTIME_OMIT {
+		return syscall.UtimesNano(path, times[:])
+	}
+
+	// Now, either atim or mtim is a special value, but not both.
+
+	// Now, either one of the inputs is a special value, or neither. This means
+	// we don't have a risk of re-reading the clock or re-doing stat.
+	if atim, err := normalizeTimespec(path, times, 0); err != nil {
+		return err
+	} else if mtim, err := normalizeTimespec(path, times, 1); err != nil {
+		return err
+	} else {
+		return syscall.UtimesNano(path, []syscall.Timespec{atim, mtim})
+	}
+}
+
+func normalizeTimespec(path string, times *[2]syscall.Timespec, i int) (ts syscall.Timespec, err error) { //nolint:unused
+	switch times[i].Nsec {
+	case UTIME_NOW: // declined in Go per golang/go#31880.
+		ts = nowTimespec()
+		return
+	case UTIME_OMIT:
+		// UTIME_OMIT is expensive until progress is made in Go, as it requires a
+		// stat to read-back the value to re-apply.
+		// - https://github.com/golang/go/issues/32558.
+		// - https://go-review.googlesource.com/c/go/+/219638 (unmerged)
+		var st Stat_t
+		if err = stat(path, &st); err != nil {
+			return
+		}
+		switch i {
+		case 0:
+			ts = syscall.NsecToTimespec(st.Atim)
+		case 1:
+			ts = syscall.NsecToTimespec(st.Mtim)
+		default:
+			panic("BUG")
+		}
+		return
+	default: // not special
+		ts = times[i]
+		return
+	}
+}
+
+func nowTimespec() syscall.Timespec { //nolint:unused
+	now := time.Now().UnixNano()
+	return syscall.NsecToTimespec(now)
+}
diff --git a/internal/platform/utimes_darwin.go b/internal/platform/futimens_darwin.go
similarity index 57%
rename from internal/platform/utimes_darwin.go
rename to internal/platform/futimens_darwin.go
index a979e4ee..5b57a4b5 100644
--- a/internal/platform/utimes_darwin.go
+++ b/internal/platform/futimens_darwin.go
@@ -2,17 +2,34 @@ package platform
 
 import (
 	"syscall"
-	"unsafe"
+	_ "unsafe" // for go:linkname
 )
 
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	tv := []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
+const (
+	_AT_FDCWD               = -0x2
+	_AT_SYMLINK_NOFOLLOW    = 0x0020
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = true
+)
+
+//go:noescape
+//go:linkname utimensat syscall.utimensat
+func utimensat(dirfd int, path string, times *[2]syscall.Timespec, flags int) error
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	var flags int
+	if !symlinkFollow {
+		flags = _AT_SYMLINK_NOFOLLOW
 	}
+	return utimensat(_AT_FDCWD, path, times, flags)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	_p0 := timesToPtr(times)
 
 	// Warning: futimens only exists since High Sierra (10.13).
-	_, _, e1 := syscall_syscall6(libc_futimens_trampoline_addr, fd, uintptr(unsafe.Pointer(&tv[0])), 0, 0, 0, 0)
+	_, _, e1 := syscall_syscall6(libc_futimens_trampoline_addr, fd, uintptr(_p0), 0, 0, 0, 0)
 	if e1 != 0 {
 		return e1
 	}
@@ -20,7 +37,7 @@ func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
 }
 
 // libc_futimens_trampoline_addr is the address of the
-// `libc_futimens_trampoline` symbol, defined in `utimes_darwin.s`.
+// `libc_futimens_trampoline` symbol, defined in `futimens_darwin.s`.
 //
 // We use this to invoke the syscall through syscall_syscall6 imported below.
 var libc_futimens_trampoline_addr uintptr
diff --git a/internal/platform/utimes_darwin.s b/internal/platform/futimens_darwin.s
similarity index 100%
rename from internal/platform/utimes_darwin.s
rename to internal/platform/futimens_darwin.s
diff --git a/internal/platform/futimens_linux.go b/internal/platform/futimens_linux.go
new file mode 100644
index 00000000..07ebf2e2
--- /dev/null
+++ b/internal/platform/futimens_linux.go
@@ -0,0 +1,43 @@
+package platform
+
+import (
+	"syscall"
+	"unsafe"
+	_ "unsafe" // for go:linkname
+)
+
+const (
+	_AT_FDCWD               = -0x64
+	_AT_SYMLINK_NOFOLLOW    = 0x100
+	_UTIME_NOW              = (1 << 30) - 1
+	_UTIME_OMIT             = (1 << 30) - 2
+	SupportsSymlinkNoFollow = true
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) (err error) {
+	var flags int
+	if !symlinkFollow {
+		flags = _AT_SYMLINK_NOFOLLOW
+	}
+
+	var _p0 *byte
+	_p0, err = syscall.BytePtrFromString(path)
+	if err != nil {
+		return
+	}
+	return utimensat(_AT_FDCWD, uintptr(unsafe.Pointer(_p0)), times, flags)
+}
+
+// On linux, implement futimens via utimensat with the NUL path.
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	return utimensat(int(fd), 0 /* NUL */, times, 0)
+}
+
+// utimensat is like syscall.utimensat special-cased to accept a NUL string for the path value.
+func utimensat(dirfd int, strPtr uintptr, times *[2]syscall.Timespec, flags int) (err error) {
+	_, _, e1 := syscall.Syscall6(syscall.SYS_UTIMENSAT, uintptr(dirfd), strPtr, uintptr(unsafe.Pointer(times)), uintptr(flags), 0, 0)
+	if e1 != 0 {
+		err = e1
+	}
+	return
+}
diff --git a/internal/platform/futimens_unsupported.go b/internal/platform/futimens_unsupported.go
new file mode 100644
index 00000000..3fb3c92b
--- /dev/null
+++ b/internal/platform/futimens_unsupported.go
@@ -0,0 +1,23 @@
+//go:build !windows && !linux && !darwin
+
+package platform
+
+import "syscall"
+
+// Define values even if not used except as sentinels.
+const (
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = false
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return utimensPortable(path, times, symlinkFollow)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	// Go exports syscall.Futimes, which is microsecond granularity, and
+	// WASI tests expect nanosecond. We don't yet have a way to invoke the
+	// futimens syscall portably.
+	return syscall.ENOSYS
+}
diff --git a/internal/platform/futimens_windows.go b/internal/platform/futimens_windows.go
new file mode 100644
index 00000000..c0bc03fa
--- /dev/null
+++ b/internal/platform/futimens_windows.go
@@ -0,0 +1,70 @@
+package platform
+
+import (
+	"syscall"
+	"time"
+)
+
+// Define values even if not used except as sentinels.
+const (
+	_UTIME_NOW              = -1
+	_UTIME_OMIT             = -2
+	SupportsSymlinkNoFollow = false
+)
+
+func utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return utimensPortable(path, times, symlinkFollow)
+}
+
+func futimens(fd uintptr, times *[2]syscall.Timespec) error {
+	// Before Go 1.20, ERROR_INVALID_HANDLE was returned for too many reasons.
+	// Kick out so that callers can use path-based operations instead.
+	if !IsGo120 {
+		return syscall.ENOSYS
+	}
+
+	// Per docs, zero isn't a valid timestamp as it cannot be differentiated
+	// from nil. In both cases, it is a marker like syscall.UTIME_OMIT.
+	// See https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfiletime
+	a, w := timespecToFiletime(times)
+
+	if a == nil && w == nil {
+		return nil // both omitted, so nothing to change
+	}
+
+	// Attempt to get the stat by handle, which works for normal files
+	h := syscall.Handle(fd)
+
+	// Note: This returns ERROR_ACCESS_DENIED when the input is a directory.
+	return syscall.SetFileTime(h, nil, a, w)
+}
+
+func timespecToFiletime(times *[2]syscall.Timespec) (a, w *syscall.Filetime) {
+	// Handle when both inputs are current system time.
+	if times == nil || times[0].Nsec == UTIME_NOW && times[1].Nsec == UTIME_NOW {
+		now := time.Now().UnixNano()
+		ft := syscall.NsecToFiletime(now)
+		return &ft, &ft
+	}
+
+	// Now, either one of the inputs is current time, or neither. This
+	// means we don't have a risk of re-reading the clock.
+	a = timespecToFileTime(times, 0)
+	w = timespecToFileTime(times, 1)
+	return
+}
+
+func timespecToFileTime(times *[2]syscall.Timespec, i int) *syscall.Filetime {
+	if times[i].Nsec == UTIME_OMIT {
+		return nil
+	}
+
+	var nsec int64
+	if times[i].Nsec == UTIME_NOW {
+		nsec = time.Now().UnixNano()
+	} else {
+		nsec = syscall.TimespecToNsec(times[i])
+	}
+	ft := syscall.NsecToFiletime(nsec)
+	return &ft
+}
diff --git a/internal/platform/mmap.go b/internal/platform/mmap.go
index 70c2cdd1..ce53dfc5 100644
--- a/internal/platform/mmap.go
+++ b/internal/platform/mmap.go
@@ -65,8 +65,6 @@ func mmapCodeSegmentARM64(code io.Reader, size int) ([]byte, error) {
 	return mmapFunc, err
 }
 
-var _zero uintptr
-
 // mprotect is like syscall.Mprotect, defined locally so that freebsd compiles.
 func mprotect(b []byte, prot int) (err error) {
 	var _p0 unsafe.Pointer
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
index 99141d05..ccfa1a75 100644
--- a/internal/platform/platform.go
+++ b/internal/platform/platform.go
@@ -17,6 +17,8 @@ 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
 
+var _zero uintptr
+
 // CompilerSupported is exported for tests and includes constraints here and also the assembler.
 func CompilerSupported() bool {
 	switch runtime.GOOS {
diff --git a/internal/platform/utimes.go b/internal/platform/utimes.go
deleted file mode 100644
index fac570af..00000000
--- a/internal/platform/utimes.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package platform
-
-import (
-	"io/fs"
-	"syscall"
-)
-
-// UtimesNano is like syscall.UtimesNano. This returns syscall.ENOENT if the
-// path doesn't exist.
-//
-// Note: This is like the function `utimensat` with `AT_FDCWD` in POSIX.
-// See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-func UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
-	err := syscall.UtimesNano(path, []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
-	})
-	return UnwrapOSError(err)
-}
-
-// UtimesNanoFile is like syscall.Futimes, but for nanosecond precision and
-// fs.File instead of a file descriptor. This returns syscall.EBADF if the file
-// or directory was closed, or syscall.EPERM if the file wasn't opened with
-// permission to update its utimes. On syscall.EPERM, or on syscall.ENOSYS, use
-// UtimesNano with the original path.
-//
-// Note: This is like the function `futimens` in POSIX.
-// See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-func UtimesNanoFile(f fs.File, atimeNsec, mtimeNsec int64) error {
-	if f, ok := f.(fdFile); ok {
-		err := futimens(f.Fd(), atimeNsec, mtimeNsec)
-		return UnwrapOSError(err)
-	}
-	return syscall.ENOSYS
-}
diff --git a/internal/platform/utimes_linux.go b/internal/platform/utimes_linux.go
deleted file mode 100644
index 2d79fe74..00000000
--- a/internal/platform/utimes_linux.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package platform
-
-import (
-	"syscall"
-	"unsafe"
-)
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	times := []syscall.Timespec{
-		syscall.NsecToTimespec(atimeNsec),
-		syscall.NsecToTimespec(mtimeNsec),
-	}
-	_, _, err := syscall.Syscall6(syscall.SYS_UTIMENSAT, fd, uintptr(0), uintptr(unsafe.Pointer(&times[0])), uintptr(0), 0, 0)
-	if err != 0 {
-		return err
-	}
-	return nil
-}
diff --git a/internal/platform/utimes_unsupported.go b/internal/platform/utimes_unsupported.go
deleted file mode 100644
index f88c0bc6..00000000
--- a/internal/platform/utimes_unsupported.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build !windows && !linux && !darwin
-
-package platform
-
-import "syscall"
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	// Go exports syscall.Futimes, which is microsecond granularity, and
-	// WASI tests expect nanosecond. We don't yet have a way to invoke the
-	// futimens syscall portably.
-	return syscall.ENOSYS
-}
diff --git a/internal/platform/utimes_windows.go b/internal/platform/utimes_windows.go
deleted file mode 100644
index e5d4723c..00000000
--- a/internal/platform/utimes_windows.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package platform
-
-import "syscall"
-
-func futimens(fd uintptr, atimeNsec, mtimeNsec int64) error {
-	// Before Go 1.20, ERROR_INVALID_HANDLE was returned for too many reasons.
-	// Kick out so that callers can use path-based operations instead.
-	if !IsGo120 {
-		return syscall.ENOSYS
-	}
-
-	// Attempt to get the stat by handle, which works for normal files
-	h := syscall.Handle(fd)
-
-	// Perform logic similar to what's done in syscall.UtimesNano
-	a := syscall.NsecToFiletime(atimeNsec)
-	w := syscall.NsecToFiletime(mtimeNsec)
-
-	// Note: This returns ERROR_ACCESS_DENIED when the input is a directory.
-	return syscall.SetFileTime(h, nil, &a, &w)
-}
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 28b1983e..a02d2c15 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -124,9 +124,9 @@ func (d *dirFS) Symlink(oldName, link string) (err error) {
 	return platform.UnwrapOSError(err)
 }
 
-// UtimesNano implements FS.UtimesNano
-func (d *dirFS) UtimesNano(name string, atimeNsec, mtimeNsec int64) error {
-	return platform.UtimesNano(d.join(name), atimeNsec, mtimeNsec)
+// Utimens implements FS.Utimens
+func (d *dirFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
+	return platform.Utimens(d.join(path), times, symlinkFollow)
 }
 
 // Truncate implements FS.Truncate
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
index b9e420fa..445b99ff 100644
--- a/internal/sysfs/rootfs.go
+++ b/internal/sysfs/rootfs.go
@@ -308,10 +308,10 @@ func (c *CompositeFS) Link(oldName, newName string) error {
 	return c.fs[fromFS].Link(oldNamePath, newNamePath)
 }
 
-// UtimesNano implements FS.UtimesNano
-func (c *CompositeFS) UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
+// Utimens implements FS.Utimens
+func (c *CompositeFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
 	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].UtimesNano(relativePath, atimeNsec, mtimeNsec)
+	return c.fs[matchIndex].Utimens(relativePath, times, symlinkFollow)
 }
 
 // Symlink implements FS.Symlink
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index d330052d..71865e9c 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -269,23 +269,34 @@ type FS interface {
 	//   - syscall.EACCES: `path` doesn't have write access.
 	Truncate(path string, size int64) error
 
-	// UtimesNano is similar to syscall.UtimesNano, except the path is relative
-	// to this file system.
+	// Utimens set file access and modification times on a path relative to
+	// this file system, at nanosecond precision.
+	//
+	// # Parameters
+	//
+	// The `times` parameter includes the access and modification timestamps to
+	// assign. Special syscall.Timespec NSec values UTIME_NOW and UTIME_OMIT may be
+	// specified instead of real timestamps. A nil `times` parameter behaves the
+	// same as if both were set to UTIME_NOW.
+	//
+	// When the `symlinkFollow` parameter is true and the path is a symbolic link,
+	// the target of expanding that link is updated.
 	//
 	// # Errors
 	//
 	// The following errors are expected:
 	//   - syscall.EINVAL: `path` is invalid.
-	//   - syscall.ENOENT: `path` doesn't exist
+	//   - syscall.EEXIST: `path` exists and is a directory.
+	//   - syscall.ENOTDIR: `path` exists and is a file.
 	//
 	// # 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.
-	//   - This is like the function `utimensat` with `AT_FDCWD` in POSIX.
-	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
-	UtimesNano(path string, atimeNsec, mtimeNsec int64) error
+	//   - This is similar to syscall.Utimens, except that doesn't have flags to
+	//     control expansion of symbolic links. It also doesn't support special
+	//     values UTIME_NOW or UTIME_NOW.
+	//   - This is like `utimensat` with `AT_FDCWD` in POSIX. See
+	//     https://pubs.opengroup.org/onlinepubs/9699919799/functions/futimens.html
+	Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error
 }
 
 // ReaderAtOffset gets an io.Reader from a fs.File that reads from an offset,
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index c89a551c..8e3cd40f 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -86,8 +86,8 @@ func (UnimplementedFS) Unlink(path string) error {
 	return syscall.ENOSYS
 }
 
-// UtimesNano implements FS.UtimesNano
-func (UnimplementedFS) UtimesNano(path string, atimeNsec, mtimeNsec int64) error {
+// Utimens implements FS.Utimens
+func (UnimplementedFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) error {
 	return syscall.ENOSYS
 }
 
diff --git a/netlify.toml b/netlify.toml
index 87cf5839..4d4d72d5 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -3,7 +3,7 @@
   publish = "public"
 
 [build.environment]
-  HUGO_VERSION = "0.110.0"
+  HUGO_VERSION = "0.111.2"
 
 [context.production]
   command = "git submodule update --init && hugo --gc --minify"


Test patch:
diff --git a/internal/platform/bench_test.go b/internal/platform/bench_test.go
index 1e3ddcde..51cbdfee 100644
--- a/internal/platform/bench_test.go
+++ b/internal/platform/bench_test.go
@@ -3,6 +3,7 @@ package platform
 import (
 	"os"
 	"path"
+	"syscall"
 	"testing"
 )
 
@@ -12,7 +13,7 @@ func Benchmark_IsTerminal(b *testing.B) {
 	}
 }
 
-func Benchmark_UtimesNano(b *testing.B) {
+func Benchmark_UtimensFile(b *testing.B) {
 	tmpDir := b.TempDir()
 	f, err := os.Create(path.Join(tmpDir, "file"))
 	if err != nil {
@@ -20,9 +21,14 @@ func Benchmark_UtimesNano(b *testing.B) {
 	}
 	defer f.Close()
 
+	times := &[2]syscall.Timespec{
+		{Sec: 123, Nsec: 4 * 1e3},
+		{Sec: 123, Nsec: 4 * 1e3},
+	}
+
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		if err := UtimesNanoFile(f, 1, 1); err != nil {
+		if err := UtimensFile(f, times); err != nil {
 			b.Fatal(err)
 		}
 	}
diff --git a/internal/platform/futimens_test.go b/internal/platform/futimens_test.go
new file mode 100644
index 00000000..4f91a37e
--- /dev/null
+++ b/internal/platform/futimens_test.go
@@ -0,0 +1,226 @@
+package platform
+
+import (
+	"os"
+	"path"
+	"runtime"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestUtimens(t *testing.T) {
+	t.Run("doesn't exist", func(t *testing.T) {
+		err := Utimens("nope", nil, true)
+		require.EqualErrno(t, syscall.ENOENT, err)
+		err = Utimens("nope", nil, false)
+		if SupportsSymlinkNoFollow {
+			require.EqualErrno(t, syscall.ENOENT, err)
+		} else {
+			require.EqualErrno(t, syscall.ENOSYS, err)
+		}
+	})
+	testFutimens(t, true)
+}
+
+func testFutimens(t *testing.T, usePath bool) {
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	//
+	// Negative isn't tested as most platforms don't return consistent results.
+	tests := []struct {
+		name  string
+		times *[2]syscall.Timespec
+	}{
+		{
+			name: "nil",
+		},
+		{
+			name: "a=omit,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=now,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=omit,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_OMIT},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: UTIME_NOW},
+				{Sec: 123, Nsec: 4 * 1e3},
+			},
+		},
+		{
+			name: "a=set,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 123, Nsec: UTIME_NOW},
+			},
+		},
+		{
+			name: "a=set,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 223, Nsec: 5 * 1e3},
+			},
+		},
+	}
+	for _, fileType := range []string{"dir", "file", "link", "link-follow"} {
+		for _, tt := range tests {
+			tc := tt
+			fileType := fileType
+			name := fileType + " " + tc.name
+			symlinkNoFollow := fileType == "link"
+
+			// symlinkNoFollow is invalid for file descriptor based operations,
+			// because the default for open is to follow links. You can't avoid
+			// this. O_NOFOLLOW is used only to return ELOOP on a link.
+			if !usePath && symlinkNoFollow {
+				continue
+			}
+
+			t.Run(name, func(t *testing.T) {
+				tmpDir := t.TempDir()
+				file := path.Join(tmpDir, "file")
+				err := os.WriteFile(file, []byte{}, 0o700)
+				require.NoError(t, err)
+
+				link := file + "-link"
+				require.NoError(t, os.Symlink(file, link))
+
+				dir := path.Join(tmpDir, "dir")
+				err = os.Mkdir(dir, 0o700)
+				require.NoError(t, err)
+
+				var path, statPath string
+				switch fileType {
+				case "dir":
+					path = dir
+					statPath = dir
+				case "file":
+					path = file
+					statPath = file
+				case "link":
+					path = link
+					statPath = link
+				case "link-follow":
+					path = link
+					statPath = file
+				default:
+					panic(tc)
+				}
+
+				var oldSt Stat_t
+				require.NoError(t, Lstat(statPath, &oldSt))
+
+				if usePath {
+					err = Utimens(path, tc.times, !symlinkNoFollow)
+					if symlinkNoFollow && !SupportsSymlinkNoFollow {
+						require.EqualErrno(t, syscall.ENOSYS, err)
+						return
+					}
+					require.NoError(t, err)
+				} else {
+					flag := syscall.O_RDWR
+					if path == dir {
+						flag = syscall.O_RDONLY
+						if runtime.GOOS == "windows" {
+							// windows requires O_RDWR, which is invalid for directories
+							t.Skip("windows cannot update timestamps on a dir")
+						}
+					}
+					f, err := OpenFile(path, flag, 0)
+					require.NoError(t, err)
+					err = UtimensFile(f, tc.times)
+					require.NoError(t, f.Close())
+					require.NoError(t, err)
+				}
+
+				var newSt Stat_t
+				require.NoError(t, Lstat(statPath, &newSt))
+
+				if CompilerSupported() {
+					if tc.times != nil && tc.times[0].Nsec == UTIME_OMIT {
+						require.Equal(t, oldSt.Atim, newSt.Atim)
+					} else if tc.times == nil || tc.times[0].Nsec == UTIME_NOW {
+						now := time.Now().UnixNano()
+						require.True(t, newSt.Atim <= now, "expected atim %d <= now %d", newSt.Atim, now)
+					} else {
+						require.Equal(t, tc.times[0].Nano(), newSt.Atim)
+					}
+				}
+
+				// When compiler isn't supported, we can still check mtim.
+				if tc.times != nil && tc.times[1].Nsec == UTIME_OMIT {
+					require.Equal(t, oldSt.Mtim, newSt.Mtim)
+				} else if tc.times == nil || tc.times[1].Nsec == UTIME_NOW {
+					now := time.Now().UnixNano()
+					require.True(t, newSt.Mtim <= now, "expected mtim %d <= now %d", newSt.Mtim, now)
+				} else {
+					require.Equal(t, tc.times[1].Nano(), newSt.Mtim)
+				}
+			})
+		}
+	}
+}
+
+func TestUtimensFile(t *testing.T) {
+	switch runtime.GOOS {
+	case "linux", "darwin": // supported
+	case "freebsd": // TODO: support freebsd w/o CGO
+	case "windows":
+		if !IsGo120 {
+			t.Skip("windows only works after Go 1.20") // TODO: possibly 1.19 ;)
+		}
+	default: // expect ENOSYS and callers need to fall back to Utimens
+		t.Skip("unsupported GOOS", runtime.GOOS)
+	}
+
+	testFutimens(t, false)
+
+	t.Run("closed file", func(t *testing.T) {
+		file := path.Join(t.TempDir(), "file")
+		err := os.WriteFile(file, []byte{}, 0o700)
+		require.NoError(t, err)
+		fileF, err := OpenFile(file, syscall.O_RDWR, 0)
+		require.NoError(t, err)
+		require.NoError(t, fileF.Close())
+
+		err = UtimensFile(fileF, nil)
+		require.EqualErrno(t, syscall.EBADF, err)
+	})
+
+	t.Run("closed dir", func(t *testing.T) {
+		dir := path.Join(t.TempDir(), "dir")
+		err := os.Mkdir(dir, 0o700)
+		require.NoError(t, err)
+		dirF, err := OpenFile(dir, syscall.O_RDONLY, 0)
+		require.NoError(t, err)
+		require.NoError(t, dirF.Close())
+
+		err = UtimensFile(dirF, nil)
+		require.EqualErrno(t, syscall.EBADF, err)
+	})
+}
diff --git a/internal/platform/utimes_test.go b/internal/platform/utimes_test.go
deleted file mode 100644
index 596dc162..00000000
--- a/internal/platform/utimes_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package platform
-
-import (
-	"io/fs"
-	"os"
-	"path"
-	"runtime"
-	"syscall"
-	"testing"
-	"time"
-
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-func TestUtimesNano(t *testing.T) {
-	tmpDir := t.TempDir()
-	file := path.Join(tmpDir, "file")
-	err := os.WriteFile(file, []byte{}, 0o700)
-	require.NoError(t, err)
-
-	dir := path.Join(tmpDir, "dir")
-	err = os.Mkdir(dir, 0o700)
-	require.NoError(t, err)
-
-	t.Run("doesn't exist", func(t *testing.T) {
-		err := UtimesNano("nope",
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.ENOENT, err)
-	})
-
-	type test struct {
-		name                 string
-		path                 string
-		atimeNsec, mtimeNsec int64
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []test{
-		{
-			name:      "file positive",
-			path:      file,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{
-			name:      "dir positive",
-			path:      dir,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", path: file},
-		{name: "dir zero", path: dir},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := UtimesNano(tc.path, tc.atimeNsec, tc.mtimeNsec)
-			require.NoError(t, err)
-
-			var stat Stat_t
-			require.NoError(t, Stat(tc.path, &stat))
-			if CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-}
-
-func TestUtimesNanoFile(t *testing.T) {
-	switch runtime.GOOS {
-	case "linux", "darwin": // supported
-	case "freebsd": // TODO: support freebsd w/o CGO
-	case "windows":
-		if !IsGo120 {
-			t.Skip("windows only works after Go 1.20") // TODO: possibly 1.19 ;)
-		}
-	default: // expect ENOSYS and callers need to fall back to UtimesNano
-		t.Skip("unsupported GOOS", runtime.GOOS)
-	}
-
-	tmpDir := t.TempDir()
-
-	file := path.Join(tmpDir, "file")
-	err := os.WriteFile(file, []byte{}, 0o700)
-	require.NoError(t, err)
-	fileF, err := OpenFile(file, syscall.O_RDWR, 0)
-	require.NoError(t, err)
-	defer fileF.Close()
-
-	dir := path.Join(tmpDir, "dir")
-	err = os.Mkdir(dir, 0o700)
-	require.NoError(t, err)
-	dirF, err := OpenFile(dir, syscall.O_RDONLY, 0)
-	require.NoError(t, err)
-	defer fileF.Close()
-
-	type test struct {
-		name                 string
-		file                 fs.File
-		atimeNsec, mtimeNsec int64
-		expectedErr          error
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []*test{
-		{
-			name:      "file positive",
-			file:      fileF,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", file: fileF},
-		{
-			name:      "dir positive",
-			file:      dirF,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "dir zero", file: dirF},
-	}
-
-	// In windows, trying to update the time of a directory fails, as it is
-	// addressed by path, not by file descriptor.
-	if runtime.GOOS == "windows" {
-		for _, tt := range tests {
-			if tt.file == dirF {
-				tt.expectedErr = syscall.EPERM
-			}
-		}
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := UtimesNanoFile(tc.file, tc.atimeNsec, tc.mtimeNsec)
-			if tc.expectedErr != nil {
-				require.EqualErrno(t, tc.expectedErr.(syscall.Errno), err)
-				return
-			}
-
-			var stat Stat_t
-			require.NoError(t, StatFile(tc.file, &stat))
-			if CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-
-	require.NoError(t, fileF.Close())
-	t.Run("closed file", func(t *testing.T) {
-		err := UtimesNanoFile(fileF,
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.EBADF, err)
-	})
-
-	require.NoError(t, dirF.Close())
-	t.Run("closed dir", func(t *testing.T) {
-		err := UtimesNanoFile(dirF,
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.EBADF, err)
-	})
-}
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 25f0d88a..06326494 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -91,7 +91,7 @@ func TestAdapt_UtimesNano(t *testing.T) {
 	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
-	err := testFS.UtimesNano(path, 1, 1)
+	err := testFS.Utimens(path, nil, true)
 	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 9753b626..6a68396e 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -8,6 +8,7 @@ import (
 	"runtime"
 	"syscall"
 	"testing"
+	"time"
 
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
@@ -498,11 +499,167 @@ func TestDirFS_Unlink(t *testing.T) {
 	})
 }
 
-func TestDirFS_UtimesNano(t *testing.T) {
+func TestDirFS_Utimesns(t *testing.T) {
 	tmpDir := t.TempDir()
 	testFS := NewDirFS(tmpDir)
 
-	testUtimesNano(t, tmpDir, testFS)
+	file := "file"
+	err := os.WriteFile(pathutil.Join(tmpDir, file), []byte{}, 0o700)
+	require.NoError(t, err)
+
+	dir := "dir"
+	err = os.Mkdir(pathutil.Join(tmpDir, dir), 0o700)
+	require.NoError(t, err)
+
+	t.Run("doesn't exist", func(t *testing.T) {
+		err := testFS.Utimens("nope", nil, true)
+		require.EqualErrno(t, syscall.ENOENT, err)
+		err = testFS.Utimens("nope", nil, false)
+		if platform.SupportsSymlinkNoFollow {
+			require.EqualErrno(t, syscall.ENOENT, err)
+		} else {
+			require.EqualErrno(t, syscall.ENOSYS, err)
+		}
+	})
+
+	// Note: This sets microsecond granularity because Windows doesn't support
+	// nanosecond.
+	//
+	// Negative isn't tested as most platforms don't return consistent results.
+	tests := []struct {
+		name  string
+		times *[2]syscall.Timespec
+	}{
+		{
+			name: "nil",
+		},
+		{
+			name: "a=omit,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=now,m=omit",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+			},
+		},
+		{
+			name: "a=omit,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_OMIT},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=now,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+				{Sec: 123, Nsec: 4 * 1e3},
+			},
+		},
+		{
+			name: "a=set,m=now",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 123, Nsec: platform.UTIME_NOW},
+			},
+		},
+		{
+			name: "a=set,m=set",
+			times: &[2]syscall.Timespec{
+				{Sec: 123, Nsec: 4 * 1e3},
+				{Sec: 223, Nsec: 5 * 1e3},
+			},
+		},
+	}
+
+	for _, fileType := range []string{"dir", "file", "link", "link-follow"} {
+		for _, tt := range tests {
+			tc := tt
+			fileType := fileType
+			name := fileType + " " + tc.name
+			symlinkNoFollow := fileType == "link"
+
+			t.Run(name, func(t *testing.T) {
+				tmpDir := t.TempDir()
+				testFS := NewDirFS(tmpDir)
+
+				file := pathutil.Join(tmpDir, "file")
+				err := os.WriteFile(file, []byte{}, 0o700)
+				require.NoError(t, err)
+
+				link := file + "-link"
+				require.NoError(t, os.Symlink(file, link))
+
+				dir := pathutil.Join(tmpDir, "dir")
+				err = os.Mkdir(dir, 0o700)
+				require.NoError(t, err)
+
+				var path, statPath string
+				switch fileType {
+				case "dir":
+					path = "dir"
+					statPath = "dir"
+				case "file":
+					path = "file"
+					statPath = "file"
+				case "link":
+					path = "file-link"
+					statPath = "file-link"
+				case "link-follow":
+					path = "file-link"
+					statPath = "file"
+				default:
+					panic(tc)
+				}
+
+				var oldSt platform.Stat_t
+				require.NoError(t, testFS.Lstat(statPath, &oldSt))
+
+				err = testFS.Utimens(path, tc.times, !symlinkNoFollow)
+				if symlinkNoFollow && !platform.SupportsSymlinkNoFollow {
+					require.EqualErrno(t, syscall.ENOSYS, err)
+					return
+				}
+				require.NoError(t, err)
+
+				var newSt platform.Stat_t
+				require.NoError(t, testFS.Lstat(statPath, &newSt))
+
+				if platform.CompilerSupported() {
+					if tc.times != nil && tc.times[0].Nsec == platform.UTIME_OMIT {
+						require.Equal(t, oldSt.Atim, newSt.Atim)
+					} else if tc.times == nil || tc.times[0].Nsec == platform.UTIME_NOW {
+						now := time.Now().UnixNano()
+						require.True(t, newSt.Atim <= now, "expected atim %d <= now %d", newSt.Atim, now)
+					} else {
+						require.Equal(t, tc.times[0].Nano(), newSt.Atim)
+					}
+				}
+
+				// When compiler isn't supported, we can still check mtim.
+				if tc.times != nil && tc.times[1].Nsec == platform.UTIME_OMIT {
+					require.Equal(t, oldSt.Mtim, newSt.Mtim)
+				} else if tc.times == nil || tc.times[1].Nsec == platform.UTIME_NOW {
+					now := time.Now().UnixNano()
+					require.True(t, newSt.Mtim <= now, "expected mtim %d <= now %d", newSt.Mtim, now)
+				} else {
+					require.Equal(t, tc.times[1].Nano(), newSt.Mtim)
+				}
+			})
+		}
+	}
 }
 
 func TestDirFS_OpenFile(t *testing.T) {
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index da7ebc31..518b19d5 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -122,7 +122,7 @@ func TestReadFS_UtimesNano(t *testing.T) {
 	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
-	err := testFS.UtimesNano(path, 1, 1)
+	err := testFS.Utimens(path, nil, true)
 	require.EqualErrno(t, syscall.ENOSYS, err)
 }
 
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 924facb7..0f7ed744 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -13,7 +13,6 @@ import (
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
-	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -389,65 +388,6 @@ func testReadlink(t *testing.T, readFS, writeFS FS) {
 	})
 }
 
-func testUtimesNano(t *testing.T, tmpDir string, testFS FS) {
-	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.UtimesNano("nope",
-			time.Unix(123, 4*1e3).UnixNano(),
-			time.Unix(567, 8*1e3).UnixNano())
-		require.EqualErrno(t, syscall.ENOENT, err)
-	})
-
-	type test struct {
-		name                 string
-		path                 string
-		atimeNsec, mtimeNsec int64
-	}
-
-	// Note: This sets microsecond granularity because Windows doesn't support
-	// nanosecond.
-	//
-	// Negative isn't tested as most platforms don't return consistent results.
-	tests := []test{
-		{
-			name:      "file positive",
-			path:      file,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{
-			name:      "dir positive",
-			path:      dir,
-			atimeNsec: time.Unix(123, 4*1e3).UnixNano(),
-			mtimeNsec: time.Unix(567, 8*1e3).UnixNano(),
-		},
-		{name: "file zero", path: file},
-		{name: "dir zero", path: dir},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			err := testFS.UtimesNano(tc.path, tc.atimeNsec, tc.mtimeNsec)
-			require.NoError(t, err)
-
-			var stat platform.Stat_t
-			require.NoError(t, testFS.Stat(tc.path, &stat))
-			if platform.CompilerSupported() {
-				require.Equal(t, stat.Atim, tc.atimeNsec)
-			} // else only mtimes will return.
-			require.Equal(t, stat.Mtim, tc.mtimeNsec)
-		})
-	}
-}
-
 var (
 	//go:embed testdata
 	testdata     embed.FS