166 lines
4.4 KiB
Go
166 lines
4.4 KiB
Go
package fileops
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// RemoveAllUnderRoot removes target and its contents only after opening root and
|
|
// every target ancestor without following symbolic links. target must name a
|
|
// proper descendant of root. Symlinks are rejected rather than followed or
|
|
// removed so a failed cleanup never silently changes its scope.
|
|
func RemoveAllUnderRoot(rootPath, target string) error {
|
|
root, targetName, err := openConfinedCleanupTarget(rootPath, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
return removeConfinedEntry(root, targetName)
|
|
}
|
|
|
|
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
|
|
if strings.TrimSpace(rootPath) == "" {
|
|
return nil, "", fmt.Errorf("cleanup root is required")
|
|
}
|
|
if strings.TrimSpace(target) == "" {
|
|
return nil, "", fmt.Errorf("cleanup target is required")
|
|
}
|
|
rootAbs, err := filepath.Abs(rootPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve cleanup root: %w", err)
|
|
}
|
|
targetAbs, err := filepath.Abs(target)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve cleanup target: %w", err)
|
|
}
|
|
relative, err := filepath.Rel(rootAbs, targetAbs)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve cleanup target below root: %w", err)
|
|
}
|
|
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return nil, "", fmt.Errorf("cleanup target %q must be below root %q", targetAbs, rootAbs)
|
|
}
|
|
parts, err := relativePathParts(relative)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
root, err := openConfinedDirectory(rootAbs)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for _, part := range parts[:len(parts)-1] {
|
|
child, err := openConfinedChild(root, part, false, 0)
|
|
if err != nil {
|
|
_ = root.Close()
|
|
return nil, "", err
|
|
}
|
|
_ = root.Close()
|
|
root = child
|
|
}
|
|
return root, parts[len(parts)-1], nil
|
|
}
|
|
|
|
func openConfinedDirectory(path string) (*os.Root, error) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve directory: %w", err)
|
|
}
|
|
parts, err := absolutePathParts(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootPath := filesystemRoot(abs)
|
|
root, err := os.OpenRoot(rootPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open filesystem root %q: %w", rootPath, err)
|
|
}
|
|
for _, part := range parts {
|
|
child, err := openConfinedChild(root, part, false, 0)
|
|
if err != nil {
|
|
_ = root.Close()
|
|
return nil, err
|
|
}
|
|
_ = root.Close()
|
|
root = child
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
func relativePathParts(path string) ([]string, error) {
|
|
parts := strings.FieldsFunc(path, func(r rune) bool { return r == filepath.Separator || r == '/' || r == '\\' })
|
|
if len(parts) == 0 {
|
|
return nil, fmt.Errorf("cleanup target is required")
|
|
}
|
|
for _, part := range parts {
|
|
if part == "" || part == "." || part == ".." {
|
|
return nil, fmt.Errorf("unsafe cleanup path component %q", part)
|
|
}
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func removeConfinedEntry(parent *os.Root, name string) error {
|
|
info, err := parent.Lstat(name)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect cleanup entry %q: %w", name, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing to delete symlink path %q", name)
|
|
}
|
|
if !info.IsDir() {
|
|
if err := parent.Remove(name); err != nil {
|
|
return fmt.Errorf("remove cleanup entry %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
child, err := openConfinedChild(parent, name, false, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = removeConfinedChildren(child)
|
|
closeErr := child.Close()
|
|
if err != nil {
|
|
if closeErr != nil {
|
|
return errors.Join(err, closeErr)
|
|
}
|
|
return err
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close cleanup directory %q: %w", name, closeErr)
|
|
}
|
|
if err := parent.Remove(name); err != nil {
|
|
return fmt.Errorf("remove cleanup directory %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeConfinedChildren(root *os.Root) error {
|
|
directory, err := root.Open(".")
|
|
if err != nil {
|
|
return fmt.Errorf("open cleanup directory: %w", err)
|
|
}
|
|
entries, readErr := directory.ReadDir(-1)
|
|
closeErr := directory.Close()
|
|
if readErr != nil {
|
|
return fmt.Errorf("read cleanup directory: %w", readErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close cleanup directory: %w", closeErr)
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
for _, entry := range entries {
|
|
if err := removeConfinedEntry(root, entry.Name()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|