mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 21:20:09 +00:00
- Add TestValidatePath covering Chinese, emoji, and special characters - Add TestValidatePathWithChineseAndSpecialChars for filesystem tests - Fix validatePath to detect .. components without rejecting valid paths Ensures paths with $, ;, :, emoji, Chinese chars are not rejected while still blocking path traversal attempts.
175 lines
4.2 KiB
Go
175 lines
4.2 KiB
Go
//go:build darwin
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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 {
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve path: %w", err)
|
|
}
|
|
|
|
// Validate path to prevent path traversal attacks.
|
|
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).
|
|
for _, component := range strings.Split(path, string(filepath.Separator)) {
|
|
if component == ".." {
|
|
return fmt.Errorf("path contains traversal components: %s", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|