From 2f9ae52f5b1b1fc22e77d243e0d177c7b19646c9 Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Sun, 26 Feb 2023 09:02:28 +0800 Subject: [PATCH] gojs: implements lstat 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. Signed-off-by: Adrian Cole --- .github/workflows/integration.yaml | 2 +- Makefile | 2 +- internal/gojs/fs.go | 22 +++- internal/gojs/testdata/writefs/main.go | 32 ++++- internal/platform/open_file_windows.go | 2 + internal/platform/stat.go | 60 +++++---- internal/platform/stat_bsd.go | 49 +++++--- internal/platform/stat_linux.go | 49 +++++--- internal/platform/stat_test.go | 165 ++++++++++++++++++++++--- internal/platform/stat_unsupported.go | 25 +++- internal/platform/stat_windows.go | 131 +++++++++++++++----- internal/sys/sys_test.go | 9 ++ internal/sysfs/adapter_test.go | 17 +++ internal/sysfs/dirfs.go | 5 + internal/sysfs/dirfs_test.go | 12 ++ internal/sysfs/readfs.go | 5 + internal/sysfs/readfs_test.go | 14 +++ internal/sysfs/sysfs.go | 18 +++ internal/sysfs/sysfs_test.go | 71 ++++++++++- internal/sysfs/unsupported.go | 5 + 20 files changed, 569 insertions(+), 126 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 7000a677e45..7595bf5dead 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -18,7 +18,7 @@ defaults: shell: bash env: # Update this prior to requiring a higher minor version in go.mod - GO_VERSION: "1.19" # 1.xx == latest patch of 1.xx + GO_VERSION: "1.20" # 1.xx == latest patch of 1.xx ZIG_VERSION: "0.11.0-dev.1797+d3c9bfada" TINYGO_VERSION: "0.27.0" diff --git a/Makefile b/Makefile index bba12b221da..fa3d532fe2c 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ main_sources := $(wildcard $(filter-out %_test.go $(all_testdata) $(all_testing main_packages := $(sort $(foreach f,$(dir $(main_sources)),$(if $(findstring ./,$(f)),./,./$(f)))) # By default, we don't run with -race as it's costly to run on every PR. -go_test_options ?= -timeout 120s +go_test_options ?= -timeout 1200s ensureCompilerFastest := -ldflags '-X github.com/tetratelabs/wazero/internal/integration_test/vs.ensureCompilerFastest=true' .PHONY: bench diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go index a1c7d1e3ca9..4d4babce7e9 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/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go index 529165f6e70..24e9a5f9bbd 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/open_file_windows.go b/internal/platform/open_file_windows.go index 66414a47d61..f7fe16531aa 100644 --- a/internal/platform/open_file_windows.go +++ b/internal/platform/open_file_windows.go @@ -44,6 +44,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 9f4d79c5479..e04be1405ae 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 f4920f7c44a..cfc2d57ce31 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 1af8d4578ff..d975db9c78e 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_test.go b/internal/platform/stat_test.go index d1ea72e8240..2f54d50f2fd 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/platform/stat_unsupported.go b/internal/platform/stat_unsupported.go index 61cc3109949..980779aefa4 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 2441c5c6fec..5e5ffbfe5bf 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/sys/sys_test.go b/internal/sys/sys_test.go index 88c2727d683..59363dcc185 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,19 @@ 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 { + return runtime.GOOS != "windows" || 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 770a1909f0d..fa28e4b949c 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.go b/internal/sysfs/dirfs.go index 027ebf512d3..e3f2a2f0ddd 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/dirfs_test.go b/internal/sysfs/dirfs_test.go index f9f54de0fa1..2b689817393 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.go b/internal/sysfs/readfs.go index 630cc3e2c9e..65fc7fcb4fd 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/readfs_test.go b/internal/sysfs/readfs_test.go index 591c3556279..2034edecf87 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.go b/internal/sysfs/sysfs.go index 4baf5b8eb76..409860176d3 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/sysfs_test.go b/internal/sysfs/sysfs_test.go index cf4cdd0a5b8..f736f09ada9 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 diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go index 70d1206a423..b7e4d0e480b 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