Repository: tetratelabs/wazero
Pull request #1041 (2023-01-16 23:10:46)

Implements stat device/inode on WASI and GOOS=js
This implements stat device and inode for WASI and GOOS=js, though it does not implement the host side for windows, yet. Doing windows requires plumbing as the values needed aren't exposed in Go. When we re-do the syscallfs file type to have a stat method, we can address that glitch. Meanwhile, I can find no Go sourcebase that does any better, though the closest is the implementation details of os.SameFile.

I verified this with wasi-testsuite which now passes all but 1 case which is unrelated (we haven't yet implemented `fd_tell`).

```bash
$ python3 test-runner/wasi_test_runner.py -t ./tests/assemblyscript/testsuite/  ./tests/c/testsuite/ -r adapters/wazero.sh
Test environ_get-multiple-variables passed
Test fd_write-to-invalid-fd passed
Test environ_sizes_get-multiple-variables passed
Test args_sizes_get-no-arguments passed
Test proc_exit-success passed
Test proc_exit-failure passed
Test args_get-multiple-arguments passed
Test args_sizes_get-multiple-arguments passed
Test fd_write-to-stdout passed
Test random_get-non-zero-length passed
Test environ_sizes_get-no-variables passed
Test random_get-zero-length passed
Test lseek failed
  [exit_code] 0 == 1
STDOUT:

STDERR:
+ exec wazero run -hostlogging=filesystem -mount=/Users/adrian/oss/wasi-testsuite/tests/c/testsuite:/ /Users/adrian/oss/wasi-testsuite/tests/c/testsuite/lseek.wasm
==> wasi_snapshot_preview1.fd_prestat_get(fd=3)
<== (prestat={pr_name_len=1},errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_dir_name(fd=3)
<== (path=/,errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_prestat_get(fd=4)
<== (prestat=,errno=EBADF)
==> wasi_snapshot_preview1.fd_fdstat_get(fd=3)
<== (stat={filetype=DIRECTORY,fdflags=,fs_rights_base=,fs_rights_inheriting=},errno=ESUCCESS)
==> wasi_snapshot_preview1.path_open(fd=3,dirflags=SYMLINK_FOLLOW,path=fs-tests.dir/lseek.txt,oflags=,fs_rights_base=,fs_rights_inheriting=,fdflags=)
<== (opened_fd=4,errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_read(fd=4,iovs=69712,iovs_len=2)
<== (nread=8,errno=ESUCCESS)
==> wasi_snapshot_preview1.fd_seek(fd=4,offset=-4,whence=1,result.newoffset=69720)
<== errno=ESUCCESS
--> wasi_snapshot_preview1.fd_tell(fd=4,result.offset=69720)
<-- errno=ENOSYS
Assertion failed: lseek(fd, 0, SEEK_CUR) == 4 (testsuite/lseek.c: main: 22)
error instantiating wasm binary: module[] function[_start] failed: wasm error: unreachable
wasm stack trace:
	.abort()
	.__assert_fail(i32,i32,i32,i32)
	.__original_main() i32
	._start()

Test clock_getres-realtime passed
Test clock_gettime-monotonic passed
Test clock_getres-monotonic passed
Test fopen-with-access passed
Test stat-dev-ino passed
Test fdopendir-with-access passed
Test clock_gettime-realtime passed
Test pwrite-with-access passed
Test fopen-with-no-access passed
Test pread-with-access passed

===== Test results =====
Runtime: wazero v0.0.0
Suite: WASI Assemblyscript tests
  Total: 12
  Passed:  12
  Failed:  0

Suite: WASI C tests
  Total: 11
  Passed:  10
  Failed:  1

Test suites: 1 failed, 1 passed, 0 total
Tests:       1 failed, 22 passed, 0 total
```


Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 94e077e2..ba052d02 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -264,29 +264,20 @@ func getWasiFiletype(fileMode fs.FileMode) uint8 {
 	return wasiFileType
 }
 
-var blockFilestat = []byte{
-	0, 0, 0, 0, 0, 0, 0, 0, // device
-	0, 0, 0, 0, 0, 0, 0, 0, // inode
-	FILETYPE_BLOCK_DEVICE, 0, 0, 0, 0, 0, 0, 0, // filetype
-	1, 0, 0, 0, 0, 0, 0, 0, // nlink
-	0, 0, 0, 0, 0, 0, 0, 0, // filesize
-	0, 0, 0, 0, 0, 0, 0, 0, // atim
-	0, 0, 0, 0, 0, 0, 0, 0, // mtim
-	0, 0, 0, 0, 0, 0, 0, 0, // ctim
-}
-
 func writeFilestat(buf []byte, stat fs.FileInfo) {
+	device, inode := platform.StatDeviceInode(stat)
 	filetype := getWasiFiletype(stat.Mode())
 	filesize := uint64(stat.Size())
 	atimeNsec, mtimeNsec, ctimeNsec := platform.StatTimes(stat)
 
-	// memory is re-used, so ensure the result is defaulted.
-	copy(buf, blockFilestat[:32])
-	buf[16] = filetype
-	le.PutUint64(buf[32:], filesize)          // filesize
-	le.PutUint64(buf[40:], uint64(atimeNsec)) // atim
-	le.PutUint64(buf[48:], uint64(mtimeNsec)) // mtim
-	le.PutUint64(buf[56:], uint64(ctimeNsec)) // ctim
+	le.PutUint64(buf, device)
+	le.PutUint64(buf[8:], inode)
+	le.PutUint64(buf[16:], uint64(filetype))
+	le.PutUint64(buf[24:], 1) // nlink
+	le.PutUint64(buf[32:], filesize)
+	le.PutUint64(buf[40:], uint64(atimeNsec))
+	le.PutUint64(buf[48:], uint64(mtimeNsec))
+	le.PutUint64(buf[56:], uint64(ctimeNsec))
 }
 
 // fdFilestatSetSize is the WASI function named FdFilestatSetSizeName which
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 03459915..6182c4cb 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -185,6 +185,7 @@ func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
 func newJsSt(stat fs.FileInfo) *jsSt {
 	ret := &jsSt{}
 	ret.isDir = stat.IsDir()
+	ret.dev, ret.ino = platform.StatDeviceInode(stat)
 	ret.mode = getJsMode(stat.Mode())
 	ret.size = stat.Size()
 	atimeNsec, mtimeNsec, ctimeNsec := platform.StatTimes(stat)
@@ -684,7 +685,7 @@ func (jsfsFsync) invoke(ctx context.Context, mod api.Module, args ...interface{}
 // jsSt is pre-parsed from fs_js.go setStat to avoid thrashing
 type jsSt struct {
 	isDir   bool
-	dev     int64
+	dev     uint64
 	ino     uint64
 	mode    uint32
 	nlink   uint32
diff --git a/internal/platform/stat.go b/internal/platform/stat.go
index 603e811f..eee7f686 100644
--- a/internal/platform/stat.go
+++ b/internal/platform/stat.go
@@ -11,6 +11,21 @@ func StatTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	return statTimes(t)
 }
 
+// StatDeviceInode returns platform-specific values if os.FileInfo Sys is
+// available. Otherwise, it returns zero which makes file identity comparison
+// unsupported.
+//
+// Returning zero for now works in most cases, except notably wasi-libc
+// code that needs to compare file identity via the underlying data as
+// opposed to a host function similar to os.SameFile.
+// See https://github.com/WebAssembly/wasi-filesystem/issues/65
+func StatDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	if t.Sys() == nil { // possibly fake filesystem
+		return
+	}
+	return statDeviceInode(t)
+}
+
 func mtimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	mtimeNsec = t.ModTime().UnixNano()
 	atimeNsec = mtimeNsec
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
index e9827e5a..7d5fae75 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/platform/stat_bsd.go
@@ -14,3 +14,10 @@ func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	ctime := d.Ctimespec
 	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec
 }
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	d := t.Sys().(*syscall.Stat_t)
+	dev = uint64(d.Dev)
+	inode = d.Ino
+	return
+}
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
index aaaf253f..8f0beb2c 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/platform/stat_linux.go
@@ -1,4 +1,7 @@
-//go:build (amd64 || arm64) && linux
+//go:build (amd64 || arm64 || riscv64) && linux
+
+// Note: This expression is not the same as compiler support, even if it looks
+// similar. Platform functions here are used in interpreter mode as well.
 
 package platform
 
