Repository: tetratelabs/wazero
Pull request #991 (2022-12-31 08:10:18)

Implements rename in GOOS=js and WASI
This implements rename, which is the last function needed to pass TinyGo os package tests:

```bash
$ wazero run  -mount=.:/ os.wasm -test.v
=== RUN   TestExpand
--- PASS: TestExpand (0.00s)
=== RUN   TestConsistentEnviron
--- PASS: TestConsistentEnviron (0.00s)
=== RUN   TestUnsetenv
--- PASS: TestUnsetenv (0.00s)
=== RUN   TestClearenv
--- PASS: TestClearenv (0.00s)
=== RUN   TestLookupEnv
--- PASS: TestLookupEnv (0.00s)
=== RUN   TestEnvironConsistency
--- PASS: TestEnvironConsistency (0.00s)
=== RUN   TestSetenvUnixEinval
--- PASS: TestSetenvUnixEinval (0.00s)
=== RUN   TestExpandEnvShellSpecialVar
--- PASS: TestExpandEnvShellSpecialVar (0.00s)
=== RUN   TestTempDir
--- PASS: TestTempDir (0.00s)
=== RUN   TestChdir
--- PASS: TestChdir (0.00s)
=== RUN   TestStandardFd
--- PASS: TestStandardFd (0.00s)
=== RUN   TestFd
--- PASS: TestFd (0.00s)
=== RUN   TestGetpagesize
--- PASS: TestGetpagesize (0.00s)
=== RUN   TestMkdir
--- PASS: TestMkdir (0.00s)
=== RUN   TestStatBadDir
--- PASS: TestStatBadDir (0.00s)
=== RUN   TestFstat
--- PASS: TestFstat (0.00s)
=== RUN   TestRemove
--- PASS: TestRemove (0.00s)
=== RUN   TestRename
--- PASS: TestRename (0.00s)
=== RUN   TestRenameOverwriteDest
--- PASS: TestRenameOverwriteDest (0.00s)
=== RUN   TestRenameFailed
--- PASS: TestRenameFailed (0.00s)
=== RUN   TestUserHomeDir
--- PASS: TestUserHomeDir (0.00s)
    UserHomeDir failed: $HOME is not defined
=== RUN   TestDirFS
--- PASS: TestDirFS (0.00s)
    TODO: allow foo/bar/. as synonym for path foo/bar on wasi?
=== RUN   TestDirFSPathsValid
--- PASS: TestDirFSPathsValid (0.00s)
    skipping on wasi because it fails on wasi on windows
=== RUN   TestRead0
--- PASS: TestRead0 (0.00s)
=== RUN   TestReadAt0
--- PASS: TestReadAt0 (0.00s)
=== RUN   TestSeek
--- PASS: TestSeek (0.00s)
=== RUN   TestReadAt
--- PASS: TestReadAt (0.00s)
=== RUN   TestReadAtOffset
--- PASS: TestReadAtOffset (0.00s)
=== RUN   TestReadAtNegativeOffset
--- PASS: TestReadAtNegativeOffset (0.00s)
=== RUN   TestReadAtEOF
--- PASS: TestReadAtEOF (0.00s)
=== RUN   TestMkdirAll
--- PASS: TestMkdirAll (0.00s)
=== RUN   TestDirAndSymlinkStats
--- PASS: TestDirAndSymlinkStats (0.00s)
=== RUN   TestFileAndSymlinkStats
--- PASS: TestFileAndSymlinkStats (0.00s)
PASS
```


Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 66edfba9..acc52f95 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -1291,7 +1291,7 @@ var pathOpen = newHostFunc(
 func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	dirfd := uint32(params[0])
+	preopenFD := uint32(params[0])
 
 	// TODO: dirflags is a lookupflags, and it only has one bit: symlink_follow
 	// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#lookupflags
@@ -1308,7 +1308,7 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	fdflags := uint16(params[7])
 	resultOpenedFd := uint32(params[8])
 
-	pathName, errno := atPath(fsc, mod.Memory(), dirfd, path, pathLen)
+	pathName, errno := atPath(fsc, mod.Memory(), preopenFD, path, pathLen)
 	if errno != ErrnoSuccess {
 		return errno
 	}
@@ -1346,15 +1346,19 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 // here in any way except assuming it is "/".
 //
 // See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/sources/at_fdcwd.c#L24-L26
-//
-// TODO: path is not precise here, as it should be a path relative to the
-// FD, which isn't always rootFD (3). This means the path for Open may need
-// to be built up. For example, if dirfd represents "/tmp/foo" and
-// path="bar", this should open "/tmp/foo/bar" not "/bar".
-//
 // See https://linux.die.net/man/2/openat
-func atPath(fsc *sys.FSContext, mem api.Memory, dirfd, path, pathLen uint32) (string, Errno) {
-	if _, ok := fsc.OpenedFile(dirfd); !ok {
+func atPath(fsc *sys.FSContext, mem api.Memory, dirFd, path, pathLen uint32) (string, Errno) {
+	if dirFd != sys.FdRoot { //nolint
+		// TODO: Research if dirFd is always a pre-open. If so, it should
+		// always be rootFd (3), until we support multiple pre-opens.
+		//
+		// Otherwise, the dirFd could be a file created dynamically, and mean
+		// paths for Open may need to be built up. For example, if dirFd
+		// represents "/tmp/foo" and path="bar", this should open
+		// "/tmp/foo/bar" not "/bar".
+	}
+
+	if _, ok := fsc.OpenedFile(dirFd); !ok {
 		return "", ErrnoBadf
 	}
 
@@ -1456,14 +1460,65 @@ func pathRemoveDirectoryFn(_ context.Context, mod api.Module, params []uint64) E
 	return ErrnoSuccess
 }
 
-// pathRename is the WASI function named PathRenameName which renames a
-// file or directory.
-var pathRename = stubFunction(
-	PathRenameName,
+// pathRename is the WASI function named PathRenameName which renames a file or
+// directory.
+//
+// # Parameters
+//
+//   - fd: file descriptor of a directory that `old_path` is relative to
+//   - old_path: offset in api.Memory to read the old path string from
+//   - old_path_len: length of `old_path`
+//   - new_fd: file descriptor of a directory that `new_path` is relative to
+//   - new_path: offset in api.Memory to read the new path string from
+//   - new_path_len: length of `new_path`
+//
+// # Result (Errno)
+//
+// The return value is ErrnoSuccess except the following error conditions:
+//   - ErrnoBadf: `fd` or `new_fd` are invalid
+//   - ErrnoNoent: `old_path` does not exist.
+//   - ErrnoNotdir: `old` is a directory and `new` exists, but is a file.
+//   - ErrnoIsdir: `old` is a file and `new` exists, but is a directory.
+//
+// # Notes
+//   - This is similar to unlinkat in POSIX.
+//     See https://linux.die.net/man/2/renameat
+//
+// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-path_renamefd-fd-old_path-string-new_fd-fd-new_path-string---errno
+var pathRename = newHostFunc(
+	PathRenameName, pathRenameFn,
 	[]wasm.ValueType{i32, i32, i32, i32, i32, i32},
 	"fd", "old_path", "old_path_len", "new_fd", "new_path", "new_path_len",
 )
 
+func pathRenameFn(_ context.Context, mod api.Module, params []uint64) Errno {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+
+	oldDirFd := uint32(params[0])
+	oldPath := uint32(params[1])
+	oldPathLen := uint32(params[2])
+
+	newDirFd := uint32(params[3])
+	newPath := uint32(params[4])
+	newPathLen := uint32(params[5])
+
+	oldPathName, errno := atPath(fsc, mod.Memory(), oldDirFd, oldPath, oldPathLen)
+	if errno != ErrnoSuccess {
+		return errno
+	}
+
+	newPathName, errno := atPath(fsc, mod.Memory(), newDirFd, newPath, newPathLen)
+	if errno != ErrnoSuccess {
+		return errno
+	}
+
+	if err := fsc.Rename(oldPathName, newPathName); err != nil {
+		return ToErrno(err)
+	}
+
+	return ErrnoSuccess
+}
+
 // pathSymlink is the WASI function named PathSymlinkName which creates a
 // symbolic link.
 //
diff --git a/internal/gojs/custom/fs.go b/internal/gojs/custom/fs.go
index b56f4e42..ab2f70c6 100644
--- a/internal/gojs/custom/fs.go
+++ b/internal/gojs/custom/fs.go
@@ -9,11 +9,12 @@ const (
 	NameFsFstat   = "fstat"
 	NameFsLstat   = "lstat"
 	NameFsClose   = "close"
-	NameFsRead    = "read"
 	NameFsWrite   = "write"
+	NameFsRead    = "read"
 	NameFsReaddir = "readdir"
 	NameFsMkdir   = "mkdir"
 	NameFsRmdir   = "rmdir"
+	NameFsRename  = "rename"
 	NameFsUnlink  = "unlink"
 	NameFsUtimes  = "utimes"
 )
@@ -72,6 +73,11 @@ var FsNameSection = map[string]*Names{
 		ParamNames:  []string{"path", NameCallback},
 		ResultNames: []string{"err", "ok"},
 	},
+	NameFsRename: {
+		Name:        NameFsRename,
+		ParamNames:  []string{"from", "to", NameCallback},
+		ResultNames: []string{"err", "ok"},
+	},
 	NameFsUnlink: {
 		Name:        NameFsUnlink,
 		ParamNames:  []string{"path", NameCallback},
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index fd956e29..55c341b3 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -36,6 +36,7 @@ var (
 		addFunction(custom.NameFsReaddir, &jsfsReaddir{}).
 		addFunction(custom.NameFsMkdir, &jsfsMkdir{}).
 		addFunction(custom.NameFsRmdir, &jsfsRmdir{}).
+		addFunction(custom.NameFsRename, &jsfsRename{}).
 		addFunction(custom.NameFsUnlink, &jsfsUnlink{}).
 		addFunction(custom.NameFsUtimes, &jsfsUtimes{})
 
@@ -45,7 +46,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("rename", from, to) // syscall.Rename
 	//	* _, err := fsCall("truncate", path, length) // syscall.Truncate
 	//	* _, err := fsCall("ftruncate", fd, length) // syscall.Ftruncate
 	//	* dst, err := fsCall("readlink", path) // syscall.Readlink
@@ -463,6 +463,28 @@ func syscallRmdir(mod api.Module, name string) (interface{}, error) {
 	return err != nil, err
 }
 
+// jsfsRename implements the following
+//
+//   - _, err := fsCall("rename", from, to) // syscall.Rename
+type jsfsRename struct{}
+
+// invoke implements jsFn.invoke
+func (*jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	from := args[0].(string)
+	to := args[1].(string)
+	callback := args[2].(funcWrapper)
+
+	ok, err := syscallRename(mod, from, to)
+	return callback.invoke(ctx, mod, goos.RefJsfs, err, ok) // note: error first
+}
+
+// syscallRename is like syscall.Rename
+func syscallRename(mod api.Module, from, to string) (interface{}, error) {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	err := fsc.Rename(from, to)
+	return err != nil, err
+}
+
 // jsfsUnlink implements the following
 //
 //	_, err := fsCall("unlink", path) // syscall.Unlink
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index a9c03a3e..27180422 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -346,6 +346,17 @@ func (c *FSContext) StatPath(name string) (fs.FileInfo, error) {
 	return c.StatFile(fd)
 }
 
+// Rename is like syscall.Rename.
+func (c *FSContext) Rename(from, to string) (err error) {
+	if wfs, ok := c.fs.(syscallfs.FS); ok {
+		from = c.cleanPath(from)
+		to = c.cleanPath(to)
+		return wfs.Rename(from, to)
+	}
+	err = syscall.ENOSYS
+	return
+}
+
 // Unlink is like syscall.Unlink.
 func (c *FSContext) Unlink(name string) (err error) {
 	if wfs, ok := c.fs.(syscallfs.FS); ok {
diff --git a/internal/syscallfs/dirfs.go b/internal/syscallfs/dirfs.go
index 77b1edd1..5e4e0eac 100644
--- a/internal/syscallfs/dirfs.go
+++ b/internal/syscallfs/dirfs.go
@@ -17,6 +17,9 @@ func NewDirFS(dir string) (FS, error) {
 	return dirFS(dir), nil
 }
 
+// dirFS currently validates each path, which means that input paths cannot
+// escape the directory, except via symlink. We may want to relax this in the
+// future, especially as we decoupled from fs.FS which has this requirement.
 type dirFS string
 
 // Open implements the same method as documented on fs.FS
@@ -43,6 +46,20 @@ func (dir dirFS) Mkdir(name string, perm fs.FileMode) error {
 	return adjustMkdirError(err)
 }
 
+// Rename implements FS.Rename
+func (dir dirFS) Rename(from, to string) error {
+	if !fs.ValidPath(from) {
+		return syscall.EINVAL
+	}
+	if !fs.ValidPath(to) {
+		return syscall.EINVAL
+	}
+	if from == to {
+		return nil
+	}
+	return rename(path.Join(string(dir), from), path.Join(string(dir), to))
+}
+
 // Rmdir implements FS.Rmdir
 func (dir dirFS) Rmdir(name string) error {
 	if !fs.ValidPath(name) {
diff --git a/internal/syscallfs/syscall.go b/internal/syscallfs/syscall.go
index 1d797f44..fbbaf7ef 100644
--- a/internal/syscallfs/syscall.go
+++ b/internal/syscallfs/syscall.go
@@ -18,3 +18,7 @@ func adjustUnlinkError(err error) error {
 	}
 	return err
 }
+
+func rename(old, new string) error {
+	return syscall.Rename(old, new)
+}
diff --git a/internal/syscallfs/syscall_windows.go b/internal/syscallfs/syscall_windows.go
index 8afc07d8..c55e0664 100644
--- a/internal/syscallfs/syscall_windows.go
+++ b/internal/syscallfs/syscall_windows.go
@@ -1,7 +1,9 @@
 package syscallfs
 
 import (
+	"errors"
 	"io/fs"
+	"os"
 	"syscall"
 )
 
@@ -47,3 +49,32 @@ func adjustUnlinkError(err error) error {
 	}
 	return err
 }
+
+// rename uses os.Rename as `windows.Rename` is internal in Go's source tree.
+func rename(old, new string) (err error) {
+	if err = os.Rename(old, new); err == nil {
+		return
+	}
+	err = errors.Unwrap(err) // unwrap the link error
+	if err == ERROR_ACCESS_DENIED {
+		var newIsDir bool
+		if stat, statErr := os.Stat(new); statErr == nil && stat.IsDir() {
+			newIsDir = true
+		}
+
+		var oldIsDir bool
+		if stat, statErr := os.Stat(old); statErr == nil && stat.IsDir() {
+			oldIsDir = true
+		}
+
+		if oldIsDir && newIsDir {
+			// Windows doesn't let you overwrite a directory
+			return syscall.EINVAL
+		} else if newIsDir {
+			err = syscall.EISDIR
+		} else { // use a mappable code
+			err = syscall.EPERM
+		}
+	}
+	return
+}
diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go
index 0edacb84..9c0eb6ba 100644
--- a/internal/syscallfs/syscallfs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -19,30 +19,30 @@ type FS interface {
 	// OpenFile is similar to os.OpenFile, except the path is relative to this
 	// file system.
 	OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error)
-	// ^^ TODO: Switch to syscall.Open, though this implies defining and
+	// ^^ TODO: Consider syscall.Open, though this implies defining and
 	// coercing flags and perms similar to what is done in os.OpenFile.
 
 	// Mkdir is similar to os.Mkdir, except the path is relative to this file
 	// system.
 	Mkdir(name string, perm fs.FileMode) error
-	// ^^ TODO: Switch to syscall.Mkdir, though this implies defining and
+	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
 	// coercing flags and perms similar to what is done in os.Mkdir.
 
-	// Utimes is similar to syscall.UtimesNano, except the path is relative to
-	// this file system.
+	// Rename is similar to syscall.Rename, 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
+	//   - syscall.EINVAL: `from` or `to` is invalid.
+	//   - syscall.ENOENT: `from` or `to` don't exist.
+	//   - syscall.ENOTDIR: `from` is a directory and `to` exists, but is a file.
+	//   - syscall.EISDIR: `from` is a file and `to` exists, but is a directory.
 	//
 	// # 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
+	//   -  Windows doesn't let you overwrite an existing directory.
+	Rename(from, to string) error
 
 	// Rmdir is similar to syscall.Rmdir, except the path is relative to this
 	// file system.
@@ -68,4 +68,20 @@ type FS interface {
 	//   - syscall.ENOENT: `path` doesn't exist.
 	//   - syscall.EISDIR: `path` exists, but is a directory.
 	Unlink(path string) 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
 }
diff --git a/site/content/specs.md b/site/content/specs.md
index 52b0fa46..040ac997 100644
--- a/site/content/specs.md
+++ b/site/content/specs.md
@@ -98,32 +98,32 @@ Note: C (via clang) supports the maximum WASI functions due to [wasi-libc][16].
 | fd_filestat_get         |   ✅    |             Zig |
 | fd_filestat_set_size    |   ❌    |                 |
 | fd_filestat_set_times   |   ❌    |                 |
-| fd_pread                |   ✅    |        Rust,Zig |
-| fd_prestat_get          |   ✅    |          TinyGo |
-| fd_prestat_dir_name     |   ✅    |          TinyGo |
+| fd_pread                |   ✅    |             Zig |
+| fd_prestat_get          |   ✅    | Rust,TinyGo,Zig |
+| fd_prestat_dir_name     |   ✅    | Rust,TinyGo,Zig |
 | fd_pwrite               |   ❌    |                 |
-| fd_read                 |   ✅    |          TinyGo |
+| fd_read                 |   ✅    | Rust,TinyGo,Zig |
 | fd_readdir              |   ✅    |        Rust,Zig |
 | fd_renumber             |   ❌    |                 |
 | fd_seek                 |   ✅    |          TinyGo |
 | fd_sync                 |   ❌    |                 |
 | fd_tell                 |   ❌    |                 |
-| fd_write                |   ✅    |                 |
-| path_create_directory   |   ❌    |                 |
-| path_filestat_get       |   ❌    |                 |
+| fd_write                |   ✅    | Rust,TinyGo,Zig |
+| path_create_directory   |   ✅    | Rust,TinyGo,Zig |
+| path_filestat_get       |   ✅    | Rust,TinyGo,Zig |
 | path_filestat_set_times |   ❌    |                 |
 | path_link               |   ❌    |                 |
-| path_open               |   ✅    |          TinyGo |
+| path_open               |   ✅    | Rust,TinyGo,Zig |
 | path_readlink           |   ❌    |                 |
-| path_remove_directory   |   ❌    |                 |
-| path_rename             |   ❌    |                 |
+| path_remove_directory   |   ✅    | Rust,TinyGo,Zig |
+| path_rename             |   ✅    | Rust,TinyGo,Zig |
 | path_symlink            |   ❌    |                 |
-| path_unlink_file        |   ❌    |                 |
+| path_unlink_file        |   ✅    | Rust,TinyGo,Zig |
 | poll_oneoff             |   ✅    | Rust,TinyGo,Zig |
 | proc_exit               |   ✅    |  AssemblyScript |
 | proc_raise              |   💀   |                 |
 | sched_yield             |   ❌    |                 |
-| random_get              |   ✅    |                 |
+| random_get              |   ✅    | Rust,TinyGo,Zig |
 | sock_accept             |   ❌    |                 |
 | sock_recv               |   ❌    |                 |
 | sock_send               |   ❌    |                 |


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 2def94f3..03c21667 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -2830,15 +2830,6 @@ func errNotDir() Errno {
 	return ErrnoNotdir
 }
 
-// Test_pathRename only tests it is stubbed for GrainLang per #271
-func Test_pathRename(t *testing.T) {
-	log := requireErrnoNosys(t, PathRenameName, 0, 0, 0, 0, 0, 0)
-	require.Equal(t, `
---> wasi_snapshot_preview1.path_rename(fd=0,old_path=,new_fd=0,new_path=)
-<-- errno=ENOSYS
-`, log)
-}
-
 // Test_pathSymlink only tests it is stubbed for GrainLang per #271
 func Test_pathSymlink(t *testing.T) {
 	log := requireErrnoNosys(t, PathSymlinkName, 0, 0, 0, 0, 0)
@@ -2848,6 +2839,220 @@ func Test_pathSymlink(t *testing.T) {
 `, log)
 }
 
+func Test_pathRename(t *testing.T) {
+	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
+	fs, err := syscallfs.NewDirFS(tmpDir)
+	require.NoError(t, err)
+
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
+	defer r.Close(testCtx)
+
+	// set up the initial memory to include the old path name starting at an offset.
+	oldDirFD := sys.FdRoot
+	oldPathName := "wazero"
+	realOldPath := path.Join(tmpDir, oldPathName)
+	oldPath := uint32(0)
+	oldPathLen := len(oldPathName)
+	ok := mod.Memory().Write(oldPath, []byte(oldPathName))
+	require.True(t, ok)
+
+	// create the file
+	err = os.WriteFile(realOldPath, []byte{}, 0o600)
+	require.NoError(t, err)
+
+	newDirFD := sys.FdRoot
+	newPathName := "wahzero"
+	realNewPath := path.Join(tmpDir, newPathName)
+	newPath := uint32(16)
+	newPathLen := len(newPathName)
+	ok = mod.Memory().Write(newPath, []byte(newPathName))
+	require.True(t, ok)
+
+	requireErrno(t, ErrnoSuccess, mod, PathRenameName,
+		uint64(oldDirFD), uint64(oldPath), uint64(oldPathLen),
+		uint64(newDirFD), uint64(newPath), uint64(newPathLen))
+	require.Equal(t, `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=wazero,new_fd=3,new_path=wahzero)
+<== errno=ESUCCESS
+`, "\n"+log.String())
+
+	// ensure the file was renamed
+	_, err = os.Stat(realOldPath)
+	require.Error(t, err)
+	_, err = os.Stat(realNewPath)
+	require.NoError(t, err)
+}
+
+func Test_pathRename_Errors(t *testing.T) {
+	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
+	fs, err := syscallfs.NewDirFS(tmpDir)
+	require.NoError(t, err)
+
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs))
+	defer r.Close(testCtx)
+
+	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)
+
+	tests := []struct {
+		name, oldPathName, newPathName string
+		oldFd, oldPath, oldPathLen     uint32
+		newFd, newPath, newPathLen     uint32
+		expectedErrno                  Errno
+		expectedLog                    string
+	}{
+		{
+			name:          "invalid old fd",
+			oldFd:         42, // arbitrary invalid fd
+			newFd:         sys.FdRoot,
+			expectedErrno: ErrnoBadf,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=42,old_path=,new_fd=3,new_path=)
+<== errno=EBADF
+`,
+		},
+		{
+			name:          "invalid new fd",
+			oldFd:         sys.FdRoot,
+			newFd:         42, // arbitrary invalid fd
+			expectedErrno: ErrnoBadf,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=,new_fd=42,new_path=)
+<== errno=EBADF
+`,
+		},
+		{
+			name:          "out-of-memory reading old path",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPath:       mod.Memory().Size(),
+			oldPathLen:    1,
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=OOM(65536,1),new_fd=3,new_path=)
+<== errno=EFAULT
+`,
+		},
+		{
+			name:          "out-of-memory reading new path",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPath:       0,
+			oldPathName:   "a",
+			oldPathLen:    1,
+			newPath:       mod.Memory().Size(),
+			newPathLen:    1,
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=a,new_fd=3,new_path=OOM(65536,1))
+<== errno=EFAULT
+`,
+		},
+		{
+			name:          "old path invalid",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPathName:   "../foo",
+			oldPathLen:    6,
+			expectedErrno: ErrnoInval,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../foo,new_fd=3,new_path=)
+<== errno=EINVAL
+`,
+		},
+		{
+			name:          "new path invalid",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPathName:   file,
+			oldPathLen:    uint32(len(file)),
+			newPathName:   "../foo",
+			newPathLen:    6,
+			expectedErrno: ErrnoInval,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../f,new_fd=3,new_path=../foo)
+<== errno=EINVAL
+`,
+		},
+		{
+			name:          "out-of-memory reading old pathLen",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPath:       0,
+			oldPathLen:    mod.Memory().Size() + 1, // path is in the valid memory range, but pathLen is OOM for path
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=OOM(0,65537),new_fd=3,new_path=)
+<== errno=EFAULT
+`,
+		},
+		{
+			name:          "out-of-memory reading new pathLen",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPathName:   file,
+			oldPathLen:    uint32(len(file)),
+			newPath:       0,
+			newPathLen:    mod.Memory().Size() + 1, // path is in the valid memory range, but pathLen is OOM for path
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=file,new_fd=3,new_path=OOM(0,65537))
+<== errno=EFAULT
+`,
+		},
+		{
+			name:          "no such file exists",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPathName:   file,
+			oldPathLen:    uint32(len(file)) - 1,
+			newPath:       16,
+			newPathName:   file,
+			newPathLen:    uint32(len(file)),
+			expectedErrno: ErrnoNoent,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=fil,new_fd=3,new_path=file)
+<== errno=ENOENT
+`,
+		},
+		{
+			name:          "dir not file",
+			oldFd:         sys.FdRoot,
+			newFd:         sys.FdRoot,
+			oldPathName:   file,
+			oldPathLen:    uint32(len(file)),
+			newPath:       16,
+			newPathName:   dir,
+			newPathLen:    uint32(len(dir)),
+			expectedErrno: ErrnoIsdir,
+			expectedLog: `
+==> wasi_snapshot_preview1.path_rename(fd=3,old_path=file,new_fd=3,new_path=dir)
+<== errno=EISDIR
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			defer log.Reset()
+
+			mod.Memory().Write(tc.oldPath, []byte(tc.oldPathName))
+			mod.Memory().Write(tc.newPath, []byte(tc.newPathName))
+
+			requireErrno(t, tc.expectedErrno, mod, PathRenameName,
+				uint64(tc.oldFd), uint64(tc.oldPath), uint64(tc.oldPathLen),
+				uint64(tc.newFd), uint64(tc.newPath), uint64(tc.newPathLen))
+			require.Equal(t, tc.expectedLog, "\n"+log.String())
+		})
+	}
+}
+
 func Test_pathUnlinkFile(t *testing.T) {
 	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
 	fs, err := syscallfs.NewDirFS(tmpDir)
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index 3b7674fa..6bf6fa56 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -13,6 +13,7 @@ import (
 func Main() {
 	// Create a test directory
 	dir := path.Join(os.TempDir(), "dir")
+	dir1 := path.Join(os.TempDir(), "dir1")
 	err := os.Mkdir(dir, 0o700)
 	if err != nil {
 		log.Panicln(err)
@@ -22,6 +23,7 @@ func Main() {
 
 	// Create a test file in that directory
 	file := path.Join(dir, "file")
+	file1 := path.Join(os.TempDir(), "file1")
 	err = os.WriteFile(file, []byte{}, 0o600)
 	if err != nil {
 		log.Panicln(err)
@@ -65,24 +67,41 @@ func Main() {
 		fmt.Println("times:", atimeSec, atimeNsec, mtimeSec, mtimeNsec)
 	}
 
+	// Test renaming a file, noting we can't verify error numbers as they
+	// vary per operating system.
+	if err = syscall.Rename(file, dir); err == nil {
+		log.Panicln("expected error")
+	}
+	if err = syscall.Rename(file, file1); err != nil {
+		log.Panicln("unexpected error", err)
+	}
+
+	// Test renaming a directory
+	if err = syscall.Rename(dir, file1); err == nil {
+		log.Panicln("expected error")
+	}
+	if err = syscall.Rename(dir, dir1); err != nil {
+		log.Panicln("unexpected error", err)
+	}
+
 	// Test unlinking a file
-	if err = syscall.Rmdir(file); err != syscall.ENOTDIR {
+	if err = syscall.Rmdir(file1); err != syscall.ENOTDIR {
 		log.Panicln("unexpected error", err)
 	}
-	if err = syscall.Unlink(file); err != nil {
+	if err = syscall.Unlink(file1); err != nil {
 		log.Panicln("unexpected error", err)
 	}
 
 	// Test removing an empty directory
-	if err = syscall.Unlink(dir); err != syscall.EISDIR {
+	if err = syscall.Unlink(dir1); err != syscall.EISDIR {
 		log.Panicln("unexpected error", err)
 	}
-	if err = syscall.Rmdir(dir); err != nil {
+	if err = syscall.Rmdir(dir1); err != nil {
 		log.Panicln("unexpected error", err)
 	}
 
 	// shouldn't fail
-	if err = os.RemoveAll(dir); err != nil {
+	if err = os.RemoveAll(dir1); err != nil {
 		log.Panicln(err)
 		return
 	}
diff --git a/internal/syscallfs/dirfs_test.go b/internal/syscallfs/dirfs_test.go
index 8082df83..8b782155 100644
--- a/internal/syscallfs/dirfs_test.go
+++ b/internal/syscallfs/dirfs_test.go
@@ -43,6 +43,205 @@ func TestDirFS_MkDir(t *testing.T) {
 	})
 }
 
+func TestDirFS_Rename(t *testing.T) {
+	t.Run("from doesn't exist", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		file1 := "file1"
+		file1Path := path.Join(tmpDir, file1)
+		err := os.WriteFile(file1Path, []byte{1}, 0o600)
+		require.NoError(t, err)
+
+		err = testFS.Rename("file2", file1)
+		require.Equal(t, syscall.ENOENT, err)
+	})
+	t.Run("file to non-exist", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		file1 := "file1"
+		file1Path := path.Join(tmpDir, file1)
+		file1Contents := []byte{1}
+		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		require.NoError(t, err)
+
+		file2 := "file2"
+		file2Path := path.Join(tmpDir, file2)
+		err = testFS.Rename(file1, file2)
+		require.NoError(t, err)
+
+		// Show the prior path no longer exists
+		_, err = os.Stat(file1Path)
+		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+
+		s, err := os.Stat(file2Path)
+		require.NoError(t, err)
+		require.False(t, s.IsDir())
+	})
+	t.Run("dir to non-exist", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		dir1 := "dir1"
+		dir1Path := path.Join(tmpDir, dir1)
+		require.NoError(t, os.Mkdir(dir1Path, 0o700))
+
+		dir2 := "dir2"
+		dir2Path := path.Join(tmpDir, dir2)
+		err := testFS.Rename(dir1, dir2)
+		require.NoError(t, err)
+
+		// Show the prior path no longer exists
+		_, err = os.Stat(dir1Path)
+		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+
+		s, err := os.Stat(dir2Path)
+		require.NoError(t, err)
+		require.True(t, s.IsDir())
+	})
+	t.Run("dir to file", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		dir1 := "dir1"
+		dir1Path := path.Join(tmpDir, dir1)
+		require.NoError(t, os.Mkdir(dir1Path, 0o700))
+
+		dir2 := "dir2"
+		dir2Path := path.Join(tmpDir, dir2)
+
+		// write a file to that path
+		err := os.WriteFile(dir2Path, []byte{2}, 0o600)
+		require.NoError(t, err)
+
+		err = testFS.Rename(dir1, dir2)
+		if runtime.GOOS == "windows" {
+			require.NoError(t, err)
+
+			// Show the directory moved
+			s, err := os.Stat(dir2Path)
+			require.NoError(t, err)
+			require.True(t, s.IsDir())
+		} else {
+			require.Equal(t, syscall.ENOTDIR, err)
+		}
+	})
+	t.Run("file to dir", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		file1 := "file1"
+		file1Path := path.Join(tmpDir, file1)
+		file1Contents := []byte{1}
+		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		require.NoError(t, err)
+
+		dir1 := "dir1"
+		dir1Path := path.Join(tmpDir, dir1)
+		require.NoError(t, os.Mkdir(dir1Path, 0o700))
+
+		err = testFS.Rename(file1, dir1)
+		require.Equal(t, syscall.EISDIR, err)
+	})
+	t.Run("dir to dir", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		dir1 := "dir1"
+		dir1Path := path.Join(tmpDir, dir1)
+		require.NoError(t, os.Mkdir(dir1Path, 0o700))
+
+		// add a file to that directory
+		file1 := "file1"
+		file1Path := path.Join(dir1Path, file1)
+		file1Contents := []byte{1}
+		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		require.NoError(t, err)
+
+		dir2 := "dir2"
+		dir2Path := path.Join(tmpDir, dir2)
+		require.NoError(t, os.Mkdir(dir2Path, 0o700))
+
+		err = testFS.Rename(dir1, dir2)
+		if runtime.GOOS == "windows" {
+			// Windows doesn't let you overwrite an existing directory.
+			require.Equal(t, syscall.EINVAL, err)
+			return
+		}
+		require.NoError(t, err)
+
+		// Show the prior path no longer exists
+		_, err = os.Stat(dir1Path)
+		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+
+		// Show the file inside that directory moved
+		s, err := os.Stat(path.Join(dir2Path, file1))
+		require.NoError(t, err)
+		require.False(t, s.IsDir())
+	})
+	t.Run("file to file", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		file1 := "file1"
+		file1Path := path.Join(tmpDir, file1)
+		file1Contents := []byte{1}
+		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		require.NoError(t, err)
+
+		file2 := "file2"
+		file2Path := path.Join(tmpDir, file2)
+		file2Contents := []byte{2}
+		err = os.WriteFile(file2Path, file2Contents, 0o600)
+		require.NoError(t, err)
+
+		err = testFS.Rename(file1, file2)
+		require.NoError(t, err)
+
+		// Show the prior path no longer exists
+		_, err = os.Stat(file1Path)
+		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+
+		// Show the file1 overwrote file2
+		b, err := os.ReadFile(file2Path)
+		require.NoError(t, err)
+		require.Equal(t, file1Contents, b)
+	})
+	t.Run("dir to itself", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		dir1 := "dir1"
+		dir1Path := path.Join(tmpDir, dir1)
+		require.NoError(t, os.Mkdir(dir1Path, 0o700))
+
+		err := testFS.Rename(dir1, dir1)
+		require.NoError(t, err)
+
+		s, err := os.Stat(dir1Path)
+		require.NoError(t, err)
+		require.True(t, s.IsDir())
+	})
+	t.Run("file to itself", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		testFS := dirFS(tmpDir)
+
+		file1 := "file1"
+		file1Path := path.Join(tmpDir, file1)
+		file1Contents := []byte{1}
+		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		require.NoError(t, err)
+
+		err = testFS.Rename(file1, file1)
+		require.NoError(t, err)
+
+		b, err := os.ReadFile(file1Path)
+		require.NoError(t, err)
+		require.Equal(t, file1Contents, b)
+	})
+}
+
 func TestDirFS_Rmdir(t *testing.T) {
 	dir := t.TempDir()