Repository: tetratelabs/wazero
Pull request #1168 (2023-02-26 01:05:25)

implements lstat and fixes inode stat on windows go 1.20
This implements platform.Lstat and uses it in GOOS=js. Notably, directory listings need to run lstat on their entries to get the correct inodes back. In GOOS=js, directories are a fan-out of names, then lstat.

This also fixes stat for inodes on directories. We were missing a test so we didn't know it was broken on windows. The approach used now is reliable on go 1.20, and we should suggest anyone using windows to compile with go 1.20.

codefromthecrypt: zig windows test is taking forever now. will turn on host logging to see what's going on.
codefromthecrypt: looks like windows is dying after this. I wonder if we can make sure that zig exits faster vs hanging? wdyt @mathetake?

```
961/2123 test.Dir.readLink... exit status 0xc000013a
[969](https://github.com/tetratelabs/wazero/actions/runs/4277347483/jobs/7446094975#step:5:970)
Error: The operation was canceled.
```
codefromthecrypt: added `timeout-minutes: 10` on #1169 which hangs the same way 
codefromthecrypt: ok this is now rebased on the same zig version that passes on main
codefromthecrypt: hostlogging isn't really viable in zig since the tests can't be skipped at runtime. the compiled stdlib test wasm isn't tainted per PR, which means it runs all of them. the logs here are 2.3GB for example!
codefromthecrypt: ok something is looping in `test.Dir.readLink` as it is the last test to execute and only at line 1904 in 2.3GB worth of logs.

```bash
$ cat -n  ~/Downloads/logs |grep ' test.'
--snip--
  1904	2023-02-27T01:23:17.1788043Z 961/2123 test.Dir.readLink... ==> wasi_snapshot_preview1.path_create_directory(fd=3,path=zig-cache)
```
codefromthecrypt: yep something is calling path_unlink_file and path_open in a loop

```
2023-02-27T01:30:09.8011004Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:30:09.8011081Z <== errno=EISDIR
2023-02-27T01:30:09.8012390Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=,path=symlink2,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:30:09.8012495Z <== (opened_fd=,errno=ENOTDIR)
2023-02-27T01:30:09.8012658Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:30:09.8012790Z <== errno=EISDIR
2023-02-27T01:30:09.8014091Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=,path=symlink2,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:30:09.8014195Z <== (opened_fd=,errno=ENOTDIR)
2023-02-27T01:30:09.8014356Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:30:09.8014436Z <== errno=EISDIR
2023-02-27T01:30:09.8015788Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=,path=symlink2,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
```
codefromthecrypt: here's darwin

```
2023-02-27T01:21:32.4733190Z 961/2123 test.Dir.readLink... ==> wasi_snapshot_preview1.path_create_directory(fd=3,path=zig-cache)
2023-02-27T01:21:32.4733490Z <== errno=EEXIST
2023-02-27T01:21:32.4735980Z ==> wasi_snapshot_preview1.path_open(fd=3,dirflags=SYMLINK_FOLLOW,path=zig-cache,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:21:32.4737210Z <== (opened_fd=7,errno=ESUCCESS)
2023-02-27T01:21:32.4737540Z ==> wasi_snapshot_preview1.path_create_directory(fd=7,path=tmp)
2023-02-27T01:21:32.4738070Z <== errno=EEXIST
2023-02-27T01:21:32.4739590Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=SYMLINK_FOLLOW,path=tmp,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:21:32.4740800Z <== (opened_fd=8,errno=ESUCCESS)
2023-02-27T01:21:32.4741120Z ==> wasi_snapshot_preview1.path_create_directory(fd=8,path=Q3BJHZH9O5v4xyYg)
2023-02-27T01:21:32.4741400Z <== errno=ESUCCESS
2023-02-27T01:21:32.4742830Z ==> wasi_snapshot_preview1.path_open(fd=8,dirflags=SYMLINK_FOLLOW,path=Q3BJHZH9O5v4xyYg,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:21:32.4744470Z <== (opened_fd=9,errno=ESUCCESS)
2023-02-27T01:21:32.4744720Z ==> wasi_snapshot_preview1.fd_close(fd=7)
2023-02-27T01:21:32.4744940Z <== errno=ESUCCESS
2023-02-27T01:21:32.4745760Z ==> wasi_snapshot_preview1.path_open(fd=9,dirflags=,path=file.txt,oflags=CREAT|TRUNC,fs_rights_base=FD_DATASYNC|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES,fs_rights_inheriting=,fdflags=)
2023-02-27T01:21:32.4746320Z <== (opened_fd=7,errno=ESUCCESS)
2023-02-27T01:21:32.4746590Z ==> wasi_snapshot_preview1.fd_write(fd=7,iovs=1047832,iovs_len=1)
2023-02-27T01:21:32.4746870Z <== (nwritten=8,errno=ESUCCESS)
2023-02-27T01:21:32.4747170Z ==> wasi_snapshot_preview1.fd_close(fd=7)
2023-02-27T01:21:32.5142700Z <== errno=ESUCCESS
2023-02-27T01:21:32.5143850Z ==> wasi_snapshot_preview1.path_create_directory(fd=9,path=subdir)
2023-02-27T01:21:32.5145490Z <== errno=ESUCCESS
2023-02-27T01:21:32.5145860Z ==> wasi_snapshot_preview1.path_symlink(old_path=file.txt,fd=9,new_path=symlink1)
2023-02-27T01:21:32.5146140Z <== errno=ESUCCESS
2023-02-27T01:21:32.5146450Z ==> wasi_snapshot_preview1.path_readlink(fd=9,path=symlink1,buf=1043864,buf_len=4096)
2023-02-27T01:21:32.5146740Z <== (bufused=8,errno=ESUCCESS)
2023-02-27T01:21:32.5147050Z ==> wasi_snapshot_preview1.path_symlink(old_path=subdir,fd=9,new_path=symlink2)
2023-02-27T01:21:32.5147700Z <== errno=ESUCCESS
2023-02-27T01:21:32.5148560Z ==> wasi_snapshot_preview1.path_readlink(fd=9,path=symlink2,buf=1043864,buf_len=4096)
2023-02-27T01:21:32.5148910Z <== (bufused=6,errno=ESUCCESS)
2023-02-27T01:21:32.5149380Z ==> wasi_snapshot_preview1.fd_close(fd=9)
2023-02-27T01:21:32.5150050Z <== errno=ESUCCESS
2023-02-27T01:21:32.5150640Z ==> wasi_snapshot_preview1.path_unlink_file(fd=8,path=Q3BJHZH9O5v4xyYg)
2023-02-27T01:21:32.5150940Z <== errno=EISDIR
2023-02-27T01:21:32.5152840Z ==> wasi_snapshot_preview1.path_open(fd=8,dirflags=,path=Q3BJHZH9O5v4xyYg,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:21:32.5154060Z <== (opened_fd=7,errno=ESUCCESS)
2023-02-27T01:21:32.5154400Z ==> wasi_snapshot_preview1.fd_readdir(fd=7,buf=1023292,buf_len=1024,cookie=0)
2023-02-27T01:21:32.5154670Z <== (bufused=177,errno=ESUCCESS)
2023-02-27T01:21:32.5154950Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=file.txt)
2023-02-27T01:21:32.5155210Z <== errno=ESUCCESS
2023-02-27T01:21:32.5155480Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink1)
2023-02-27T01:21:32.5155730Z <== errno=ESUCCESS
2023-02-27T01:21:32.5157130Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=,path=subdir,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|FD_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:21:32.5158710Z <== (opened_fd=9,errno=ESUCCESS)
2023-02-27T01:21:32.5159010Z ==> wasi_snapshot_preview1.fd_readdir(fd=9,buf=1024356,buf_len=1024,cookie=0)
2023-02-27T01:21:32.5159290Z <== (bufused=51,errno=ESUCCESS)
2023-02-27T01:21:32.5159860Z ==> wasi_snapshot_preview1.fd_readdir(fd=9,buf=1024356,buf_len=1024,cookie=2)
2023-02-27T01:21:32.5160180Z <== (bufused=0,errno=ESUCCESS)
2023-02-27T01:21:32.5160430Z ==> wasi_snapshot_preview1.fd_close(fd=9)
2023-02-27T01:21:32.5160650Z <== errno=ESUCCESS
2023-02-27T01:21:32.5160930Z ==> wasi_snapshot_preview1.path_remove_directory(fd=7,path=subdir)
2023-02-27T01:21:32.5161190Z <== errno=ESUCCESS
2023-02-27T01:21:32.5161460Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:21:32.5161700Z <== errno=ESUCCESS
2023-02-27T01:21:32.5161970Z ==> wasi_snapshot_preview1.fd_readdir(fd=7,buf=1023292,buf_len=1024,cookie=6)
2023-02-27T01:21:32.5162240Z <== (bufused=0,errno=ESUCCESS)
2023-02-27T01:21:32.5162480Z ==> wasi_snapshot_preview1.fd_close(fd=7)
2023-02-27T01:21:32.5162700Z <== errno=ESUCCESS
2023-02-27T01:21:32.5162990Z ==> wasi_snapshot_preview1.path_remove_directory(fd=8,path=Q3BJHZH9O5v4xyYg)
2023-02-27T01:21:32.5163260Z <== errno=ESUCCESS
2023-02-27T01:21:32.5163750Z ==> wasi_snapshot_preview1.fd_close(fd=8)
2023-02-27T01:21:32.5164290Z <== errno=ESUCCESS
2023-02-27T01:21:32.5164690Z OK
```
codefromthecrypt: @mathetake so this seems the root cause is our implementation is struggling to delete a symlink on windows. I have to run for a couple hours in case you have time to try this in this PR or a separate one.


