The picker walks the machine like the tab does — back, forward, up, breadcrumbs, a path box, a new folder — and "Move here" puts the file or folder in the folder being looked at; it says why it can't when it can't (already here, into itself, a name clash). POST /api/files/move is a rename when the two ends share a filesystem and otherwise a task: on a NAS every move between datasets is a different filesystem, and a rename there is EXDEV. The copy is built under a hidden temporary name beside its destination, renamed into place once complete, and only then is the original removed, so a failure or a cancel leaves the original as it was. Modes, times, symlinks and, as root, owners are kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package files
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInside(t *testing.T) {
|
|
for _, c := range []struct {
|
|
from, to string
|
|
want bool
|
|
}{
|
|
{"/a/b", "/a/b", true}, {"/a/b", "/a/b/c", true}, {"/a/b", "/a/bc", false}, {"/a/b", "/a", false}, {"/", "/x", true},
|
|
} {
|
|
if got := Inside(c.from, c.to); got != c.want {
|
|
t.Errorf("Inside(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsCrossDevice(t *testing.T) {
|
|
if !IsCrossDevice(&os.LinkError{Op: "rename", Old: "/a", New: "/b", Err: syscall.EXDEV}) {
|
|
t.Fatal("the kernel's EXDEV")
|
|
}
|
|
if !IsCrossDevice(errors.New("rename /a /b: invalid cross-device link")) {
|
|
t.Fatal("the helper's words for it")
|
|
}
|
|
if IsCrossDevice(errors.New("permission denied")) || IsCrossDevice(nil) {
|
|
t.Fatal("anything else is not")
|
|
}
|
|
}
|
|
|
|
// The copy path, exercised directly: a tree with nested folders, a symlink, an odd mode
|
|
// and old mtimes lands whole under the new name, and the original is gone only then.
|
|
func TestMoveByCopy(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src", "album")
|
|
if err := os.MkdirAll(filepath.Join(src, "sub", "deep"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
write := func(p, s string, mode os.FileMode) {
|
|
if err := os.WriteFile(p, []byte(s), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.Chmod(p, mode)
|
|
_ = os.Chtimes(p, old, old)
|
|
}
|
|
write(filepath.Join(src, "a.txt"), "hello", 0o644)
|
|
write(filepath.Join(src, "sub", "deep", "b.bin"), strings.Repeat("x", 100000), 0o600)
|
|
write(filepath.Join(src, "run.sh"), "#!/bin/sh\n", 0o755)
|
|
if err := os.Symlink("a.txt", filepath.Join(src, "link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.Chtimes(filepath.Join(src, "sub"), old, old)
|
|
|
|
dst := filepath.Join(root, "dst", "moved")
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out strings.Builder
|
|
if err := moveByCopy(context.Background(), src, dst, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
|
t.Fatal("the original is still there")
|
|
}
|
|
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "hello" {
|
|
t.Fatal("a.txt")
|
|
}
|
|
if st, err := os.Stat(filepath.Join(dst, "sub", "deep", "b.bin")); err != nil || st.Size() != 100000 || st.Mode().Perm() != 0o600 || !st.ModTime().Equal(old) {
|
|
t.Fatalf("b.bin: %v %v", st, err)
|
|
}
|
|
if st, _ := os.Stat(filepath.Join(dst, "run.sh")); st.Mode().Perm() != 0o755 {
|
|
t.Fatal("mode lost")
|
|
}
|
|
if target, err := os.Readlink(filepath.Join(dst, "link")); err != nil || target != "a.txt" {
|
|
t.Fatal("symlink not copied as a symlink")
|
|
}
|
|
if st, _ := os.Stat(filepath.Join(dst, "sub")); !st.ModTime().Equal(old) {
|
|
t.Fatal("folder mtime lost")
|
|
}
|
|
if left, _ := filepath.Glob(filepath.Join(root, "dst", ".*hamlab-moving*")); len(left) != 0 {
|
|
t.Fatal("temporary copy left behind")
|
|
}
|
|
if !strings.Contains(out.String(), "moved ") || !strings.Contains(out.String(), "4 files") {
|
|
t.Fatalf("progress said: %q", out.String())
|
|
}
|
|
}
|
|
|
|
// A failure part-way leaves the original as it was and no partial copy.
|
|
func TestMoveByCopyFailsClean(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src")
|
|
if err := os.MkdirAll(src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(src, "a"), []byte("a"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := syscall.Mkfifo(filepath.Join(src, "pipe"), 0o644); err != nil {
|
|
t.Skip("no fifos here")
|
|
}
|
|
dst := filepath.Join(root, "dst")
|
|
err := moveByCopy(context.Background(), src, dst, &strings.Builder{})
|
|
if err == nil || !strings.Contains(err.Error(), "pipe") {
|
|
t.Fatalf("expected the pipe to be refused, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
|
t.Fatal("the original was touched")
|
|
}
|
|
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
|
|
t.Fatal("a partial copy was left")
|
|
}
|
|
if left, _ := filepath.Glob(filepath.Join(root, ".*hamlab-moving*")); len(left) != 0 {
|
|
t.Fatal("temporary copy left behind")
|
|
}
|
|
// a cancel is the same story
|
|
if err := os.Remove(filepath.Join(src, "pipe")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := moveByCopy(ctx, src, dst, &strings.Builder{}); err == nil {
|
|
t.Fatal("a cancelled move must fail")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
|
t.Fatal("the original was touched by a cancel")
|
|
}
|
|
}
|
|
|
|
// Move on one filesystem is a rename: nothing is copied, and the guards hold.
|
|
func TestMoveSameFilesystem(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "f")
|
|
if err := os.WriteFile(src, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var l Local
|
|
var out strings.Builder
|
|
if err := l.Move(context.Background(), src, filepath.Join(root, "g"), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatal("a rename has nothing to say")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "g")); err != nil {
|
|
t.Fatal("not renamed")
|
|
}
|
|
if err := l.Move(context.Background(), root, filepath.Join(root, "inside"), &out); err == nil {
|
|
t.Fatal("into itself")
|
|
}
|
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "g"), &out); err == nil {
|
|
t.Fatal("onto itself")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "h"), nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "h"), &out); err == nil || !strings.Contains(err.Error(), "exists") {
|
|
t.Fatalf("onto an existing file: %v", err)
|
|
}
|
|
}
|