Repository: tetratelabs/wazero
Pull request #1135 (2023-02-17 09:20:20)

sysfs: adds chmod
This adds `FS.Chmod` and implements it for `GOOS=js`. This function isn't defined in WASI snapshot01, but it is in [wasi-filesystem](https://github.com/WebAssembly/wasi-filesystem), e.g. [change-file-permissions-at](https://github.com/WebAssembly/wasi-filesystem/blob/main/wasi-filesystem.md#-change-file-permissions-at).


Patch:
diff --git a/internal/gojs/errno.go b/internal/gojs/errno.go
index 90fb1d76..ea09bb48 100644
--- a/internal/gojs/errno.go
+++ b/internal/gojs/errno.go
@@ -21,6 +21,8 @@ func (e *Errno) Error() string {
 // This order match constants from wasi_snapshot_preview1.ErrnoSuccess for
 // easier maintenance.
 var (
+	// ErrnoAcces Permission denied.
+	ErrnoAcces = &Errno{"EACCES"}
 	// ErrnoAgain Resource unavailable, or operation would block.
 	ErrnoAgain = &Errno{"EAGAIN"}
 	// ErrnoBadf Bad file descriptor.
@@ -61,6 +63,8 @@ func ToErrno(err error) *Errno {
 	errno := sysfs.UnwrapOSError(err)
 
 	switch errno {
+	case syscall.EACCES:
+		return ErrnoAcces
 	case syscall.EAGAIN:
 		return ErrnoAgain
 	case syscall.EBADF:
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 9731727a..d1f468d0 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -84,6 +84,8 @@ var (
 
 // The following interfaces are used until we finalize our own FD-scoped file.
 type (
+	// chmoder is implemented by os.File in file_posix.go
+	chmoder interface{ Chmod(fs.FileMode) error }
 	// syncer is implemented by os.File in file_posix.go
 	syncer interface{ Sync() error }
 	// truncater is implemented by os.File in file_posix.go
@@ -528,8 +530,8 @@ func (jsfsChmod) invoke(ctx context.Context, mod api.Module, args ...interface{}
 	mode := goos.ValueToUint32(args[1])
 	callback := args[2].(funcWrapper)
 
-	_, _ = path, mode // TODO
-	var err error = syscall.ENOSYS
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	err := fsc.RootFS().Chmod(path, fs.FileMode(mode))
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
@@ -544,8 +546,16 @@ func (jsfsFchmod) invoke(ctx context.Context, mod api.Module, args ...interface{
 	mode := goos.ValueToUint32(args[1])
 	callback := args[2].(funcWrapper)
 
-	_, _ = fd, mode // TODO
-	var err error = syscall.ENOSYS
+	// Check to see if the file descriptor is available
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	var err error
+	if f, ok := fsc.LookupFile(fd); !ok {
+		err = syscall.EBADF
+	} else if chmoder, ok := f.File.(chmoder); !ok {
+		err = syscall.EBADF // possibly a fake file
+	} else {
+		err = chmoder.Chmod(fs.FileMode(mode))
+	}
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go
index 0a7b35b8..552588c4 100644
--- a/internal/sysfs/dirfs.go
+++ b/internal/sysfs/dirfs.go
@@ -1,7 +1,6 @@
 package sysfs
 
 import (
-	"errors"
 	"io/fs"
 	"os"
 	"syscall"
@@ -51,11 +50,17 @@ func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, erro
 }
 
 // Mkdir implements FS.Mkdir
-func (d *dirFS) Mkdir(name string, perm fs.FileMode) error {
-	err := os.Mkdir(d.join(name), perm)
-	if errors.Is(err, syscall.ENOTDIR) {
-		return syscall.ENOENT
+func (d *dirFS) Mkdir(name string, perm fs.FileMode) (err error) {
+	err = os.Mkdir(d.join(name), perm)
+	if err = UnwrapOSError(err); err == syscall.ENOTDIR {
+		err = syscall.ENOENT
 	}
+	return
+}
+
+// Chmod implements FS.Chmod
+func (d *dirFS) Chmod(name string, perm fs.FileMode) error {
+	err := os.Chmod(d.join(name), perm)
 	return UnwrapOSError(err)
 }
 
diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go
index 554a17bc..d0d3ed99 100644
--- a/internal/sysfs/sysfs.go
+++ b/internal/sysfs/sysfs.go
@@ -52,6 +52,12 @@ type FS interface {
 	// fs.FS. While fs.FS is supported (Adapt), wazero cannot runtime enforce
 	// open flags. Instead, we encourage good behavior and test our built-in
 	// implementations.
+	//
+	// # Notes
+	//
+	//   - flag are the same as OpenFile, for example, os.O_CREATE.
+	//   - Implications of permissions when os.O_CREATE are described in Chmod
+	//     notes.
 	OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error)
 	// ^^ TODO: Consider syscall.Open, though this implies defining and
 	// coercing flags and perms similar to what is done in os.OpenFile.
@@ -66,10 +72,31 @@ type FS interface {
 	//   - syscall.EEXIST: `path` exists and is a directory.
 	//   - syscall.ENOTDIR: `path` exists and is a file.
 	//
+	// # Notes
+	//
+	//   - Implications of permissions are described in Chmod notes.
 	Mkdir(path string, perm fs.FileMode) error
 	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
 	// coercing flags and perms similar to what is done in os.Mkdir.
 
+	// Chmod is similar to os.Chmod, except the path is relative to this file
+	// system, and syscall.Errno are returned instead of a os.PathError.
+	//
+	// # Errors
+	//
+	// The following errors are expected:
+	//   - syscall.EINVAL: `path` is invalid.
+	//   - syscall.ENOENT: `path` does not exist.
+	//
+	// # Notes
+	//
+	//   - Windows ignores the execute bit, and any permissions come back as
+	//     group and world. For example, chmod of 0400 reads back as 0444, and
+	//     0700 0666. Also, permissions on directories aren't supported at all.
+	Chmod(path string, perm fs.FileMode) error
+	// ^^ TODO: Consider syscall.Chmod, though this implies defining and
+	// coercing flags and perms similar to what is done in os.Chmod.
+
 	// Rename is similar to syscall.Rename, except the path is relative to this
 	// file system.
 	//
@@ -171,7 +198,8 @@ type FS interface {
 	// The following errors are expected:
 	//   - syscall.EINVAL: `path` is invalid or size is negative.
 	//   - syscall.ENOENT: `path` doesn't exist
-	Truncate(name string, size int64) error
+	//   - syscall.EACCES: `path` doesn't have write access.
+	Truncate(path string, size int64) error
 
 	// Utimes is similar to syscall.UtimesNano, except the path is relative to
 	// this file system.
@@ -221,6 +249,7 @@ type file interface {
 	readFile
 	io.Writer
 	io.WriterAt // for pwrite
+	chmoder
 	syncer
 	truncater
 	fder // for the number of links.
@@ -228,6 +257,8 @@ type file interface {
 
 // The following interfaces are used until we finalize our own FD-scoped file.
 type (
+	// chmoder is implemented by os.File in file_posix.go
+	chmoder interface{ Chmod(fs.FileMode) error }
 	// syncer is implemented by os.File in file_posix.go
 	syncer interface{ Sync() error }
 	// truncater is implemented by os.File in file_posix.go
diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go
index e7ee48c9..e9c73b6d 100644
--- a/internal/sysfs/unsupported.go
+++ b/internal/sysfs/unsupported.go
@@ -29,6 +29,11 @@ func (UnimplementedFS) Mkdir(path string, perm fs.FileMode) error {
 	return syscall.ENOSYS
 }
 
+// Chmod implements FS.Chmod
+func (UnimplementedFS) Chmod(path string, perm fs.FileMode) error {
+	return syscall.ENOSYS
+}
+
 // Rename implements FS.Rename
 func (UnimplementedFS) Rename(from, to string) error {
 	return syscall.ENOSYS
diff --git a/internal/wasi_snapshot_preview1/errno.go b/internal/wasi_snapshot_preview1/errno.go
index 838d96be..42e0ac96 100644
--- a/internal/wasi_snapshot_preview1/errno.go
+++ b/internal/wasi_snapshot_preview1/errno.go
@@ -268,8 +268,9 @@ var errnoToString = [...]string{
 func ToErrno(err error) Errno {
 	errno := sysfs.UnwrapOSError(err)
 
-	// The below Errno have references in existing WASI code.
 	switch errno {
+	case syscall.EACCES:
+		return ErrnoAcces
 	case syscall.EAGAIN:
 		return ErrnoAgain
 	case syscall.EBADF:


Test patch:
diff --git a/internal/gojs/errno_test.go b/internal/gojs/errno_test.go
index 48f66efa..47bc972a 100644
--- a/internal/gojs/errno_test.go
+++ b/internal/gojs/errno_test.go
@@ -13,6 +13,11 @@ func TestToErrno(t *testing.T) {
 		input    error
 		expected *Errno
 	}{
+		{
+			name:     "syscall.EACCES",
+			input:    syscall.EACCES,
+			expected: ErrnoAcces,
+		},
 		{
 			name:     "syscall.EAGAIN",
 			input:    syscall.EAGAIN,
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index bbd4a68b..529165f6 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -3,6 +3,7 @@ package writefs
 import (
 	"errors"
 	"fmt"
+	"io/fs"
 	"log"
 	"os"
 	"path"
@@ -73,9 +74,31 @@ func Main() {
 	if err = f.Sync(); err != nil {
 		log.Panicln(err)
 	}
+	// Next, chmod it (tests Fchmod)
+	if err = f.Chmod(0o400); err != nil {
+		log.Panicln(err)
+	}
+	if stat, err := f.Stat(); err != nil {
+		log.Panicln(err)
+	} else if mode := stat.Mode() & fs.ModePerm; mode != 0o400 {
+		log.Panicln("expected mode = 0o400", mode)
+	}
+	// Finally, close it.
 	if err = f.Close(); err != nil {
 		log.Panicln(err)
 	}
+
+	// Revert to writeable
+	if err = syscall.Chmod(file1, 0o600); err != nil {
+		log.Panicln(err)
+	}
+	if stat, err := os.Stat(file1); err != nil {
+		log.Panicln(err)
+	} else if mode := stat.Mode() & fs.ModePerm; mode != 0o600 {
+		log.Panicln("expected mode = 0o600", mode)
+	}
+
+	// Check the file was truncated.
 	if bytes, err := os.ReadFile(file1); err != nil {
 		log.Panicln(err)
 	} else if string(bytes) != "wa" {
diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go
index f13ab400..fcbcb164 100644
--- a/internal/sysfs/adapter_test.go
+++ b/internal/sysfs/adapter_test.go
@@ -24,6 +24,13 @@ func TestAdapt_MkDir(t *testing.T) {
 	require.Equal(t, syscall.ENOSYS, err)
 }
 
+func TestAdapt_Chmod(t *testing.T) {
+	testFS := Adapt(os.DirFS(t.TempDir()))
+
+	err := testFS.Chmod("chmod", fs.ModeDir)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
 func TestAdapt_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
 	testFS := Adapt(os.DirFS(tmpDir))
@@ -110,6 +117,8 @@ func (dir hackFS) Open(name string) (fs.File, error) {
 		return f, nil
 	} else if errors.Is(err, syscall.EISDIR) {
 		return os.OpenFile(path, os.O_RDONLY, 0)
+	} else if errors.Is(err, syscall.ENOENT) {
+		return os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o444)
 	} else {
 		return nil, err
 	}
diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go
index 888f2404..4ae5ec4e 100644
--- a/internal/sysfs/dirfs_test.go
+++ b/internal/sysfs/dirfs_test.go
@@ -76,6 +76,45 @@ func TestDirFS_MkDir(t *testing.T) {
 		err := testFS.Mkdir(filePath, fs.ModeDir)
 		require.Equal(t, syscall.ENOENT, err)
 	})
+
+	// Remove the path so that we can test creating it with perms.
+	require.NoError(t, os.Remove(realPath))
+
+	// Setting mode only applies to files on windows
+	if runtime.GOOS != "windows" {
+		t.Run("dir", func(t *testing.T) {
+			require.NoError(t, os.Mkdir(realPath, 0o444))
+			defer os.RemoveAll(realPath)
+			testChmod(t, testFS, name)
+		})
+	}
+
+	t.Run("file", func(t *testing.T) {
+		require.NoError(t, os.WriteFile(realPath, nil, 0o444))
+		defer os.RemoveAll(realPath)
+		testChmod(t, testFS, name)
+	})
+}
+
+func testChmod(t *testing.T, testFS FS, path string) {
+	// Test base case, using 0o444 not 0o400 for read-back on windows.
+	requireMode(t, testFS, path, 0o444)
+
+	// Test adding write, using 0o666 not 0o600 for read-back on windows.
+	require.NoError(t, testFS.Chmod(path, 0o666))
+	requireMode(t, testFS, path, 0o666)
+
+	if runtime.GOOS != "windows" {
+		// Test clearing group and world, setting owner read+execute.
+		require.NoError(t, testFS.Chmod(path, 0o500))
+		requireMode(t, testFS, path, 0o500)
+	}
+}
+
+func requireMode(t *testing.T, testFS FS, path string, mode fs.FileMode) {
+	stat, err := StatPath(testFS, path)
+	require.NoError(t, err)
+	require.Equal(t, mode, stat.Mode()&fs.ModePerm)
 }
 
 func TestDirFS_Rename(t *testing.T) {
@@ -371,7 +410,7 @@ func TestDirFS_Utimes(t *testing.T) {
 	testUtimes(t, tmpDir, testFS)
 }
 
-func TestDirFS_Open(t *testing.T) {
+func TestDirFS_OpenFile(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	// Create a subdirectory, so we can test reads outside the FS root.
@@ -466,9 +505,9 @@ func TestDirFS_Truncate(t *testing.T) {
 		require.NoError(t, os.Remove(realPath))
 	})
 
-	t.Run("negative", func(t *testing.T) {
-		require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
+	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
+	t.Run("negative", func(t *testing.T) {
 		err := testFS.Truncate(name, -1)
 		require.Equal(t, syscall.EINVAL, err)
 	})
diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go
index 9a6a165b..68c1dea9 100644
--- a/internal/sysfs/readfs_test.go
+++ b/internal/sysfs/readfs_test.go
@@ -41,6 +41,14 @@ func TestReadFS_MkDir(t *testing.T) {
 	require.Equal(t, syscall.ENOSYS, err)
 }
 
+func TestReadFS_Chmod(t *testing.T) {
+	writeable := NewDirFS(t.TempDir())
+	testFS := NewReadFS(writeable)
+
+	err := testFS.Chmod("chmod", fs.ModeDir)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
 func TestReadFS_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
 	writeable := NewDirFS(tmpDir)
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 49739f59..813ff57a 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -45,6 +45,28 @@ func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
 	b, err := os.ReadFile(realPath)
 	require.NoError(t, err)
 	require.Equal(t, fileContents, b)
+
+	require.NoError(t, f.Close())
+
+	// re-create as read-only, using 0444 to allow read-back on windows.
+	require.NoError(t, os.Remove(realPath))
+	f, err = testFS.OpenFile(file, os.O_RDONLY|os.O_CREATE, 0o444)
+	require.NoError(t, err)
+	defer f.Close()
+
+	w, ok = f.(io.Writer)
+	require.True(t, ok)
+
+	if runtime.GOOS != "windows" {
+		// If the read-only flag was honored, we should not be able to write!
+		_, err = w.Write(fileContents)
+		require.Equal(t, syscall.EBADF, UnwrapOSError(err))
+	}
+
+	// Verify stat on the file
+	stat, err := f.Stat()
+	require.NoError(t, err)
+	require.Equal(t, fs.FileMode(0o444), stat.Mode()&fs.ModePerm)
 }
 
 func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
diff --git a/internal/wasi_snapshot_preview1/errno_test.go b/internal/wasi_snapshot_preview1/errno_test.go
index 808fc07f..a9e90d47 100644
--- a/internal/wasi_snapshot_preview1/errno_test.go
+++ b/internal/wasi_snapshot_preview1/errno_test.go
@@ -13,6 +13,11 @@ func TestToErrno(t *testing.T) {
 		input    error
 		expected Errno
 	}{
+		{
+			name:     "syscall.EACCES",
+			input:    syscall.EACCES,
+			expected: ErrnoAcces,
+		},
 		{
 			name:     "syscall.EAGAIN",
 			input:    syscall.EAGAIN,