package storage import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" "sort" "strings" "syscall" "time" "github.com/Bios-Marcel/wastebasket" ) type Trashable interface { Trash(path string) error MoveToTrash(path, trashRoot string) error } type Storage interface { Stat(path string) (os.FileInfo, error) Open(path string) (*os.File, error) Create(path string) (*os.File, error) Remove(path string) error Rename(oldPath, newPath string) error MkdirAll(path string) error List(path string) ([]os.FileInfo, error) } type Local struct{ Root string } func (s Local) real(path string) (string, error) { root, err := filepath.Abs(s.Root) if err != nil { return "", err } root, err = filepath.EvalSymlinks(root) if err != nil { return "", err } p := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator)) full := filepath.Join(root, p) rel, err := filepath.Rel(root, full) if err != nil { return "", err } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", errors.New("path escapes storage root") } check := full for { if _, err = os.Lstat(check); !os.IsNotExist(err) { break } next := filepath.Dir(check) if next == check { break } check = next } resolved, err := filepath.EvalSymlinks(check) if err != nil { return "", err } resolvedRel, err := filepath.Rel(root, resolved) if err != nil || resolvedRel == ".." || strings.HasPrefix(resolvedRel, ".."+string(filepath.Separator)) { return "", errors.New("path escapes storage root") } return full, nil } func (s Local) Stat(path string) (os.FileInfo, error) { p, e := s.real(path) if e != nil { return nil, e } return os.Stat(p) } func (s Local) Open(path string) (*os.File, error) { p, e := s.real(path) if e != nil { return nil, e } return os.Open(p) } func (s Local) Create(path string) (*os.File, error) { p, e := s.real(path) if e != nil { return nil, e } if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil { return nil, e } return os.Create(p) } func (s Local) Remove(path string) error { p, e := s.real(path) if e != nil { return e } return os.RemoveAll(p) } func (s Local) Trash(path string) error { p, e := s.real(path) if e != nil { return e } return wastebasket.Trash(p) } func (s Local) MoveToTrash(path, trashRoot string) error { p, e := s.real(path) if e != nil { return e } root, e := filepath.Abs(s.Root) if e != nil { return e } rel, e := filepath.Rel(root, p) if e != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return errors.New("path escapes storage root") } if e = os.MkdirAll(trashRoot, 0755); e != nil { return e } // Preserve the original relative path under the CHFS trash root so // identically named files from different directories cannot collide. target := filepath.Join(trashRoot, rel) if e = os.MkdirAll(filepath.Dir(target), 0755); e != nil { return e } for i := 1; ; i++ { if _, e = os.Lstat(target); os.IsNotExist(e) { break } target = filepath.Join(filepath.Dir(target), fmt.Sprintf("%s.%d", filepath.Base(p), i)) } if e = movePath(p, target); e != nil { return e } // Cleanup is maintenance work; a successful move must not be reported as // a failed delete merely because an unrelated stale trash entry is busy. _ = cleanupTrash(trashRoot, 30*24*time.Hour) return nil } func cleanupTrash(root string, maxAge time.Duration) error { now := time.Now() return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if path == root || d.IsDir() { return nil } info, err := d.Info() if err != nil { return err } if now.Sub(info.ModTime()) > maxAge { return os.Remove(path) } return nil }) } func movePath(src, dst string) error { if err := os.Rename(src, dst); err == nil { return nil } else if !errors.Is(err, syscall.EXDEV) { return err } if _, err := os.Lstat(dst); err == nil { return fmt.Errorf("destination already exists: %s", dst) } else if !os.IsNotExist(err) { return err } if err := copyPath(src, dst); err != nil { return err } return os.RemoveAll(src) } func copyPath(src, dst string) error { info, err := os.Lstat(src) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(src) if err != nil { return err } return os.Symlink(target, dst) } if info.IsDir() { if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { if err := copyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { return err } } return os.Chtimes(dst, info.ModTime(), info.ModTime()) } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) if err != nil { return err } _, copyErr := io.Copy(out, in) closeErr := out.Close() if copyErr != nil { _ = os.Remove(dst) return copyErr } if closeErr != nil { _ = os.Remove(dst) return closeErr } return os.Chtimes(dst, info.ModTime(), info.ModTime()) } func (s Local) Rename(a, b string) error { pa, e := s.real(a) if e != nil { return e } pb, e := s.real(b) if e != nil { return e } return movePath(pa, pb) } func (s Local) MkdirAll(path string) error { p, e := s.real(path) if e != nil { return e } return os.MkdirAll(p, 0755) } func (s Local) List(path string) ([]os.FileInfo, error) { p, e := s.real(path) if e != nil { return nil, e } f, e := os.Open(p) if e != nil { return nil, e } defer f.Close() items, err := f.Readdir(-1) if err != nil { return nil, err } sort.Slice(items, func(i, j int) bool { return items[i].Name() < items[j].Name() }) return items, nil } var _ fs.FS