@@ -14,3 +17,10 @@ func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	ctime := d.Ctim
 	return atime.Sec*1e9 + atime.Nsec, mtime.Sec*1e9 + mtime.Nsec, ctime.Sec*1e9 + ctime.Nsec
 }
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	d := t.Sys().(*syscall.Stat_t)
+	dev = d.Dev
+	inode = d.Ino
+	return
+}
diff --git a/internal/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go
index 140549f8..69635b5b 100644
--- a/internal/platform/stat_unsupported.go
+++ b/internal/platform/stat_unsupported.go
@@ -1,4 +1,4 @@
-//go:build !(amd64 || arm64) || !(darwin || linux || freebsd || windows)
+//go:build !((amd64 || arm64 || riscv64) && linux) && !((amd64 || arm64) && (darwin || freebsd)) && !((amd64 || arm64) && windows)
 
 package platform
 
@@ -7,3 +7,7 @@ import "os"
 func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	return mtimes(t)
 }
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	return
+}
diff --git a/internal/platform/stat_windows.go b/internal/platform/stat_windows.go
index 5080d933..a5dcb663 100644
--- a/internal/platform/stat_windows.go
+++ b/internal/platform/stat_windows.go
@@ -14,3 +14,11 @@ func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	ctimeNsec = d.CreationTime.Nanoseconds()
 	return
 }
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	// TODO: VolumeSerialNumber, FileIndexHigh and FileIndexLow are used in
+	// os.SameFile, but the fields aren't exported or accessible in os.FileInfo
+	// When we make our file type, get these from GetFileInformationByHandle.
+	// Note that this requires access to the underlying FD number.
+	return 0, 0
+}


