mirror of
https://github.com/tw93/Mole.git
synced 2026-02-12 05:18:59 +00:00
feat(analyze): safer deletion with Trash and two-key confirm
- Change delete confirmation from double-delete to Delete→Enter - Move files to macOS Trash instead of permanent deletion - Allow file recovery from Trash if accidentally deleted - Update UI prompts to show 'Press Enter to confirm' - Skip Finder-dependent tests in CI environments - Update SECURITY_AUDIT.md with new safety mechanisms Closes #288
This commit is contained in:
@@ -1,19 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"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 := deletePathWithProgress(path, counter)
|
||||
count, err := trashPathWithProgress(path, counter)
|
||||
return deleteProgressMsg{
|
||||
done: true,
|
||||
err: err,
|
||||
@@ -23,20 +28,20 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// deleteMultiplePathsCmd deletes paths and aggregates results.
|
||||
// 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
|
||||
|
||||
// Delete deeper paths first to avoid parent/child conflicts.
|
||||
// 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 := deletePathWithProgress(path, counter)
|
||||
count, err := trashPathWithProgress(path, counter)
|
||||
totalCount += count
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -72,48 +77,70 @@ func (e *multiDeleteError) Error() string {
|
||||
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
|
||||
}
|
||||
|
||||
func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
||||
// 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.
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Count items for progress reporting.
|
||||
var count int64
|
||||
var firstErr error
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// Skip permission errors but continue.
|
||||
if os.IsPermission(err) {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return filepath.SkipDir
|
||||
if info.IsDir() {
|
||||
_ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
if removeErr := os.Remove(path); removeErr == nil {
|
||||
if !d.IsDir() {
|
||||
count++
|
||||
if counter != nil {
|
||||
atomic.StoreInt64(counter, count)
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
firstErr = removeErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
if removeErr := os.RemoveAll(root); removeErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = removeErr
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
count = 1
|
||||
if counter != nil {
|
||||
atomic.StoreInt64(counter, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return count, firstErr
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user