From 94491fef0b85807f5fa309ad2b98fc2fe6791dec Mon Sep 17 00:00:00 2001 From: Crypt Keeper <64215+codefromthecrypt@users.noreply.github.com> Date: Sat, 31 Dec 2022 16:37:28 +0800 Subject: [PATCH] Implements rename in GOOS=js and WASI (#991) This implements rename, which is the last function needed to pass TinyGo os package tests: Signed-off-by: Adrian Cole --- imports/wasi_snapshot_preview1/fs.go | 83 ++++++-- imports/wasi_snapshot_preview1/fs_test.go | 223 +++++++++++++++++++++- internal/gojs/custom/fs.go | 8 +- internal/gojs/fs.go | 24 ++- internal/gojs/testdata/writefs/main.go | 29 ++- internal/sys/fs.go | 11 ++ internal/syscallfs/dirfs.go | 17 ++ internal/syscallfs/dirfs_test.go | 199 +++++++++++++++++++ internal/syscallfs/syscall.go | 4 + internal/syscallfs/syscall_windows.go | 31 +++ internal/syscallfs/syscallfs.go | 36 +++- 11 files changed, 625 insertions(+), 40 deletions(-) diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go index 66edfba978..acc52f959b 100644 --- a/imports/wasi_snapshot_preview1/fs.go +++ b/imports/wasi_snapshot_preview1/fs.go @@ -1291,7 +1291,7 @@ var pathOpen = newHostFunc( func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno { fsc := mod.(*wasm.CallContext).Sys.FS() - dirfd := uint32(params[0]) + preopenFD := uint32(params[0]) // TODO: dirflags is a lookupflags, and it only has one bit: symlink_follow // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#lookupflags @@ -1308,7 +1308,7 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno { fdflags := uint16(params[7]) resultOpenedFd := uint32(params[8]) - pathName, errno := atPath(fsc, mod.Memory(), dirfd, path, pathLen) + pathName, errno := atPath(fsc, mod.Memory(), preopenFD, path, pathLen) if errno != ErrnoSuccess { return errno } @@ -1346,15 +1346,19 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno { // here in any way except assuming it is "/". // // See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/sources/at_fdcwd.c#L24-L26 -// -// TODO: path is not precise here, as it should be a path relative to the -// FD, which isn't always rootFD (3). This means the path for Open may need -// to be built up. For example, if dirfd represents "/tmp/foo" and -// path="bar", this should open "/tmp/foo/bar" not "/bar". -// // See https://linux.die.net/man/2/openat -func atPath(fsc *sys.FSContext, mem api.Memory, dirfd, path, pathLen uint32) (string, Errno) { - if _, ok := fsc.OpenedFile(dirfd); !ok { +func atPath(fsc *sys.FSContext, mem api.Memory, dirFd, path, pathLen uint32) (string, Errno) { + if dirFd != sys.FdRoot { //nolint + // TODO: Research if dirFd is always a pre-open. If so, it should + // always be rootFd (3), until we support multiple pre-opens. + // + // Otherwise, the dirFd could be a file created dynamically, and mean + // paths for Open may need to be built up. For example, if dirFd + // represents "/tmp/foo" and path="bar", this should open + // "/tmp/foo/bar" not "/bar". + } + + if _, ok := fsc.OpenedFile(dirFd); !ok { return "", ErrnoBadf } @@ -1456,14 +1460,65 @@ func pathRemoveDirectoryFn(_ context.Context, mod api.Module, params []uint64) E return ErrnoSuccess } -// pathRename is the WASI function named PathRenameName which renames a -// file or directory. -var pathRename = stubFunction( - PathRenameName, +// pathRename is the WASI function named PathRenameName which renames a file or +// directory. +// +// # Parameters +// +// - fd: file descriptor of a directory that `old_path` is relative to +// - old_path: offset in api.Memory to read the old path string from +// - old_path_len: length of `old_path` +// - new_fd: file descriptor of a directory that `new_path` is relative to +// - new_path: offset in api.Memory to read the new path string from +// - new_path_len: length of `new_path` +// +// # Result (Errno) +// +// The return value is ErrnoSuccess except the following error conditions: +// - ErrnoBadf: `fd` or `new_fd` are invalid +// - ErrnoNoent: `old_path` does not exist. +// - ErrnoNotdir: `old` is a directory and `new` exists, but is a file. +// - ErrnoIsdir: `old` is a file and `new` exists, but is a directory. +// +// # Notes +// - This is similar to unlinkat in POSIX. +// See https://linux.die.net/man/2/renameat +// +// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-path_renamefd-fd-old_path-string-new_fd-fd-new_path-string---errno +var pathRename = newHostFunc( + PathRenameName, pathRenameFn, []wasm.ValueType{i32, i32, i32, i32, i32, i32}, "fd", "old_path", "old_path_len", "new_fd", "new_path", "new_path_len", ) +func pathRenameFn(_ context.Context, mod api.Module, params []uint64) Errno { + fsc := mod.(*wasm.CallContext).Sys.FS() + + oldDirFd := uint32(params[0]) + oldPath := uint32(params[1]) + oldPathLen := uint32(params[2]) + + newDirFd := uint32(params[3]) + newPath := uint32(params[4]) + newPathLen := uint32(params[5]) + + oldPathName, errno := atPath(fsc, mod.Memory(), oldDirFd, oldPath, oldPathLen) + if errno != ErrnoSuccess { + return errno + } + + newPathName, errno := atPath(fsc, mod.Memory(), newDirFd, newPath, newPathLen) + if errno != ErrnoSuccess { + return errno + } + + if err := fsc.Rename(oldPathName, newPathName); err != nil { + return ToErrno(err) + } + + return ErrnoSuccess +} + // pathSymlink is the WASI function named PathSymlinkName which creates a // symbolic link. // diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go index 2def94f365..03c21667c6 100644 --- a/imports/wasi_snapshot_preview1/fs_test.go +++ b/imports/wasi_snapshot_preview1/fs_test.go @@ -2830,15 +2830,6 @@ func errNotDir() Errno { return ErrnoNotdir } -// Test_pathRename only tests it is stubbed for GrainLang per #271 -func Test_pathRename(t *testing.T) { - log := requireErrnoNosys(t, PathRenameName, 0, 0, 0, 0, 0, 0) - require.Equal(t, ` ---> wasi_snapshot_preview1.path_rename(fd=0,old_path=,new_fd=0,new_path=) -<-- errno=ENOSYS -`, log) -} - // Test_pathSymlink only tests it is stubbed for GrainLang per #271 func Test_pathSymlink(t *testing.T) { log := requireErrnoNosys(t, PathSymlinkName, 0, 0, 0, 0, 0) @@ -2848,6 +2839,220 @@ func Test_pathSymlink(t *testing.T) { `, log) } +func Test_pathRename(t *testing.T) { + tmpDir := t.TempDir() // open before loop to ensure no locking problems. + fs, err := syscallfs.NewDirFS(tmpDir) + require.NoError(t, err) + + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs)) + defer r.Close(testCtx) + + // set up the initial memory to include the old path name starting at an offset. + oldDirFD := sys.FdRoot + oldPathName := "wazero" + realOldPath := path.Join(tmpDir, oldPathName) + oldPath := uint32(0) + oldPathLen := len(oldPathName) + ok := mod.Memory().Write(oldPath, []byte(oldPathName)) + require.True(t, ok) + + // create the file + err = os.WriteFile(realOldPath, []byte{}, 0o600) + require.NoError(t, err) + + newDirFD := sys.FdRoot + newPathName := "wahzero" + realNewPath := path.Join(tmpDir, newPathName) + newPath := uint32(16) + newPathLen := len(newPathName) + ok = mod.Memory().Write(newPath, []byte(newPathName)) + require.True(t, ok) + + requireErrno(t, ErrnoSuccess, mod, PathRenameName, + uint64(oldDirFD), uint64(oldPath), uint64(oldPathLen), + uint64(newDirFD), uint64(newPath), uint64(newPathLen)) + require.Equal(t, ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=wazero,new_fd=3,new_path=wahzero) +<== errno=ESUCCESS +`, "\n"+log.String()) + + // ensure the file was renamed + _, err = os.Stat(realOldPath) + require.Error(t, err) + _, err = os.Stat(realNewPath) + require.NoError(t, err) +} + +func Test_pathRename_Errors(t *testing.T) { + tmpDir := t.TempDir() // open before loop to ensure no locking problems. + fs, err := syscallfs.NewDirFS(tmpDir) + require.NoError(t, err) + + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fs)) + defer r.Close(testCtx) + + file := "file" + err = os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700) + require.NoError(t, err) + + dir := "dir" + err = os.Mkdir(path.Join(tmpDir, dir), 0o700) + require.NoError(t, err) + + tests := []struct { + name, oldPathName, newPathName string + oldFd, oldPath, oldPathLen uint32 + newFd, newPath, newPathLen uint32 + expectedErrno Errno + expectedLog string + }{ + { + name: "invalid old fd", + oldFd: 42, // arbitrary invalid fd + newFd: sys.FdRoot, + expectedErrno: ErrnoBadf, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=42,old_path=,new_fd=3,new_path=) +<== errno=EBADF +`, + }, + { + name: "invalid new fd", + oldFd: sys.FdRoot, + newFd: 42, // arbitrary invalid fd + expectedErrno: ErrnoBadf, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=,new_fd=42,new_path=) +<== errno=EBADF +`, + }, + { + name: "out-of-memory reading old path", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPath: mod.Memory().Size(), + oldPathLen: 1, + expectedErrno: ErrnoFault, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=OOM(65536,1),new_fd=3,new_path=) +<== errno=EFAULT +`, + }, + { + name: "out-of-memory reading new path", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPath: 0, + oldPathName: "a", + oldPathLen: 1, + newPath: mod.Memory().Size(), + newPathLen: 1, + expectedErrno: ErrnoFault, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=a,new_fd=3,new_path=OOM(65536,1)) +<== errno=EFAULT +`, + }, + { + name: "old path invalid", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPathName: "../foo", + oldPathLen: 6, + expectedErrno: ErrnoInval, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../foo,new_fd=3,new_path=) +<== errno=EINVAL +`, + }, + { + name: "new path invalid", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPathName: file, + oldPathLen: uint32(len(file)), + newPathName: "../foo", + newPathLen: 6, + expectedErrno: ErrnoInval, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../f,new_fd=3,new_path=../foo) +<== errno=EINVAL +`, + }, + { + name: "out-of-memory reading old pathLen", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPath: 0, + oldPathLen: mod.Memory().Size() + 1, // path is in the valid memory range, but pathLen is OOM for path + expectedErrno: ErrnoFault, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=OOM(0,65537),new_fd=3,new_path=) +<== errno=EFAULT +`, + }, + { + name: "out-of-memory reading new pathLen", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPathName: file, + oldPathLen: uint32(len(file)), + newPath: 0, + newPathLen: mod.Memory().Size() + 1, // path is in the valid memory range, but pathLen is OOM for path + expectedErrno: ErrnoFault, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=file,new_fd=3,new_path=OOM(0,65537)) +<== errno=EFAULT +`, + }, + { + name: "no such file exists", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPathName: file, + oldPathLen: uint32(len(file)) - 1, + newPath: 16, + newPathName: file, + newPathLen: uint32(len(file)), + expectedErrno: ErrnoNoent, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=fil,new_fd=3,new_path=file) +<== errno=ENOENT +`, + }, + { + name: "dir not file", + oldFd: sys.FdRoot, + newFd: sys.FdRoot, + oldPathName: file, + oldPathLen: uint32(len(file)), + newPath: 16, + newPathName: dir, + newPathLen: uint32(len(dir)), + expectedErrno: ErrnoIsdir, + expectedLog: ` +==> wasi_snapshot_preview1.path_rename(fd=3,old_path=file,new_fd=3,new_path=dir) +<== errno=EISDIR +`, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + defer log.Reset() + + mod.Memory().Write(tc.oldPath, []byte(tc.oldPathName)) + mod.Memory().Write(tc.newPath, []byte(tc.newPathName)) + + requireErrno(t, tc.expectedErrno, mod, PathRenameName, + uint64(tc.oldFd), uint64(tc.oldPath), uint64(tc.oldPathLen), + uint64(tc.newFd), uint64(tc.newPath), uint64(tc.newPathLen)) + require.Equal(t, tc.expectedLog, "\n"+log.String()) + }) + } +} + func Test_pathUnlinkFile(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. fs, err := syscallfs.NewDirFS(tmpDir) diff --git a/internal/gojs/custom/fs.go b/internal/gojs/custom/fs.go index b56f4e4286..ab2f70c66b 100644 --- a/internal/gojs/custom/fs.go +++ b/internal/gojs/custom/fs.go @@ -9,11 +9,12 @@ const ( NameFsFstat = "fstat" NameFsLstat = "lstat" NameFsClose = "close" - NameFsRead = "read" NameFsWrite = "write" + NameFsRead = "read" NameFsReaddir = "readdir" NameFsMkdir = "mkdir" NameFsRmdir = "rmdir" + NameFsRename = "rename" NameFsUnlink = "unlink" NameFsUtimes = "utimes" ) @@ -72,6 +73,11 @@ var FsNameSection = map[string]*Names{ ParamNames: []string{"path", NameCallback}, ResultNames: []string{"err", "ok"}, }, + NameFsRename: { + Name: NameFsRename, + ParamNames: []string{"from", "to", NameCallback}, + ResultNames: []string{"err", "ok"}, + }, NameFsUnlink: { Name: NameFsUnlink, ParamNames: []string{"path", NameCallback}, diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go index fd956e2941..55c341b3f1 100644 --- a/internal/gojs/fs.go +++ b/internal/gojs/fs.go @@ -36,6 +36,7 @@ var ( addFunction(custom.NameFsReaddir, &jsfsReaddir{}). addFunction(custom.NameFsMkdir, &jsfsMkdir{}). addFunction(custom.NameFsRmdir, &jsfsRmdir{}). + addFunction(custom.NameFsRename, &jsfsRename{}). addFunction(custom.NameFsUnlink, &jsfsUnlink{}). addFunction(custom.NameFsUtimes, &jsfsUtimes{}) @@ -45,7 +46,6 @@ var ( // * _, err := fsCall("chown", path, uint32(uid), uint32(gid)) // syscall.Chown // * _, err := fsCall("fchown", fd, uint32(uid), uint32(gid)) // syscall.Fchown // * _, err := fsCall("lchown", path, uint32(uid), uint32(gid)) // syscall.Lchown - // * _, err := fsCall("rename", from, to) // syscall.Rename // * _, err := fsCall("truncate", path, length) // syscall.Truncate // * _, err := fsCall("ftruncate", fd, length) // syscall.Ftruncate // * dst, err := fsCall("readlink", path) // syscall.Readlink @@ -463,6 +463,28 @@ func syscallRmdir(mod api.Module, name string) (interface{}, error) { return err != nil, err } +// jsfsRename implements the following +// +// - _, err := fsCall("rename", from, to) // syscall.Rename +type jsfsRename struct{} + +// invoke implements jsFn.invoke +func (*jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) { + from := args[0].(string) + to := args[1].(string) + callback := args[2].(funcWrapper) + + ok, err := syscallRename(mod, from, to) + return callback.invoke(ctx, mod, goos.RefJsfs, err, ok) // note: error first +} + +// syscallRename is like syscall.Rename +func syscallRename(mod api.Module, from, to string) (interface{}, error) { + fsc := mod.(*wasm.CallContext).Sys.FS() + err := fsc.Rename(from, to) + return err != nil, err +} + // jsfsUnlink implements the following // // _, err := fsCall("unlink", path) // syscall.Unlink diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go index 3b7674face..6bf6fa5684 100644 --- a/internal/gojs/testdata/writefs/main.go +++ b/internal/gojs/testdata/writefs/main.go @@ -13,6 +13,7 @@ import ( func Main() { // Create a test directory dir := path.Join(os.TempDir(), "dir") + dir1 := path.Join(os.TempDir(), "dir1") err := os.Mkdir(dir, 0o700) if err != nil { log.Panicln(err) @@ -22,6 +23,7 @@ func Main() { // Create a test file in that directory file := path.Join(dir, "file") + file1 := path.Join(os.TempDir(), "file1") err = os.WriteFile(file, []byte{}, 0o600) if err != nil { log.Panicln(err) @@ -65,24 +67,41 @@ func Main() { fmt.Println("times:", atimeSec, atimeNsec, mtimeSec, mtimeNsec) } + // Test renaming a file, noting we can't verify error numbers as they + // vary per operating system. + if err = syscall.Rename(file, dir); err == nil { + log.Panicln("expected error") + } + if err = syscall.Rename(file, file1); err != nil { + log.Panicln("unexpected error", err) + } + + // Test renaming a directory + if err = syscall.Rename(dir, file1); err == nil { + log.Panicln("expected error") + } + if err = syscall.Rename(dir, dir1); err != nil { + log.Panicln("unexpected error", err) + } + // Test unlinking a file - if err = syscall.Rmdir(file); err != syscall.ENOTDIR { + if err = syscall.Rmdir(file1); err != syscall.ENOTDIR { log.Panicln("unexpected error", err) } - if err = syscall.Unlink(file); err != nil { + if err = syscall.Unlink(file1); err != nil { log.Panicln("unexpected error", err) } // Test removing an empty directory - if err = syscall.Unlink(dir); err != syscall.EISDIR { + if err = syscall.Unlink(dir1); err != syscall.EISDIR { log.Panicln("unexpected error", err) } - if err = syscall.Rmdir(dir); err != nil { + if err = syscall.Rmdir(dir1); err != nil { log.Panicln("unexpected error", err) } // shouldn't fail - if err = os.RemoveAll(dir); err != nil { + if err = os.RemoveAll(dir1); err != nil { log.Panicln(err) return } diff --git a/internal/sys/fs.go b/internal/sys/fs.go index a9c03a3e8c..27180422db 100644 --- a/internal/sys/fs.go +++ b/internal/sys/fs.go @@ -346,6 +346,17 @@ func (c *FSContext) StatPath(name string) (fs.FileInfo, error) { return c.StatFile(fd) } +// Rename is like syscall.Rename. +func (c *FSContext) Rename(from, to string) (err error) { + if wfs, ok := c.fs.(syscallfs.FS); ok { + from = c.cleanPath(from) + to = c.cleanPath(to) + return wfs.Rename(from, to) + } + err = syscall.ENOSYS + return +} + // Unlink is like syscall.Unlink. func (c *FSContext) Unlink(name string) (err error) { if wfs, ok := c.fs.(syscallfs.FS); ok { diff --git a/internal/syscallfs/dirfs.go b/internal/syscallfs/dirfs.go index 77b1edd189..5e4e0eac02 100644 --- a/internal/syscallfs/dirfs.go +++ b/internal/syscallfs/dirfs.go @@ -17,6 +17,9 @@ func NewDirFS(dir string) (FS, error) { return dirFS(dir), nil } +// dirFS currently validates each path, which means that input paths cannot +// escape the directory, except via symlink. We may want to relax this in the +// future, especially as we decoupled from fs.FS which has this requirement. type dirFS string // Open implements the same method as documented on fs.FS @@ -43,6 +46,20 @@ func (dir dirFS) Mkdir(name string, perm fs.FileMode) error { return adjustMkdirError(err) } +// Rename implements FS.Rename +func (dir dirFS) Rename(from, to string) error { + if !fs.ValidPath(from) { + return syscall.EINVAL + } + if !fs.ValidPath(to) { + return syscall.EINVAL + } + if from == to { + return nil + } + return rename(path.Join(string(dir), from), path.Join(string(dir), to)) +} + // Rmdir implements FS.Rmdir func (dir dirFS) Rmdir(name string) error { if !fs.ValidPath(name) { diff --git a/internal/syscallfs/dirfs_test.go b/internal/syscallfs/dirfs_test.go index 8082df8377..8b782155ac 100644 --- a/internal/syscallfs/dirfs_test.go +++ b/internal/syscallfs/dirfs_test.go @@ -43,6 +43,205 @@ func TestDirFS_MkDir(t *testing.T) { }) } +func TestDirFS_Rename(t *testing.T) { + t.Run("from doesn't exist", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + file1 := "file1" + file1Path := path.Join(tmpDir, file1) + err := os.WriteFile(file1Path, []byte{1}, 0o600) + require.NoError(t, err) + + err = testFS.Rename("file2", file1) + require.Equal(t, syscall.ENOENT, err) + }) + t.Run("file to non-exist", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + file1 := "file1" + file1Path := path.Join(tmpDir, file1) + file1Contents := []byte{1} + err := os.WriteFile(file1Path, file1Contents, 0o600) + require.NoError(t, err) + + file2 := "file2" + file2Path := path.Join(tmpDir, file2) + err = testFS.Rename(file1, file2) + require.NoError(t, err) + + // Show the prior path no longer exists + _, err = os.Stat(file1Path) + require.Equal(t, syscall.ENOENT, errors.Unwrap(err)) + + s, err := os.Stat(file2Path) + require.NoError(t, err) + require.False(t, s.IsDir()) + }) + t.Run("dir to non-exist", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + dir1 := "dir1" + dir1Path := path.Join(tmpDir, dir1) + require.NoError(t, os.Mkdir(dir1Path, 0o700)) + + dir2 := "dir2" + dir2Path := path.Join(tmpDir, dir2) + err := testFS.Rename(dir1, dir2) + require.NoError(t, err) + + // Show the prior path no longer exists + _, err = os.Stat(dir1Path) + require.Equal(t, syscall.ENOENT, errors.Unwrap(err)) + + s, err := os.Stat(dir2Path) + require.NoError(t, err) + require.True(t, s.IsDir()) + }) + t.Run("dir to file", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + dir1 := "dir1" + dir1Path := path.Join(tmpDir, dir1) + require.NoError(t, os.Mkdir(dir1Path, 0o700)) + + dir2 := "dir2" + dir2Path := path.Join(tmpDir, dir2) + + // write a file to that path + err := os.WriteFile(dir2Path, []byte{2}, 0o600) + require.NoError(t, err) + + err = testFS.Rename(dir1, dir2) + if runtime.GOOS == "windows" { + require.NoError(t, err) + + // Show the directory moved + s, err := os.Stat(dir2Path) + require.NoError(t, err) + require.True(t, s.IsDir()) + } else { + require.Equal(t, syscall.ENOTDIR, err) + } + }) + t.Run("file to dir", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + file1 := "file1" + file1Path := path.Join(tmpDir, file1) + file1Contents := []byte{1} + err := os.WriteFile(file1Path, file1Contents, 0o600) + require.NoError(t, err) + + dir1 := "dir1" + dir1Path := path.Join(tmpDir, dir1) + require.NoError(t, os.Mkdir(dir1Path, 0o700)) + + err = testFS.Rename(file1, dir1) + require.Equal(t, syscall.EISDIR, err) + }) + t.Run("dir to dir", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + dir1 := "dir1" + dir1Path := path.Join(tmpDir, dir1) + require.NoError(t, os.Mkdir(dir1Path, 0o700)) + + // add a file to that directory + file1 := "file1" + file1Path := path.Join(dir1Path, file1) + file1Contents := []byte{1} + err := os.WriteFile(file1Path, file1Contents, 0o600) + require.NoError(t, err) + + dir2 := "dir2" + dir2Path := path.Join(tmpDir, dir2) + require.NoError(t, os.Mkdir(dir2Path, 0o700)) + + err = testFS.Rename(dir1, dir2) + if runtime.GOOS == "windows" { + // Windows doesn't let you overwrite an existing directory. + require.Equal(t, syscall.EINVAL, err) + return + } + require.NoError(t, err) + + // Show the prior path no longer exists + _, err = os.Stat(dir1Path) + require.Equal(t, syscall.ENOENT, errors.Unwrap(err)) + + // Show the file inside that directory moved + s, err := os.Stat(path.Join(dir2Path, file1)) + require.NoError(t, err) + require.False(t, s.IsDir()) + }) + t.Run("file to file", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + file1 := "file1" + file1Path := path.Join(tmpDir, file1) + file1Contents := []byte{1} + err := os.WriteFile(file1Path, file1Contents, 0o600) + require.NoError(t, err) + + file2 := "file2" + file2Path := path.Join(tmpDir, file2) + file2Contents := []byte{2} + err = os.WriteFile(file2Path, file2Contents, 0o600) + require.NoError(t, err) + + err = testFS.Rename(file1, file2) + require.NoError(t, err) + + // Show the prior path no longer exists + _, err = os.Stat(file1Path) + require.Equal(t, syscall.ENOENT, errors.Unwrap(err)) + + // Show the file1 overwrote file2 + b, err := os.ReadFile(file2Path) + require.NoError(t, err) + require.Equal(t, file1Contents, b) + }) + t.Run("dir to itself", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + dir1 := "dir1" + dir1Path := path.Join(tmpDir, dir1) + require.NoError(t, os.Mkdir(dir1Path, 0o700)) + + err := testFS.Rename(dir1, dir1) + require.NoError(t, err) + + s, err := os.Stat(dir1Path) + require.NoError(t, err) + require.True(t, s.IsDir()) + }) + t.Run("file to itself", func(t *testing.T) { + tmpDir := t.TempDir() + testFS := dirFS(tmpDir) + + file1 := "file1" + file1Path := path.Join(tmpDir, file1) + file1Contents := []byte{1} + err := os.WriteFile(file1Path, file1Contents, 0o600) + require.NoError(t, err) + + err = testFS.Rename(file1, file1) + require.NoError(t, err) + + b, err := os.ReadFile(file1Path) + require.NoError(t, err) + require.Equal(t, file1Contents, b) + }) +} + func TestDirFS_Rmdir(t *testing.T) { dir := t.TempDir() diff --git a/internal/syscallfs/syscall.go b/internal/syscallfs/syscall.go index 1d797f443e..fbbaf7ef1c 100644 --- a/internal/syscallfs/syscall.go +++ b/internal/syscallfs/syscall.go @@ -18,3 +18,7 @@ func adjustUnlinkError(err error) error { } return err } + +func rename(old, new string) error { + return syscall.Rename(old, new) +} diff --git a/internal/syscallfs/syscall_windows.go b/internal/syscallfs/syscall_windows.go index 8afc07d84c..c55e0664e3 100644 --- a/internal/syscallfs/syscall_windows.go +++ b/internal/syscallfs/syscall_windows.go @@ -1,7 +1,9 @@ package syscallfs import ( + "errors" "io/fs" + "os" "syscall" ) @@ -47,3 +49,32 @@ func adjustUnlinkError(err error) error { } return err } + +// rename uses os.Rename as `windows.Rename` is internal in Go's source tree. +func rename(old, new string) (err error) { + if err = os.Rename(old, new); err == nil { + return + } + err = errors.Unwrap(err) // unwrap the link error + if err == ERROR_ACCESS_DENIED { + var newIsDir bool + if stat, statErr := os.Stat(new); statErr == nil && stat.IsDir() { + newIsDir = true + } + + var oldIsDir bool + if stat, statErr := os.Stat(old); statErr == nil && stat.IsDir() { + oldIsDir = true + } + + if oldIsDir && newIsDir { + // Windows doesn't let you overwrite a directory + return syscall.EINVAL + } else if newIsDir { + err = syscall.EISDIR + } else { // use a mappable code + err = syscall.EPERM + } + } + return +} diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go index 0edacb841e..9c0eb6baa2 100644 --- a/internal/syscallfs/syscallfs.go +++ b/internal/syscallfs/syscallfs.go @@ -19,30 +19,30 @@ type FS interface { // OpenFile is similar to os.OpenFile, except the path is relative to this // file system. OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) - // ^^ TODO: Switch to syscall.Open, though this implies defining and + // ^^ TODO: Consider syscall.Open, though this implies defining and // coercing flags and perms similar to what is done in os.OpenFile. // Mkdir is similar to os.Mkdir, except the path is relative to this file // system. Mkdir(name string, perm fs.FileMode) error - // ^^ TODO: Switch to syscall.Mkdir, though this implies defining and + // ^^ TODO: Consider syscall.Mkdir, though this implies defining and // coercing flags and perms similar to what is done in os.Mkdir. - // Utimes is similar to syscall.UtimesNano, except the path is relative to - // this file system. + // Rename is similar to syscall.Rename, except the path is relative to this + // file system. // // # Errors // // The following errors are expected: - // - syscall.EINVAL: `path` is invalid. - // - syscall.ENOENT: `path` doesn't exist + // - syscall.EINVAL: `from` or `to` is invalid. + // - syscall.ENOENT: `from` or `to` don't exist. + // - syscall.ENOTDIR: `from` is a directory and `to` exists, but is a file. + // - syscall.EISDIR: `from` is a file and `to` exists, but is a directory. // // # Notes // - // - To set wall clock time, retrieve it first from sys.Walltime. - // - syscall.UtimesNano cannot change the ctime. Also, neither WASI nor - // runtime.GOOS=js support changing it. Hence, ctime it is absent here. - Utimes(path string, atimeSec, atimeNsec, mtimeSec, mtimeNsec int64) error + // - Windows doesn't let you overwrite an existing directory. + Rename(from, to string) error // Rmdir is similar to syscall.Rmdir, except the path is relative to this // file system. @@ -68,4 +68,20 @@ type FS interface { // - syscall.ENOENT: `path` doesn't exist. // - syscall.EISDIR: `path` exists, but is a directory. Unlink(path string) error + + // Utimes is similar to syscall.UtimesNano, except the path is relative to + // this file system. + // + // # Errors + // + // The following errors are expected: + // - syscall.EINVAL: `path` is invalid. + // - syscall.ENOENT: `path` doesn't exist + // + // # Notes + // + // - To set wall clock time, retrieve it first from sys.Walltime. + // - syscall.UtimesNano cannot change the ctime. Also, neither WASI nor + // runtime.GOOS=js support changing it. Hence, ctime it is absent here. + Utimes(path string, atimeSec, atimeNsec, mtimeSec, mtimeNsec int64) error }