Files: Move… opens a folder picker, and a move across filesystems copies then removes
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e5d3fb2afd
commit
fb3b8e4b4c
+37
-2
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -62,6 +63,7 @@ type FS interface {
|
||||
Mkdir(ctx context.Context, path string) error
|
||||
Touch(ctx context.Context, path string) error
|
||||
Rename(ctx context.Context, from, to string) error
|
||||
Move(ctx context.Context, from, to string, progress io.Writer) error // Rename, or across filesystems a copy and a removal; long, so callers run it as a task
|
||||
Delete(ctx context.Context, path string, recursive bool) error
|
||||
Chmod(ctx context.Context, path string, perm os.FileMode) error
|
||||
}
|
||||
@@ -385,6 +387,30 @@ func (s Sudo) Touch(ctx context.Context, path string) error {
|
||||
func (s Sudo) Rename(ctx context.Context, from, to string) error {
|
||||
return s.runJSON(ctx, nil, "rename", from, to)
|
||||
}
|
||||
|
||||
// Move as root: the helper does the copying and talks on stdout.
|
||||
func (s Sudo) Move(ctx context.Context, from, to string, progress io.Writer) error {
|
||||
c := s.cmd(ctx, "move", from, to)
|
||||
c.Stdout = progress
|
||||
var errb strings.Builder
|
||||
c.Stderr = &errb
|
||||
if err := c.Run(); err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveCmd is the command a task runs for a long move: this binary's helper, as root when
|
||||
// the backend is.
|
||||
func MoveCmd(ctx context.Context, fsys FS, self, from, to string) *exec.Cmd {
|
||||
if s, ok := fsys.(Sudo); ok {
|
||||
return s.cmd(ctx, "move", from, to)
|
||||
}
|
||||
return exec.CommandContext(ctx, self, "fs", "move", from, to)
|
||||
}
|
||||
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
|
||||
if recursive {
|
||||
return s.runJSON(ctx, nil, "delete", "-r", path)
|
||||
@@ -399,7 +425,7 @@ func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
|
||||
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
|
||||
if len(args) < 2 {
|
||||
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
|
||||
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|move|delete|chmod> …"))
|
||||
}
|
||||
ctx := context.Background()
|
||||
var l Local
|
||||
@@ -452,7 +478,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
case "rename":
|
||||
case "rename", "move":
|
||||
from, err := clean(1)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
@@ -461,6 +487,15 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if op == "move" {
|
||||
// A cancelled task is a SIGTERM: stop copying and take the partial copy away.
|
||||
mctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer stop()
|
||||
if err := l.Move(mctx, from, to, stdout); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if err := l.Rename(ctx, from, to); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// IsCrossDevice reports a rename refused because the two ends are on different
|
||||
// filesystems — on a NAS that is every move between datasets. The Sudo backend only has
|
||||
// the helper's words for it.
|
||||
func IsCrossDevice(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) || (err != nil && strings.Contains(err.Error(), "cross-device"))
|
||||
}
|
||||
|
||||
// Inside reports whether to lies within from — a folder cannot be moved into itself.
|
||||
func Inside(from, to string) bool {
|
||||
return to == from || strings.HasPrefix(to, strings.TrimSuffix(from, "/")+"/")
|
||||
}
|
||||
|
||||
// Move is Rename, and across filesystems a copy and a removal. The copy is built under a
|
||||
// hidden temporary name beside where it will land and renamed into place only once it is
|
||||
// complete, and the source is removed only after that: a failure part-way, or a cancel,
|
||||
// leaves the source exactly as it was. Progress goes to w, a line at a time, for the task
|
||||
// output that a long move is watched through.
|
||||
func (Local) Move(ctx context.Context, from, to string, w io.Writer) error {
|
||||
if protected[from] {
|
||||
return errors.New("refusing to move " + from)
|
||||
}
|
||||
if Inside(from, to) {
|
||||
return errors.New("a folder can't be moved into itself")
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
return errors.New("destination exists")
|
||||
}
|
||||
err := os.Rename(from, to)
|
||||
if err == nil || !IsCrossDevice(err) {
|
||||
return err
|
||||
}
|
||||
return moveByCopy(ctx, from, to, w)
|
||||
}
|
||||
|
||||
// moveByCopy is the slow path: copy, rename into place, remove the original.
|
||||
func moveByCopy(ctx context.Context, from, to string, w io.Writer) error {
|
||||
st, err := os.Lstat(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(to), "."+filepath.Base(to)+".hamlab-moving")
|
||||
_ = os.RemoveAll(tmp) // a previous attempt that died
|
||||
fmt.Fprintf(w, "%s is on another filesystem: copying, then removing the original\n", filepath.Dir(to))
|
||||
p := &progress{w: w}
|
||||
if err := copyPath(ctx, from, tmp, st, p); err != nil {
|
||||
fmt.Fprintln(w, "copy failed; removing the partial copy, the original is untouched")
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, to); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "copied %s; removing the original\n", p.String())
|
||||
if err := os.RemoveAll(from); err != nil {
|
||||
return fmt.Errorf("copied to %s, but the original could not be removed: %w", to, err)
|
||||
}
|
||||
fmt.Fprintf(w, "moved %s to %s\n", from, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
type progress struct {
|
||||
w io.Writer
|
||||
files int64
|
||||
bytes int64
|
||||
shownFiles int64
|
||||
shownBytes int64
|
||||
lastPrinted bool
|
||||
}
|
||||
|
||||
func (p *progress) add(files, bytes int64) {
|
||||
p.files += files
|
||||
p.bytes += bytes
|
||||
if p.files-p.shownFiles >= 500 || p.bytes-p.shownBytes >= 512<<20 {
|
||||
fmt.Fprintf(p.w, "copying… %s\n", p.String())
|
||||
p.shownFiles, p.shownBytes = p.files, p.bytes
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progress) String() string {
|
||||
return fmt.Sprintf("%d files, %s", p.files, fmtSize(p.bytes))
|
||||
}
|
||||
|
||||
func fmtSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// copyPath copies one entry of any kind, recursively for a directory, keeping mode, times
|
||||
// and — when running as root — ownership. Devices, pipes and sockets are refused: a copy
|
||||
// of one is not the thing itself.
|
||||
func copyPath(ctx context.Context, src, dst string, st os.FileInfo, p *progress) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := st.Mode()
|
||||
switch {
|
||||
case mode&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
chown(dst, st)
|
||||
p.add(1, 0)
|
||||
return nil
|
||||
case mode.IsDir():
|
||||
if err := os.Mkdir(dst, mode.Perm()|0o700); err != nil { // writable while it fills, exact mode after
|
||||
return err
|
||||
}
|
||||
ents, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range ents {
|
||||
lst, err := e.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyPath(ctx, filepath.Join(src, e.Name()), filepath.Join(dst, e.Name()), lst, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, mode.Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
case mode.IsRegular():
|
||||
n, err := copyFile(src, dst, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.add(1, n)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s: can't copy a device, pipe or socket", src)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, st os.FileInfo) (int64, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode().Perm())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(out, in)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return n, err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return n, err
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, st.Mode().Perm()); err != nil { // umask has no say
|
||||
return n, err
|
||||
}
|
||||
return n, os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
}
|
||||
|
||||
// chown keeps the owner when this process may set one; an ordinary user's copy is the
|
||||
// user's, which is what a rename would have left anyway.
|
||||
func chown(dst string, st os.FileInfo) {
|
||||
if os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Lchown(dst, int(sys.Uid), int(sys.Gid))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user