mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 23:40:09 +00:00
filepath.Abs resolves ".." components, so the existing validatePath call on the resolved path could never catch traversal attempts. Move validation before Abs to reject raw input with "..", keeping the post-Abs check as defense-in-depth.
179 lines
4.3 KiB
Go
179 lines
4.3 KiB
Go
//go:build darwin
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
const trashTimeout = 30 * time.Second
|
|
|
|
func deletePathCmd(path string, counter *int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
count, err := trashPathWithProgress(path, counter)
|
|
return deleteProgressMsg{
|
|
done: true,
|
|
err: err,
|
|
count: count,
|
|
path: path,
|
|
}
|
|
}
|
|
}
|
|
|
|
// deleteMultiplePathsCmd moves paths to Trash and aggregates results.
|
|
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
var totalCount int64
|
|
var errors []string
|
|
|
|
// Process deeper paths first to avoid parent/child conflicts.
|
|
pathsToDelete := append([]string(nil), paths...)
|
|
sort.Slice(pathsToDelete, func(i, j int) bool {
|
|
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
|
|
})
|
|
|
|
for _, path := range pathsToDelete {
|
|
count, err := trashPathWithProgress(path, counter)
|
|
totalCount += count
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
errors = append(errors, err.Error())
|
|
}
|
|
}
|
|
|
|
var resultErr error
|
|
if len(errors) > 0 {
|
|
resultErr = &multiDeleteError{errors: errors}
|
|
}
|
|
|
|
return deleteProgressMsg{
|
|
done: true,
|
|
err: resultErr,
|
|
count: totalCount,
|
|
path: "",
|
|
}
|
|
}
|
|
}
|
|
|
|
// multiDeleteError holds multiple deletion errors.
|
|
type multiDeleteError struct {
|
|
errors []string
|
|
}
|
|
|
|
func (e *multiDeleteError) Error() string {
|
|
if len(e.errors) == 1 {
|
|
return e.errors[0]
|
|
}
|
|
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
|
|
}
|
|
|
|
// trashPathWithProgress moves a path to Trash using Finder.
|
|
// This allows users to recover accidentally deleted files.
|
|
func trashPathWithProgress(root string, counter *int64) (int64, error) {
|
|
// Verify path exists (use Lstat to handle broken symlinks).
|
|
info, err := os.Lstat(root)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Count items for progress reporting.
|
|
var count int64
|
|
if info.IsDir() {
|
|
_ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if !d.IsDir() {
|
|
count++
|
|
if counter != nil {
|
|
atomic.StoreInt64(counter, count)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
} else {
|
|
count = 1
|
|
if counter != nil {
|
|
atomic.StoreInt64(counter, 1)
|
|
}
|
|
}
|
|
|
|
// Move to Trash using Finder AppleScript.
|
|
if err := moveToTrash(root); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// moveToTrash uses macOS Finder to move a file/directory to Trash.
|
|
// This is the safest method as it uses the system's native trash mechanism.
|
|
func moveToTrash(path string) error {
|
|
// Validate raw input before Abs resolves ".." components away.
|
|
if err := validatePath(path); err != nil {
|
|
return err
|
|
}
|
|
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve path: %w", err)
|
|
}
|
|
|
|
// Validate resolved path as well (defense-in-depth).
|
|
if err := validatePath(absPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Escape path for AppleScript (handle quotes and backslashes).
|
|
escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\")
|
|
escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"")
|
|
|
|
script := fmt.Sprintf(`tell application "Finder" to delete POSIX file "%s"`, escapedPath)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), trashTimeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return fmt.Errorf("timeout moving to Trash")
|
|
}
|
|
return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output)))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validatePath checks path safety for external commands.
|
|
// Returns error if path is empty, relative, contains null bytes, or has traversal.
|
|
func validatePath(path string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("path is empty")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("path must be absolute: %s", path)
|
|
}
|
|
if strings.Contains(path, "\x00") {
|
|
return fmt.Errorf("path contains null bytes")
|
|
}
|
|
// Check for path traversal attempts (.. components).
|
|
if slices.Contains(strings.Split(path, string(filepath.Separator)), "..") {
|
|
return fmt.Errorf("path contains traversal components: %s", path)
|
|
}
|
|
return nil
|
|
}
|