here's the start of the loop on windows. I think possibly the EISDIR is misleading (as it is a link) or something else amuck when trying to path_unlink_file symlink2
```
2023-02-27T01:23:17.1819639Z ==> wasi_snapshot_preview1.path_remove_directory(fd=7,path=subdir)
2023-02-27T01:23:17.1819924Z <== errno=ESUCCESS
2023-02-27T01:23:17.1820219Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink1)
2023-02-27T01:23:17.1820490Z <== errno=ESUCCESS
2023-02-27T01:23:17.1820766Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:23:17.1821039Z <== errno=EISDIR
2023-02-27T01:23:17.1822548Z ==> wasi_snapshot_preview1.path_open(fd=7,dirflags=,path=symlink2,oflags=DIRECTORY,fs_rights_base=FDSTAT_SET_FLAGS|PATH_CREATE_DIRECTORY|PATH_CREATE_F
ILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTAT_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_
FILESTAT_GET|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE,fs_rights_inheriting=FD_DATASYNC|FD_READ|FD_SEEK|FDSTAT_SET_FLAGS|FD_SYNC|FD_TELL|FD_WRITE|F
D_ADVISE|FD_ALLOCATE|PATH_CREATE_DIRECTORY|PATH_CREATE_FILE|PATH_LINK_SOURCE|PATH_LINK_TARGET|PATH_OPEN|FD_READDIR|PATH_READLINK|PATH_RENAME_SOURCE|PATH_RENAME_TARGET|PATH_FILESTA
T_GET|PATH_FILESTAT_SET_SIZE|PATH_FILESTAT_SET_TIMES|FD_FILESTAT_GET|FD_FILESTAT_SET_SIZE|FD_FILESTAT_SET_TIMES|PATH_SYMLINK|PATH_REMOVE_DIRECTORY|PATH_UNLINK_FILE|POLL_FD_READWRITE,fdflags=)
2023-02-27T01:23:17.1823967Z <== (opened_fd=,errno=ENOTDIR)
2023-02-27T01:23:17.1824286Z ==> wasi_snapshot_preview1.path_unlink_file(fd=7,path=symlink2)
2023-02-27T01:23:17.1824562Z <== errno=EISDIR
2023-02-27T01:23:17.1826027Z ==> wasi_snapshot_p
```
mathetake: https://github.com/tetratelabs/wazero/pull/1172 here's a repro 

Patch:
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index a1c7d1e3..4d4babce 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -143,11 +143,22 @@ func (jsfsLstat) invoke(ctx context.Context, mod api.Module, args ...interface{}
 	path := args[0].(string)
 	callback := args[1].(funcWrapper)
 
-	lstat, err := syscallStat(mod, path) // TODO switch to lstat syscall
+	lstat, err := syscallLstat(mod, path)
 
 	return callback.invoke(ctx, mod, goos.RefJsfs, err, lstat) // note: error first
 }
 
+// syscallLstat is like syscall.Lstat
+func syscallLstat(mod api.Module, path string) (*jsSt, error) {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+
+	var stat platform.Stat_t
+	if err := fsc.RootFS().Lstat(path, &stat); err != nil {
+		return nil, err
+	}
+	return newJsSt(&stat), nil
+}
+
 // jsfsFstat implements jsFn for syscall.Open
 //
 //	stat, err := fsCall("fstat", fd); err == nil && stat.Call("isDirectory").Bool()
