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 }