Test patch:
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 6b96dd9f..4537380d 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -73,12 +73,12 @@ func Test_writefs(t *testing.T) {
 		//  Note: as of Go 1.19, only the Sec field is set on update in fs_js.go.
 		require.Equal(t, `/tmp/dir mode drwx------
 /tmp/dir/file mode -rw-------
-times: 123 0 567 0
+times: 123000000000 567000000000
 `, stdout)
 	} else { // only mtimes will return.
 		require.Equal(t, `/tmp/dir mode drwx------
 /tmp/dir/file mode -rw-------
-times: 567 0 567 0
+times: 567000000000 567000000000
 `, stdout)
 	}
 }
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index 2a7a4f16..4ae4dc64 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -88,8 +88,18 @@ func Main() {
 	if stat, err := os.Stat(dir); err != nil {
 		log.Panicln("unexpected error", err)
 	} else {
-		atimeSec, atimeNsec, mtimeSec, mtimeNsec, _, _ := statTimes(stat)
-		fmt.Println("times:", atimeSec, atimeNsec, mtimeSec, mtimeNsec)
+		atimeNsec, mtimeNsec, _ := statTimes(stat)
+		fmt.Println("times:", atimeNsec, mtimeNsec)
+
+		// statDeviceInode cannot be tested against real device values because
+		// the size of d.Dev (32-bit) in js is smaller than linux (64-bit).
+		//
+		// We can't test the real inode of dir, though we could /tmp as that
+		// file is visible on the host. However, we haven't yet implemented
+		// platform.StatDeviceInode on windows, so we couldn't run that test
+		// in CI. For now, this only tests there is no compilation problem or
+		// runtime panic.
+		_, _ = statDeviceInode(stat)
 	}
 
 	// Test renaming a file, noting we can't verify error numbers as they
diff --git a/internal/gojs/testdata/writefs/times.go b/internal/gojs/testdata/writefs/times.go
index ad4dfd2b..9f472ab8 100644
--- a/internal/gojs/testdata/writefs/times.go
+++ b/internal/gojs/testdata/writefs/times.go
@@ -8,6 +8,10 @@ import (
 	"github.com/tetratelabs/wazero/internal/platform"
 )
 
-func statTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	return platform.StatTimes(t) // allow the file to compile and run outside JS
 }
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	return platform.StatDeviceInode(t)
+}
diff --git a/internal/gojs/testdata/writefs/times_js.go b/internal/gojs/testdata/writefs/times_js.go
index 2a744eeb..fe85241a 100644
--- a/internal/gojs/testdata/writefs/times_js.go
+++ b/internal/gojs/testdata/writefs/times_js.go
@@ -5,7 +5,12 @@ import (
 	"syscall"
 )
 
