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 } 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)) } }