@@ -211,6 +222,8 @@ func newJsSt(stat *platform.Stat_t) *jsSt {
 func getJsMode(mode fs.FileMode) (jsMode uint32) {
 	jsMode = uint32(mode & fs.ModePerm)
 	switch mode & fs.ModeType {
+	case 0:
+		jsMode |= S_IFREG
 	case fs.ModeDir:
 		jsMode |= S_IFDIR
 	case fs.ModeSymlink:
@@ -227,9 +240,6 @@ func getJsMode(mode fs.FileMode) (jsMode uint32) {
 		// unmapped to js
 	}
 
-	if mode&fs.ModeType == 0 {
-		jsMode |= S_IFREG
-	}
 	if mode&fs.ModeSetgid != 0 {
 		jsMode |= S_ISGID
 	}
@@ -687,8 +697,8 @@ func (jsfsSymlink) invoke(ctx context.Context, mod api.Module, args ...interface
 	link := args[1].(string)
 	callback := args[2].(funcWrapper)
 
-	_, _ = path, link // TODO
-	var err error = syscall.ENOSYS
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	err := fsc.RootFS().Symlink(path, link)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
diff --git a/internal/platform/open_file_windows.go b/internal/platform/open_file_windows.go
index c344806d..67c08d51 100644
--- a/internal/platform/open_file_windows.go
+++ b/internal/platform/open_file_windows.go
@@ -46,6 +46,8 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
 	}
 
 	switch err {
+	// To match expectations of WASI, e.g. TinyGo TestStatBadDir, return
+	// ENOENT, not ENOTDIR.
 	case syscall.ENOTDIR:
 		err = syscall.ENOENT
 	case syscall.ENOENT:
diff --git a/internal/platform/stat.go b/internal/platform/stat.go
index 9f4d79c5..e04be140 100644
--- a/internal/platform/stat.go
+++ b/internal/platform/stat.go
@@ -20,7 +20,7 @@ type Stat_t struct {
 	Ino uint64
 
 	// Mode is the same as Mode on fs.FileInfo containing bits to identify the
-	// type of the file and its permissions (fs.ModePerm).
+	// type of the file (fs.ModeType) and its permissions (fs.ModePerm).
 	Mode fs.FileMode
 
 	/// Nlink is the number of hard links to the file.
@@ -42,47 +42,53 @@ type Stat_t struct {
 	Ctim int64
 }
 
+// Lstat is like syscall.Lstat. This returns syscall.ENOENT if the path doesn't
+// exist.
+//
+// # Notes
+//
+// The primary difference between this and Stat is, when the path is a
+// symbolic link, the stat is about the link, not its target, such as directory
+// listings.
+func Lstat(path string, st *Stat_t) error {
+	err := lstat(path, st) // extracted to override more expensively in windows
+	return UnwrapOSError(err)
+}
+
 // Stat is like syscall.Stat. This returns syscall.ENOENT if the path doesn't
 // exist.
 func Stat(path string, st *Stat_t) error {
-	return stat(path, st) // extracted to override more expensively in windows
+	err := stat(path, st) // extracted to override more expensively in windows
+	return UnwrapOSError(err)
 }
 
 // StatFile is like syscall.Fstat, but for fs.File instead of a file
 // descriptor. This returns syscall.EBADF if the file or directory was closed.
 // Note: windows allows you to stat a closed directory.
 func StatFile(f fs.File, st *Stat_t) (err error) {
-	t, err := f.Stat()
-	if err = UnwrapOSError(err); err != nil {
-		if err == syscall.EIO { // linux/darwin returns this on a closed file.
-			err = syscall.EBADF // windows returns this, which is better.
-		}
-		return
+	err = statFile(f, st)
+	if err = UnwrapOSError(err); err == syscall.EIO {
+		err = syscall.EBADF
 	}
-	return fillStatFile(st, f, t)
+	return
 }
 
-// fdFile is implemented by os.File in file_unix.go and file_windows.go
-// Note: we use this until we finalize our own FD-scoped file.
-type fdFile interface{ Fd() (fd uintptr) }
-
-func fillStatFile(stat *Stat_t, f fs.File, t fs.FileInfo) (err error) {
-	if of, ok := f.(fdFile); !ok { // possibly fake filesystem
-		fillStatFromFileInfo(stat, t)
-	} else {
-		err = fillStatFromOpenFile(stat, of.Fd(), t)
+func defaultStatFile(f fs.File, st *Stat_t) (err error) {
+	var t fs.FileInfo
+	if t, err = f.Stat(); err == nil {
+		fillStatFromFileInfo(st, t)
 	}
 	return
 }
 
-func fillStatFromFileInfo(stat *Stat_t, t fs.FileInfo) {
-	stat.Ino = 0
-	stat.Dev = 0
-	stat.Mode = t.Mode()
-	stat.Nlink = 1
-	stat.Size = t.Size()
+func fillStatFromDefaultFileInfo(st *Stat_t, t fs.FileInfo) {
+	st.Ino = 0
+	st.Dev = 0
+	st.Mode = t.Mode()
+	st.Nlink = 1
+	st.Size = t.Size()
 	mtim := t.ModTime().UnixNano() // Set all times to the mod time
-	stat.Atim = mtim
-	stat.Mtim = mtim
-	stat.Ctim = mtim
+	st.Atim = mtim
+	st.Mtim = mtim
+	st.Ctim = mtim
 }
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
index f4920f7c..cfc2d57c 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/platform/stat_bsd.go
@@ -3,34 +3,45 @@
 package platform
 
 import (
+	"io/fs"
 	"os"
 	"syscall"
 )
 
-func stat(path string, st *Stat_t) (err error) {
-	t, err := os.Stat(path)
-	if err = UnwrapOSError(err); err == nil {
-		fillStatFromSys(st, t)
+func lstat(path string, st *Stat_t) (err error) {
+	var t fs.FileInfo
+	if t, err = os.Lstat(path); err == nil {
+		fillStatFromFileInfo(st, t)
 	}
 	return
 }
 
-func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
-	fillStatFromSys(stat, t)
+func stat(path string, st *Stat_t) (err error) {
+	var t fs.FileInfo
+	if t, err = os.Stat(path); err == nil {
+		fillStatFromFileInfo(st, t)
+	}
 	return
 }
 
-func fillStatFromSys(stat *Stat_t, t os.FileInfo) {
-	d := t.Sys().(*syscall.Stat_t)
-	stat.Ino = d.Ino
-	stat.Dev = uint64(d.Dev)
-	stat.Mode = t.Mode()
-	stat.Nlink = uint64(d.Nlink)
-	stat.Size = d.Size
-	atime := d.Atimespec
-	stat.Atim = atime.Sec*1e9 + atime.Nsec
-	mtime := d.Mtimespec
-	stat.Mtim = mtime.Sec*1e9 + mtime.Nsec
-	ctime := d.Ctimespec
-	stat.Ctim = ctime.Sec*1e9 + ctime.Nsec
+func statFile(f fs.File, st *Stat_t) error {
+	return defaultStatFile(f, st)
+}
+
+func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
+	if d, ok := t.Sys().(*syscall.Stat_t); ok {
+		st.Ino = d.Ino
+		st.Dev = uint64(d.Dev)
+		st.Mode = t.Mode()
+		st.Nlink = uint64(d.Nlink)
+		st.Size = d.Size
+		atime := d.Atimespec
+		st.Atim = atime.Sec*1e9 + atime.Nsec
+		mtime := d.Mtimespec
+		st.Mtim = mtime.Sec*1e9 + mtime.Nsec
+		ctime := d.Ctimespec
+		st.Ctim = ctime.Sec*1e9 + ctime.Nsec
+	} else {
+		fillStatFromDefaultFileInfo(st, t)
+	}
 }
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
index 1af8d457..d975db9c 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/platform/stat_linux.go
@@ -6,34 +6,45 @@
 package platform
 
 import (
+	"io/fs"
 	"os"
 	"syscall"
 )
 
-func stat(path string, st *Stat_t) (err error) {
-	t, err := os.Stat(path)
-	if err = UnwrapOSError(err); err == nil {
-		fillStatFromSys(st, t)
+func lstat(path string, st *Stat_t) (err error) {
+	var t fs.FileInfo
+	if t, err = os.Lstat(path); err == nil {
+		fillStatFromFileInfo(st, t)
 	}
 	return
 }
 
-func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
-	fillStatFromSys(stat, t)
+func stat(path string, st *Stat_t) (err error) {
+	var t fs.FileInfo
+	if t, err = os.Stat(path); err == nil {
+		fillStatFromFileInfo(st, t)
+	}
 	return
 }
 
-func fillStatFromSys(stat *Stat_t, t os.FileInfo) {
-	d := t.Sys().(*syscall.Stat_t)
-	stat.Ino = uint64(d.Ino)
-	stat.Dev = uint64(d.Dev)
-	stat.Mode = t.Mode()
-	stat.Nlink = uint64(d.Nlink)
-	stat.Size = d.Size
-	atime := d.Atim
-	stat.Atim = atime.Sec*1e9 + atime.Nsec
-	mtime := d.Mtim
-	stat.Mtim = mtime.Sec*1e9 + mtime.Nsec
-	ctime := d.Ctim
-	stat.Ctim = ctime.Sec*1e9 + ctime.Nsec
+func statFile(f fs.File, st *Stat_t) error {
+	return defaultStatFile(f, st)
+}
+
+func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
+	if d, ok := t.Sys().(*syscall.Stat_t); ok {
+		st.Ino = uint64(d.Ino)
+		st.Dev = uint64(d.Dev)
+		st.Mode = t.Mode()
+		st.Nlink = uint64(d.Nlink)
+		st.Size = d.Size
+		atime := d.Atim
+		st.Atim = atime.Sec*1e9 + atime.Nsec
+		mtime := d.Mtim
+		st.Mtim = mtime.Sec*1e9 + mtime.Nsec
+		ctime := d.Ctim
+		st.Ctim = ctime.Sec*1e9 + ctime.Nsec
+	} else {
+		fillStatFromDefaultFileInfo(st, t)
+	}
 }
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
index 61cc3109..980779ae 100644
--- a/internal/platform/stat_unsupported.go
+++ b/internal/platform/stat_unsupported.go
@@ -2,7 +2,18 @@
 
 package platform
 
-import "os"
+import (
+	"io/fs"
+	"os"
+)
+
+func lstat(path string, st *Stat_t) (err error) {
+	t, err := os.Lstat(path)
+	if err = UnwrapOSError(err); err == nil {
+		fillStatFromFileInfo(st, t)
+	}
+	return
+}
 
 func stat(path string, st *Stat_t) (err error) {
 	t, err := os.Stat(path)
@@ -12,7 +23,15 @@ func stat(path string, st *Stat_t) (err error) {
 	return
 }
 
-func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
-	fillStatFromFileInfo(stat, t)
+func statFile(f fs.File, st *Stat_t) error {
+	return defaultStatFile(f, st)
+}
+
+func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
+	fillStatFromDefaultFileInfo(st, t)
+}
+
+func fillStatFromOpenFile(st *Stat_t, fd uintptr, t os.FileInfo) (err error) {
+	fillStatFromFileInfo(st, t)
 	return
 }
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
index 2441c5c6..5e5ffbfe 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/platform/stat_windows.go
@@ -3,46 +3,119 @@
 package platform
 
 import (
-	"os"
+	"io/fs"
 	"syscall"
 )
 
-func stat(path string, st *Stat_t) (err error) {
-	// TODO: See if we can refactor to avoid opening a file first.
-	f, err := OpenFile(path, syscall.O_RDONLY, 0)
+func lstat(path string, st *Stat_t) error {
+	attrs := uint32(syscall.FILE_FLAG_BACKUP_SEMANTICS)
+	// Use FILE_FLAG_OPEN_REPARSE_POINT, otherwise CreateFile will follow symlink.
+	// See https://docs.microsoft.com/en-us/windows/desktop/FileIO/symbolic-link-effects-on-file-systems-functions#createfile-and-createfiletransacted
+	attrs |= syscall.FILE_FLAG_OPEN_REPARSE_POINT
+	return statPath(attrs, path, st)
+}
+
+func stat(path string, st *Stat_t) error {
+	attrs := uint32(syscall.FILE_FLAG_BACKUP_SEMANTICS)
+	return statPath(attrs, path, st)
+}
+
+func statPath(createFileAttrs uint32, path string, st *Stat_t) (err error) {
+	if len(path) == 0 {
+		return syscall.ENOENT
+	}
+	pathp, err := syscall.UTF16PtrFromString(path)
 	if err != nil {
-		return
+		return syscall.EINVAL
 	}
-	defer f.Close()
-	return StatFile(f, st)
+
+	// open the file handle
+	h, err := syscall.CreateFile(pathp, 0, 0, nil,
+		syscall.OPEN_EXISTING, createFileAttrs, 0)
+	if err != nil {
+		// To match expectations of WASI, e.g. TinyGo TestStatBadDir, return
+		// ENOENT, not ENOTDIR.
+		if err == syscall.ENOTDIR {
+			err = syscall.ENOENT
+		}
+		return err
+	}
+	defer syscall.CloseHandle(h)
+
+	return statHandle(h, st)
 }
 
-func fillStatFromOpenFile(stat *Stat_t, fd uintptr, t os.FileInfo) (err error) {
-	d := t.Sys().(*syscall.Win32FileAttributeData)
-	handle := syscall.Handle(fd)
-	var info syscall.ByHandleFileInformation
-	if err = syscall.GetFileInformationByHandle(handle, &info); err != nil {
-		// If the file descriptor is already closed, we have to re-open just like
-		// os.Stat does to allow the results on the closed files.
-		// https://github.com/golang/go/blob/go1.20/src/os/stat_windows.go#L86
-		//
-		// TODO: once we have our File/Stat type, this shouldn't be necessary.
-		// But for now, ignore the error to pass the std library test for bad file descriptor.
-		// https://github.com/ziglang/zig/blob/master/lib/std/os/test.zig#L167-L170
-		if err == syscall.Errno(6) {
-			err = nil
+// fdFile is implemented by os.File in file_unix.go and file_windows.go
+// Note: we use this until we finalize our own FD-scoped file.
+type fdFile interface{ Fd() (fd uintptr) }
+
+func statFile(f fs.File, st *Stat_t) (err error) {
+	if of, ok := f.(fdFile); ok {
+		// Attempt to get the stat by handle, which works for normal files
+		err = statHandle(syscall.Handle(of.Fd()), st)
+
+		// ERROR_INVALID_HANDLE happens before Go 1.20. Don't fail as we only
+		// use that approach to fill in inode data, which is not critical.
+		if err != ERROR_INVALID_HANDLE {
+			return
 		}
 	}
+	return defaultStatFile(f, st)
+}
+
+func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
+	if d, ok := t.Sys().(*syscall.Win32FileAttributeData); ok {
+		st.Ino = 0 // not in Win32FileAttributeData
+		st.Dev = 0 // not in Win32FileAttributeData
+		st.Mode = t.Mode()
+		st.Nlink = 1 // not in Win32FileAttributeData
+		st.Size = t.Size()
+		st.Atim = d.LastAccessTime.Nanoseconds()
+		st.Mtim = d.LastWriteTime.Nanoseconds()
+		st.Ctim = d.CreationTime.Nanoseconds()
+	} else {
+		fillStatFromDefaultFileInfo(st, t)
+	}
+}
+
+func statHandle(h syscall.Handle, st *Stat_t) (err error) {
+	winFt, err := syscall.GetFileType(h)
+	if err != nil {
+		return err
+	}
+
+	var fi syscall.ByHandleFileInformation
+	if err = syscall.GetFileInformationByHandle(h, &fi); err != nil {
+		return err
+	}
+
+	var m fs.FileMode
+	if fi.FileAttributes&syscall.FILE_ATTRIBUTE_READONLY != 0 {
+		m |= 0o444
+	} else {
+		m |= 0o666
+	}
+
+	switch { // check whether this is a symlink first
+	case fi.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0:
+		m |= fs.ModeSymlink
+	case winFt == syscall.FILE_TYPE_PIPE:
+		m |= fs.ModeNamedPipe
+	case winFt == syscall.FILE_TYPE_CHAR:
+		m |= fs.ModeDevice | fs.ModeCharDevice
+	case fi.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0:
+		m |= fs.ModeDir | 0o111 // e.g. 0o444 -> 0o555
+	}
 
 	// FileIndex{High,Low} can be combined and used as a unique identifier like inode.
 	// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/ns-fileapi-by_handle_file_information
-	stat.Ino = (uint64(info.FileIndexHigh) << 32) | uint64(info.FileIndexLow)
-	stat.Dev = uint64(info.VolumeSerialNumber)
-	stat.Mode = t.Mode()
-	stat.Nlink = uint64(info.NumberOfLinks)
-	stat.Size = t.Size()
-	stat.Atim = d.LastAccessTime.Nanoseconds()
-	stat.Mtim = d.LastWriteTime.Nanoseconds()
-	stat.Ctim = d.CreationTime.Nanoseconds()
+	st.Dev = uint64(fi.VolumeSerialNumber)
+	st.Ino = (uint64(fi.FileIndexHigh) << 32) | uint64(fi.FileIndexLow)
+	st.Mode = m
+	st.Nlink = uint64(fi.NumberOfLinks)
+	st.Size = int64(fi.FileSizeHigh)<<32 + int64(fi.FileSizeLow)
+	st.Atim = fi.LastAccessTime.Nanoseconds()
+	st.Mtim = fi.LastWriteTime.Nanoseconds()
+	st.Ctim = fi.CreationTime.Nanoseconds()
 	return
 }
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 06d28ab7..9cfa16fc 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -49,6 +49,11 @@ func (d *dirFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, erro
 	}
 }
 
+// Lstat implements FS.Lstat
+func (d *dirFS) Lstat(path string, stat *platform.Stat_t) error {
+	return platform.Lstat(d.join(path), stat)
+}
+
 // Stat implements FS.Stat
 func (d *dirFS) Stat(path string, stat *platform.Stat_t) error {
 	return platform.Stat(d.join(path), stat)
diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go
index 630cc3e2..65fc7fcb 100644
--- a/internal/sysfs/readfs.go
+++ b/internal/sysfs/readfs.go
@@ -128,6 +128,11 @@ func maskForReads(f fs.File) fs.File {
 	}
 }
 
+// Lstat implements FS.Lstat
+func (r *readFS) Lstat(path string, lstat *platform.Stat_t) error {
+	return r.fs.Lstat(path, lstat)
+}
+
 // Stat implements FS.Stat
 func (r *readFS) Stat(path string, stat *platform.Stat_t) error {
 	return r.fs.Stat(path, stat)
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index a6e9db3f..dd650923 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -61,6 +61,22 @@ type FS interface {
 	// ^^ TODO: Consider syscall.Open, though this implies defining and
 	// coercing flags and perms similar to what is done in os.OpenFile.
 
+	// Lstat is similar to syscall.Lstat, except the path is relative to this
+	// file system.
+	//
+	// # Errors
+	//
+	// The following errors are expected:
+	//   - syscall.ENOENT: `path` doesn't exist.
+	//
+	// # Notes
+	//
+	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
+	//     same value.
+	//   - When the path is a symbolic link, the stat returned is for the link,
+	//     not the file it refers to.
+	Lstat(path string, stat *platform.Stat_t) error
+
 	// Stat is similar to syscall.Stat, except the path is relative to this
 	// file system.
 	//
@@ -73,6 +89,8 @@ type FS interface {
 	//
 	//   - An fs.FileInfo backed implementation sets atim, mtim and ctim to the
 	//     same value.
+	//   - When the path is a symbolic link, the stat returned is for the file
+	//     it refers to.
 	Stat(path string, stat *platform.Stat_t) error
 
 	// Mkdir is similar to os.Mkdir, except the path is relative to this file
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index 70d1206a..b7e4d0e4 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -26,6 +26,11 @@ func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.Fil
 	return nil, syscall.ENOSYS
 }
 
+// Lstat implements FS.Lstat
+func (UnimplementedFS) Lstat(path string, stat *platform.Stat_t) error {
+	return syscall.ENOSYS
+}
+
 // Stat implements FS.Stat
 func (UnimplementedFS) Stat(path string, stat *platform.Stat_t) error {
 	return syscall.ENOSYS


Test patch:
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index 529165f6..24e9a5f9 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -92,10 +92,18 @@ func Main() {
 	if err = syscall.Chmod(file1, 0o600); err != nil {
 		log.Panicln(err)
 	}
-	if stat, err := os.Stat(file1); err != nil {
+
+	// Test stat
+	stat, err := os.Stat(file1)
+	if err != nil {
 		log.Panicln(err)
-	} else if mode := stat.Mode() & fs.ModePerm; mode != 0o600 {
-		log.Panicln("expected mode = 0o600", mode)
+	}
+
+	if stat.Mode().Type() != 0 {
+		log.Panicln("expected type = 0", stat.Mode().Type())
+	}
+	if stat.Mode().Perm() != 0o600 {
+		log.Panicln("expected perm = 0o600", stat.Mode().Perm())
 	}
 
 	// Check the file was truncated.
@@ -114,6 +122,24 @@ func Main() {
 		log.Panicln("unexpected contents:", string(bytes))
 	}
 
+	// Test lstat which should be about the link not its target.
+	link := file1 + "-link"
+	if err = os.Symlink(file1, link); err != nil {
+		log.Panicln(err)
+	}
+
+	lstat, err := os.Lstat(link)
+	if err != nil {
+		log.Panicln(err)
+	}
+
+	if lstat.Mode().Type() != fs.ModeSymlink {
+		log.Panicln("expected type = symlink", lstat.Mode().Type())
+	}
+	if size := int64(len(file1)); lstat.Size() != size {
+		log.Panicln("unexpected symlink size", lstat.Size(), size)
+	}
+
 	// Test removing a non-empty empty directory
 	if err = syscall.Rmdir(dir); err != syscall.ENOTEMPTY {
 		log.Panicln("unexpected error", err)
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
index d1ea72e8..2f54d50f 100644
--- a/internal/platform/stat_test.go
+++ b/internal/platform/stat_test.go
@@ -1,9 +1,11 @@
 package platform
 
 import (
+	"io/fs"
 	"os"
-	"path"
+	pathutil "path"
 	"runtime"
+	"strings"
 	"syscall"
 	"testing"
 	"time"
@@ -11,33 +13,125 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
+func TestLstat(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	var stat Stat_t
+	require.EqualErrno(t, syscall.ENOENT, Lstat(pathutil.Join(tmpDir, "cat"), &stat))
+	require.EqualErrno(t, syscall.ENOENT, Lstat(pathutil.Join(tmpDir, "sub/cat"), &stat))
+
+	t.Run("dir", func(t *testing.T) {
+		err := Lstat(tmpDir, &stat)
+		require.NoError(t, err)
+		require.True(t, stat.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
+
+	file := pathutil.Join(tmpDir, "file")
+	var statFile Stat_t
+
+	t.Run("file", func(t *testing.T) {
+		require.NoError(t, os.WriteFile(file, []byte{1, 2}, 0o400))
+		require.NoError(t, Lstat(file, &statFile))
+		require.Zero(t, statFile.Mode.Type())
+		require.Equal(t, int64(2), statFile.Size)
+		require.NotEqual(t, uint64(0), statFile.Ino)
+	})
+
+	t.Run("link to file", func(t *testing.T) {
+		requireLinkStat(t, file, &statFile)
+	})
+
+	subdir := pathutil.Join(tmpDir, "sub")
+	var statSubdir Stat_t
+	t.Run("subdir", func(t *testing.T) {
+		require.NoError(t, os.Mkdir(subdir, 0o500))
+
+		require.NoError(t, Lstat(subdir, &statSubdir))
+		require.True(t, statSubdir.Mode.IsDir())
+		require.NotEqual(t, uint64(0), statSubdir.Ino)
+	})
+
+	t.Run("link to dir", func(t *testing.T) {
+		requireLinkStat(t, subdir, &statSubdir)
+	})
+
+	t.Run("link to dir link", func(t *testing.T) {
+		pathLink := subdir + "-link"
+		var statLink Stat_t
+		require.NoError(t, Lstat(pathLink, &statLink))
+
+		requireLinkStat(t, pathLink, &statLink)
+	})
+}
+
+func requireLinkStat(t *testing.T, path string, stat *Stat_t) {
+	link := path + "-link"
+	var linkStat Stat_t
+	require.NoError(t, os.Symlink(path, link))
+
+	require.NoError(t, Lstat(link, &linkStat))
+	require.NotEqual(t, uint64(0), linkStat.Ino)
+	require.NotEqual(t, stat.Ino, linkStat.Ino) // inodes are not equal
+	require.Equal(t, fs.ModeSymlink, linkStat.Mode.Type())
+	// From https://linux.die.net/man/2/lstat:
+	// The size of a symbolic link is the length of the pathname it
+	// contains, without a terminating null byte.
+	if runtime.GOOS == "windows" { // size is zero, not the path length
+		require.Zero(t, linkStat.Size)
+	} else {
+		require.Equal(t, int64(len(path)), linkStat.Size)
+	}
+}
+
 func TestStat(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	var stat Stat_t
-	require.EqualErrno(t, syscall.ENOENT, Stat(path.Join(tmpDir, "cat"), &stat))
-	require.EqualErrno(t, syscall.ENOENT, Stat(path.Join(tmpDir, "sub/cat"), &stat))
+	require.EqualErrno(t, syscall.ENOENT, Stat(pathutil.Join(tmpDir, "cat"), &stat))
+	require.EqualErrno(t, syscall.ENOENT, Stat(pathutil.Join(tmpDir, "sub/cat"), &stat))
 
 	t.Run("dir", func(t *testing.T) {
 		err := Stat(tmpDir, &stat)
 		require.NoError(t, err)
 		require.True(t, stat.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
 	})
 
+	file := pathutil.Join(tmpDir, "file")
+	var statFile Stat_t
+
 	t.Run("file", func(t *testing.T) {
-		file := path.Join(tmpDir, "file")
 		require.NoError(t, os.WriteFile(file, nil, 0o400))
+		require.NoError(t, Stat(file, &statFile))
+		require.False(t, statFile.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
 
-		require.NoError(t, Stat(file, &stat))
-		require.False(t, stat.Mode.IsDir())
+	t.Run("link to file", func(t *testing.T) {
+		link := pathutil.Join(tmpDir, "file-link")
+		require.NoError(t, os.Symlink(file, link))
+
+		require.NoError(t, Stat(link, &stat))
+		require.Equal(t, statFile, stat) // resolves to the file
 	})
 
+	subdir := pathutil.Join(tmpDir, "sub")
+	var statSubdir Stat_t
 	t.Run("subdir", func(t *testing.T) {
-		subdir := path.Join(tmpDir, "sub")
 		require.NoError(t, os.Mkdir(subdir, 0o500))
 
-		require.NoError(t, Stat(subdir, &stat))
-		require.True(t, stat.Mode.IsDir())
+		require.NoError(t, Stat(subdir, &statSubdir))
+		require.True(t, statSubdir.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
+
+	t.Run("link to dir", func(t *testing.T) {
+		link := pathutil.Join(tmpDir, "dir-link")
+		require.NoError(t, os.Symlink(subdir, link))
+
+		require.NoError(t, Stat(link, &stat))
+		require.Equal(t, statSubdir, stat) // resolves to the dir
 	})
 }
 
@@ -54,16 +148,19 @@ func TestStatFile(t *testing.T) {
 		err = StatFile(tmpDirF, &stat)
 		require.NoError(t, err)
 		require.True(t, stat.Mode.IsDir())
+		requireDirectoryDevIno(t, stat)
 	})
 
-	if runtime.GOOS != "windows" { // windows allows you to stat a closed dir
+	// Windows allows you to stat a closed dir because it is accessed by path,
+	// not by file descriptor.
+	if runtime.GOOS != "windows" {
 		t.Run("closed dir", func(t *testing.T) {
 			require.NoError(t, tmpDirF.Close())
 			require.EqualErrno(t, syscall.EBADF, StatFile(tmpDirF, &stat))
 		})
 	}
 
-	file := path.Join(tmpDir, "file")
+	file := pathutil.Join(tmpDir, "file")
 	require.NoError(t, os.WriteFile(file, nil, 0o400))
 	fileF, err := OpenFile(file, syscall.O_RDONLY, 0)
 	require.NoError(t, err)
@@ -73,14 +170,16 @@ func TestStatFile(t *testing.T) {
 		err = StatFile(fileF, &stat)
 		require.NoError(t, err)
 		require.False(t, stat.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
 	})
 
 	t.Run("closed file", func(t *testing.T) {
 		require.NoError(t, fileF.Close())
 		require.EqualErrno(t, syscall.EBADF, StatFile(fileF, &stat))
+		require.NotEqual(t, uint64(0), stat.Ino)
 	})
 
-	subdir := path.Join(tmpDir, "sub")
+	subdir := pathutil.Join(tmpDir, "sub")
 	require.NoError(t, os.Mkdir(subdir, 0o500))
 	subdirF, err := OpenFile(subdir, syscall.O_RDONLY, 0)
 	require.NoError(t, err)
@@ -90,6 +189,7 @@ func TestStatFile(t *testing.T) {
 		err = StatFile(subdirF, &stat)
 		require.NoError(t, err)
 		require.True(t, stat.Mode.IsDir())
+		requireDirectoryDevIno(t, stat)
 	})
 
 	if runtime.GOOS != "windows" { // windows allows you to stat a closed dir
@@ -103,7 +203,7 @@ func TestStatFile(t *testing.T) {
 func Test_StatFile_times(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	file := path.Join(tmpDir, "file")
+	file := pathutil.Join(tmpDir, "file")
 	err := os.WriteFile(file, []byte{}, 0o700)
 	require.NoError(t, err)
 
@@ -153,36 +253,56 @@ func Test_StatFile_times(t *testing.T) {
 
 func TestStatFile_dev_inode(t *testing.T) {
 	tmpDir := t.TempDir()
+	d, err := os.Open(tmpDir)
+	require.NoError(t, err)
+	defer d.Close()
 
-	path1 := path.Join(tmpDir, "1")
+	path1 := pathutil.Join(tmpDir, "1")
 	f1, err := os.Create(path1)
 	require.NoError(t, err)
+	defer f1.Close()
 
-	path2 := path.Join(tmpDir, "2")
+	path2 := pathutil.Join(tmpDir, "2")
 	f2, err := os.Create(path2)
 	require.NoError(t, err)
+	defer f2.Close()
+
+	pathLink2 := pathutil.Join(tmpDir, "link2")
+	err = os.Symlink(path2, pathLink2)
+	require.NoError(t, err)
+	l2, err := os.Open(pathLink2)
+	require.NoError(t, err)
+	defer l2.Close()
 
+	// First, stat the directory
 	var stat1 Stat_t
+	require.NoError(t, StatFile(d, &stat1))
+	requireDirectoryDevIno(t, stat1)
+
+	// Now, stat the files in it
 	require.NoError(t, StatFile(f1, &stat1))
 
 	var stat2 Stat_t
 	require.NoError(t, StatFile(f2, &stat2))
 
+	var stat3 Stat_t
+	require.NoError(t, StatFile(l2, &stat3))
+
 	// The files should be on the same device, but different inodes
 	require.Equal(t, stat1.Dev, stat2.Dev)
 	require.NotEqual(t, stat1.Ino, stat2.Ino)
+	require.Equal(t, stat2, stat3) // stat on a link is for its target
 
 	// Redoing stat should result in the same inodes
 	var stat1Again Stat_t
 	require.NoError(t, StatFile(f1, &stat1Again))
-
 	require.Equal(t, stat1.Dev, stat1Again.Dev)
-	require.Equal(t, stat1.Ino, stat1Again.Ino)
 
 	// On Windows, we cannot rename while opening.
 	// So we manually close here before renaming.
 	require.NoError(t, f1.Close())
 	require.NoError(t, f2.Close())
+	require.NoError(t, l2.Close())
 
 	// Renaming a file shouldn't change its inodes.
 	require.NoError(t, Rename(path1, path2))
@@ -194,3 +314,14 @@ func TestStatFile_dev_inode(t *testing.T) {
 	require.Equal(t, stat1.Dev, stat1Again.Dev)
 	require.Equal(t, stat1.Ino, stat1Again.Ino)
 }
+
+func requireDirectoryDevIno(t *testing.T, st Stat_t) {
+	// windows before go 1.20 has trouble reading the inode information on directories.
+	if runtime.GOOS != "windows" || strings.HasPrefix(runtime.Version(), "go1.20") {
+		require.NotEqual(t, uint64(0), st.Dev)
+		require.NotEqual(t, uint64(0), st.Ino)
+	} else {
+		require.Zero(t, st.Dev)
+		require.Zero(t, st.Ino)
+	}
+}
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index 88c2727d..a000c422 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -3,6 +3,8 @@ package sys
 import (
 	"bytes"
 	"io/fs"
+	"runtime"
+	"strings"
 	"testing"
 	"time"
 
@@ -110,12 +112,23 @@ func TestFileEntry_cachedStat(t *testing.T) {
 			ino, ft, err := f.CachedStat()
 			require.NoError(t, err)
 			require.Equal(t, fs.ModeDir, ft)
+			if !canReadDirInode() {
+				tc.expectedIno = 0
+			}
 			require.Equal(t, tc.expectedIno, ino)
 			require.Equal(t, &cachedStat{Ino: tc.expectedIno, Type: fs.ModeDir}, f.cachedStat)
 		})
 	}
 }
 
+func canReadDirInode() bool {
+	if runtime.GOOS != "windows" {
+		return true
+	} else {
+		return strings.HasPrefix(runtime.Version(), "go1.20")
+	}
+}
+
 func TestNewContext_Args(t *testing.T) {
 	tests := []struct {
 		name         string
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index 770a1909..fa28e4b9 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/fstest"
+	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -104,6 +105,22 @@ func TestAdapt_Open_Read(t *testing.T) {
 	})
 }
 
+// TestAdapt_Lstat is unsupported because the Lstat() function is not implemented
+// on os.File.
+func TestAdapt_Lstat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+	testFS := Adapt(os.DirFS(tmpDir))
+
+	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
+		fullPath := pathutil.Join(tmpDir, path)
+		linkPath := pathutil.Join(tmpDir, path+"-link")
+		require.NoError(t, os.Symlink(fullPath, linkPath))
+		var stat platform.Stat_t
+		require.EqualErrno(t, syscall.ENOSYS, testFS.Lstat(linkPath, &stat))
+	}
+}
+
 func TestAdapt_Stat(t *testing.T) {
 	tmpDir := t.TempDir()
 	require.NoError(t, fstest.WriteTestFiles(tmpDir))
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index cb1763b2..0fdda0a0 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -45,6 +45,18 @@ func TestDirFS_String(t *testing.T) {
 	require.Equal(t, ".", testFS.String())
 }
 
+func TestDirFS_Lstat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	testFS := NewDirFS(tmpDir)
+	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
+		require.NoError(t, testFS.Symlink(path, path+"-link"))
+	}
+
+	testLstat(t, testFS)
+}
+
 func TestDirFS_MkDir(t *testing.T) {
 	tmpDir := t.TempDir()
 	testFS := NewDirFS(tmpDir)
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 591c3556..2034edec 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -33,6 +33,20 @@ func TestReadFS_String(t *testing.T) {
 	require.Equal(t, "/tmp", readFS.String())
 }
 
+func TestReadFS_Lstat(t *testing.T) {
+	tmpDir := t.TempDir()
+	require.NoError(t, fstest.WriteTestFiles(tmpDir))
+
+	writeable := NewDirFS(tmpDir)
+	for _, path := range []string{"animals.txt", "sub", "sub-link"} {
+		require.NoError(t, writeable.Symlink(path, path+"-link"))
+	}
+
+	testFS := NewReadFS(writeable)
+
+	testLstat(t, testFS)
+}
+
 func TestReadFS_MkDir(t *testing.T) {
 	writeable := NewDirFS(t.TempDir())
 	testFS := NewReadFS(writeable)
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index cf4cdd0a..f736f09a 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -11,6 +11,7 @@ import (
 	"path"
 	"runtime"
 	"sort"
+	"strings"
 	"syscall"
 	"testing"
 	gofstest "testing/fstest"
@@ -64,7 +65,7 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
 	// Verify stat on the file
 	stat, err := f.Stat()
 	require.NoError(t, err)
-	require.Equal(t, fs.FileMode(0o444), stat.Mode()&fs.ModePerm)
+	require.Equal(t, fs.FileMode(0o444), stat.Mode().Perm())
 }
 
 func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
@@ -176,6 +177,67 @@ func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 	})
 }
 
+func testLstat(t *testing.T, testFS FS) {
+	var stat platform.Stat_t
+	require.EqualErrno(t, syscall.ENOENT, testFS.Lstat("cat", &stat))
+	require.EqualErrno(t, syscall.ENOENT, testFS.Lstat("sub/cat", &stat))
+
+	t.Run("dir", func(t *testing.T) {
+		err := testFS.Lstat(".", &stat)
+		require.NoError(t, err)
+		require.True(t, stat.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
+
+	var statFile platform.Stat_t
+
+	t.Run("file", func(t *testing.T) {
+		require.NoError(t, testFS.Lstat("animals.txt", &statFile))
+		require.Zero(t, statFile.Mode.Type())
+		require.Equal(t, int64(30), statFile.Size)
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
+
+	t.Run("link to file", func(t *testing.T) {
+		requireLinkStat(t, testFS, "animals.txt", &statFile)
+	})
+
+	var statSubdir platform.Stat_t
+	t.Run("subdir", func(t *testing.T) {
+		require.NoError(t, testFS.Lstat("sub", &statSubdir))
+		require.True(t, statSubdir.Mode.IsDir())
+		require.NotEqual(t, uint64(0), stat.Ino)
+	})
+
+	t.Run("link to dir", func(t *testing.T) {
+		requireLinkStat(t, testFS, "sub", &statSubdir)
+	})
+
+	t.Run("link to dir link", func(t *testing.T) {
+		pathLink := "sub-link"
+		var statLink platform.Stat_t
+		require.NoError(t, testFS.Lstat(pathLink, &statLink))
+
+		requireLinkStat(t, testFS, pathLink, &statLink)
+	})
+}
+
+func requireLinkStat(t *testing.T, testFS FS, path string, stat *platform.Stat_t) {
+	link := path + "-link"
+	var linkStat platform.Stat_t
+	require.NoError(t, testFS.Lstat(link, &linkStat))
+	require.NotEqual(t, stat.Ino, linkStat.Ino) // inodes are not equal
+	require.Equal(t, fs.ModeSymlink, linkStat.Mode.Type())
+	// From https://linux.die.net/man/2/lstat:
+	// The size of a symbolic link is the length of the pathname it
+	// contains, without a terminating null byte.
+	if runtime.GOOS == "windows" { // size is zero, not the path length
+		require.Zero(t, linkStat.Size)
+	} else {
+		require.Equal(t, int64(len(path)), linkStat.Size)
+	}
+}
+
 func testStat(t *testing.T, testFS FS) {
 	var stat platform.Stat_t
 	require.EqualErrno(t, syscall.ENOENT, testFS.Stat("cat", &stat))
@@ -184,10 +246,17 @@ func testStat(t *testing.T, testFS FS) {
 	err := testFS.Stat("sub/test.txt", &stat)
 	require.NoError(t, err)
 	require.False(t, stat.Mode.IsDir())
+	require.NotEqual(t, uint64(0), stat.Dev)
+	require.NotEqual(t, uint64(0), stat.Ino)
 
 	err = testFS.Stat("sub", &stat)
 	require.NoError(t, err)
 	require.True(t, stat.Mode.IsDir())
+	// windows before go 1.20 has trouble reading the inode information on directories.
+	if runtime.GOOS != "windows" || strings.HasPrefix(runtime.Version(), "go1.20") {
+		require.NotEqual(t, uint64(0), stat.Dev)
+		require.NotEqual(t, uint64(0), stat.Ino)
+	}
 }
 
 // requireReadDir ensures the input file is a directory, and returns its