-func statTimes(t os.FileInfo) (atimeSec, atimeNsec, mtimeSec, mtimeNsec, ctimeSec, ctimeNsec int64) {
+func statTimes(t os.FileInfo) (atimeNsec, mtimeNsec, ctimeNsec int64) {
 	d := t.Sys().(*syscall.Stat_t)
-	return d.Atime, d.AtimeNsec, d.Mtime, d.MtimeNsec, d.Ctime, d.CtimeNsec
+	return d.Atime*1e9 + d.AtimeNsec, d.Mtime*1e9 + d.MtimeNsec, d.Ctime*1e9 + d.CtimeNsec
+}
+
+func statDeviceInode(t os.FileInfo) (dev, inode uint64) {
+	d := t.Sys().(*syscall.Stat_t)
+	return uint64(d.Dev), uint64(d.Ino)
 }
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
index 23f836fe..f240450c 100644
--- a/internal/platform/stat_test.go
+++ b/internal/platform/stat_test.go
@@ -53,10 +53,53 @@ func Test_StatTimes(t *testing.T) {
 			require.NoError(t, err)
 
 			atimeNsec, mtimeNsec, _ := StatTimes(stat)
-			if CompilerSupported() {
-				require.Equal(t, atimeNsec, tc.atimeNsec)
-			} // else only mtimes will return.
+			require.Equal(t, atimeNsec, tc.atimeNsec)
 			require.Equal(t, mtimeNsec, tc.mtimeNsec)
 		})
 	}
 }
+
+func TestStatDeviceInode(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("platform.StatDeviceInode not yet implemented on windows")
+	}
+
+	tmpDir := t.TempDir()
+
+	path1 := path.Join(tmpDir, "1")
+	fa, err := os.Create(path1)
+	require.NoError(t, err)
+	defer fa.Close()
+
+	path2 := path.Join(tmpDir, "2")
+	fb, err := os.Create(path2)
+	require.NoError(t, err)
+	defer fb.Close()
+
+	stat1, err := fa.Stat()
+	require.NoError(t, err)
+	device1, inode1 := StatDeviceInode(stat1)
+
+	stat2, err := fb.Stat()
+	require.NoError(t, err)
+	device2, inode2 := StatDeviceInode(stat2)
+
+	// The files should be on the same device, but different inodes
+	require.Equal(t, device1, device2)
+	require.NotEqual(t, inode1, inode2)
+
+	// Redoing stat should result in the same inodes
+	stat1Again, err := os.Stat(path1)
+	require.NoError(t, err)
+	device1Again, inode1Again := StatDeviceInode(stat1Again)
+	require.Equal(t, device1, device1Again)
+	require.Equal(t, inode1, inode1Again)
+
+	// Renaming a file shouldn't change its inodes
+	require.NoError(t, os.Rename(path1, path2))
+	stat1Again, err = os.Stat(path2)
+	require.NoError(t, err)
+	device1Again, inode1Again = StatDeviceInode(stat1Again)
+	require.Equal(t, device1, device1Again)
+	require.Equal(t, inode1, inode1Again)
+}