From the race detector, staticcheck, a garbage-in battery over every safe route and a
headless pass through every tab on two machines. Fixed:
- docker inspect/health of a container that isn't there answered 500; now 404, and a
daemon that is down 503 (dockerErr, with a test).
- /api/virt/instances/{name} on a machine without Incus answered 502 "incus not found";
it now says Incus isn't ready, like its siblings.
- Deleting a ZFS snapshot with a bad name answered 500; a bad name is 400, a snapshot
that doesn't exist 404.
- The task list read a task's Running flag without the task's lock while the waiter
goroutine cleared it: a data race, now read under the lock.
- The Virtualization export config and run record were written in place; a crash
mid-write would have left a half file. Atomic now, like the config and state files.
- An unused function in kvm.go, one in incus/subnet.go, an unused field in files/move.go
and a capitalised error string, from staticcheck.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
198 lines
5.4 KiB
Go
198 lines
5.4 KiB
Go
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))
|
|
}
|
|
}
|