diff --git a/go.mod b/go.mod index af37fbc..4c2d90e 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/ijt/go-anytime v1.9.2 github.com/lithammer/fuzzysearch v1.1.8 + github.com/moby/sys/mountinfo v0.7.2 github.com/urfave/cli/v2 v2.27.3 gitlab.com/tymonx/go-formatter v1.5.1 golang.org/x/term v0.22.0 diff --git a/go.sum b/go.sum index b60018b..4022b7e 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/files/trash.go b/internal/files/trash.go index 0b038e1..8151506 100644 --- a/internal/files/trash.go +++ b/internal/files/trash.go @@ -5,7 +5,9 @@ import ( "io/fs" "math/rand" "os" + "os/user" "path/filepath" + "slices" "strings" "time" @@ -13,16 +15,20 @@ import ( "git.burning.moe/celediel/gt/internal/filter" "git.burning.moe/celediel/gt/internal/prompt" + "github.com/adrg/xdg" "github.com/charmbracelet/log" "github.com/dustin/go-humanize" + "github.com/moby/sys/mountinfo" "gitlab.com/tymonx/go-formatter/formatter" "gopkg.in/ini.v1" ) const ( + sep = string(os.PathSeparator) executePerm = fs.FileMode(0755) noExecuteUserPerm = fs.FileMode(0600) randomStrLength int = 8 + trashName string = ".Trash" trashInfoExt string = ".trashinfo" trashInfoSec string = "Trash Info" trashInfoPath string = "Path" @@ -61,6 +67,26 @@ func (t TrashInfo) String() string { return t.name + t.path + t.ogpath + t.trashinfo } +func FindInAllTrashes(ogdir string, fltr *filter.Filter) (Files, error) { + var files Files + + personalTrash := filepath.Join(xdg.DataHome, "Trash") + + if fls, err := FindTrash(personalTrash, ogdir, fltr); err == nil { + files = append(files, fls...) + } + + for _, trash := range getAllTrashes() { + fls, err := FindTrash(trash, ogdir, fltr) + if err != nil { + continue + } + files = append(files, fls...) + } + + return files, nil +} + func FindTrash(trashdir, ogdir string, fltr *filter.Filter) (Files, error) { log.Debugf("searching for trashinfo files in %s", trashdir) var files Files @@ -215,60 +241,58 @@ func ConfirmClean(confirm bool, fs Files) error { return nil } -func TrashFile(trashDir, name string) error { - trashinfoFilename, outPath := ensureUniqueName(filepath.Base(name), trashDir) +func TrashFile(filename string) error { + trashDir, err := getTrashDir(filename) + if err != nil { + return err + } - if err := os.Rename(name, outPath); err != nil { - if strings.Contains(err.Error(), "invalid cross-device link") { - // TODO: use $topdir/.Trash as per XDG spec - // TODO: maybe figure out if filesystem is truly different or is a btrfs subvolume - return err - } + trashInfoFilename, outPath := getTrashFilenames(filepath.Base(filename), trashDir) + + if err := os.Rename(filename, outPath); err != nil { return err } trashInfo, err := formatter.Format(trashInfoTemplate, formatter.Named{ - "path": name, + "path": filename, "date": time.Now().Format(trashInfoDateFmt), }) if err != nil { return err } - if err := os.WriteFile(trashinfoFilename, []byte(trashInfo), noExecuteUserPerm); err != nil { + if err := os.WriteFile(trashInfoFilename, []byte(trashInfo), noExecuteUserPerm); err != nil { return err } + return nil } -func TrashFiles(trashDir string, files ...string) (trashed int) { +func TrashFiles(files []string) (trashed int) { for _, file := range files { - if err := TrashFile(trashDir, file); err != nil { + if err := TrashFile(file); err != nil { log.Errorf("cannot trash '%s': %s", file, err) continue } trashed++ } - return trashed + return } -func ConfirmTrash(confirm bool, fs Files, trashDir string) error { +func ConfirmTrash(confirm bool, fs Files) error { if !confirm || prompt.YesNo(fmt.Sprintf("trash %d selected files?", len(fs))) { tfs := make([]string, 0, len(fs)) for _, file := range fs { - log.Debugf("gonna trash %s", file.Path()) tfs = append(tfs, file.Path()) } - trashed := TrashFiles(trashDir, tfs...) + trashed := TrashFiles(tfs) - var files string - if trashed == 1 { - files = "file" - } else { - files = "files" + var s string + if trashed > 1 { + s = "s" } - fmt.Fprintf(os.Stdout, "trashed %d %s\n", trashed, files) + fmt.Fprintf(os.Stdout, "trashed %d file%s\n", trashed, s) } else { fmt.Fprintf(os.Stdout, "not doing anything\n") return nil @@ -276,7 +300,7 @@ func ConfirmTrash(confirm bool, fs Files, trashDir string) error { return nil } -func randomFilename(length int) string { +func randomString(length int) string { out := strings.Builder{} for range length { out.WriteByte(randomChar()) @@ -289,7 +313,7 @@ func randomChar() byte { return chars[rand.Intn(len(chars))] } -func ensureUniqueName(filename, trashDir string) (string, string) { +func getTrashFilenames(filename, trashDir string) (string, string) { var ( filedir = filepath.Join(trashDir, "files") infodir = filepath.Join(trashDir, "info") @@ -307,12 +331,102 @@ func ensureUniqueName(filename, trashDir string) (string, string) { var tries int for { tries++ - rando := randomFilename(randomStrLength) - newName := filepath.Join(infodir, filename+rando+trashInfoExt) - if _, err := os.Stat(newName); os.IsNotExist(err) { + rando := randomString(randomStrLength) + newInfo := filepath.Join(infodir, filename+rando+trashInfoExt) + newFile := filepath.Join(filedir, filename+rando) + _, infoErr := os.Stat(newInfo) + _, fileErr := os.Stat(newFile) + if os.IsNotExist(infoErr) && os.IsNotExist(fileErr) { path := filepath.Join(filedir, filename+rando) log.Debugf("settled on random name %s%s on the %s try", filename, rando, humanize.Ordinal(tries)) - return newName, path + return newInfo, path } } } + +func getTrashDir(filename string) (string, error) { + root, err := getRoot(filename) + if err != nil { + return "", err + } + + var trashDir string + if strings.Contains(filename, xdg.Home) { + trashDir = filepath.Join(xdg.DataHome, trashName[1:]) + } else { + trashDir = filepath.Clean(root + sep + trashName) + } + + if _, err := os.Lstat(trashDir); err != nil { + usr, _ := user.Current() + trashDir += "-" + usr.Uid + if err := os.Mkdir(trashDir, executePerm); err != nil { + return "", fmt.Errorf("%s%s does not exist and creation of %s failed", root, trashName, trashDir) + } + } + + if link, err := os.Readlink(trashDir); err == nil && link != "" { + return "", fmt.Errorf("trash dir %s is a symbolic link", trashDir) + } + + return trashDir, nil +} + +func getRoot(path string) (string, error) { + var roots []string + + // populate a list of mountpoints on the system + _, err := mountinfo.GetMounts(func(i *mountinfo.Info) (skip bool, stop bool) { + roots = append(roots, i.Mountpoint) + return false, false + }) + if err != nil { + log.Error(err) + } + + var depth uint8 = 1 // 255 seems a reasonable recursion maximum + current := path + + // recursively search upwards by using filepath.Clean and .. + for { + if depth == 0 { + return path, fmt.Errorf("reached max depth getting root of %s", path) + } + + current = filepath.Clean(current) + + if current == string(os.PathSeparator) || slices.Contains(roots, current) { + return current, nil + } + + current += string(os.PathSeparator) + ".." + depth++ + } +} + +func getAllTrashes() []string { + var trashes []string + usr, _ := user.Current() + + _, err := mountinfo.GetMounts(func(mount *mountinfo.Info) (skip bool, stop bool) { + point := mount.Mountpoint + trashDir := filepath.Clean(point + sep + trashName) + userTrashDir := trashDir + "-" + usr.Uid + + if _, err := os.Lstat(trashDir); err == nil { + trashes = append(trashes, trashDir) + } + + if _, err := os.Lstat(userTrashDir); err == nil { + trashes = append(trashes, userTrashDir) + } + + return false, false + }) + + if err != nil { + return []string{} + } + + return trashes +} diff --git a/internal/interactive/interactive.go b/internal/interactive/interactive.go index a5c9fa9..5d5bf51 100644 --- a/internal/interactive/interactive.go +++ b/internal/interactive/interactive.go @@ -86,8 +86,8 @@ type model struct { fltrfiles files.Files } -func newModel(fls []files.File, selectall, readonly, once bool, workdir string, mode modes.Mode) (m model) { - m = model{ +func newModel(fls []files.File, selectall, readonly, once bool, workdir string, mode modes.Mode) model { + m := model{ keys: defaultKeyMap(), readonly: readonly, once: once, @@ -116,7 +116,7 @@ func newModel(fls []files.File, selectall, readonly, once bool, workdir string, m.selectAll() } - return + return m } type keyMap struct { diff --git a/main.go b/main.go index 04a33fd..9dbbea1 100644 --- a/main.go +++ b/main.go @@ -34,8 +34,8 @@ See gt(1) for more information.` ) var ( - loglvl string fltr *filter.Filter + loglvl string onArg, beforeArg, afterArg string globArg, patternArg string unGlobArg, unPatternArg string @@ -46,8 +46,6 @@ var ( workdir, ogdir cli.Path recursive bool - trashDir = filepath.Join(xdg.DataHome, "Trash") - beforeAll = func(_ *cli.Context) error { // setup log log.SetReportTimestamp(true) @@ -62,19 +60,15 @@ var ( log.Errorf("unknown log level '%s' (possible values: debug, info, warn, error, fatal, default: warn)", loglvl) } - // ensure trash directories exist - if _, e := os.Stat(trashDir); os.IsNotExist(e) { - if err := os.Mkdir(trashDir, executePerm); err != nil { + // ensure personal trash directories exist + homeTrash := filepath.Join(xdg.DataHome, "Trash") + if _, e := os.Stat(filepath.Join(homeTrash, "info")); os.IsNotExist(e) { + if err := os.MkdirAll(filepath.Join(homeTrash, "info"), executePerm); err != nil { return err } } - if _, e := os.Stat(filepath.Join(trashDir, "info")); os.IsNotExist(e) { - if err := os.Mkdir(filepath.Join(trashDir, "info"), executePerm); err != nil { - return err - } - } - if _, e := os.Stat(filepath.Join(trashDir, "files")); os.IsNotExist(e) { - if err := os.Mkdir(filepath.Join(trashDir, "files"), executePerm); err != nil { + if _, e := os.Stat(filepath.Join(homeTrash, "files")); os.IsNotExist(e) { + if err := os.MkdirAll(filepath.Join(homeTrash, "files"), executePerm); err != nil { return err } } @@ -107,7 +101,8 @@ var ( mode modes.Mode err error ) - infiles, err = files.FindTrash(trashDir, ogdir, fltr) + + infiles, err = files.FindInAllTrashes(ogdir, fltr) if err != nil { return err } @@ -158,7 +153,7 @@ var ( } filesToTrash = append(filesToTrash, file) } - return files.ConfirmTrash(askconfirm, filesToTrash, trashDir) + return files.ConfirmTrash(askconfirm, filesToTrash) } beforeCommands = func(ctx *cli.Context) (err error) { @@ -231,7 +226,7 @@ var ( return nil } - return files.ConfirmTrash(askconfirm, selected, trashDir) + return files.ConfirmTrash(askconfirm, selected) }, } @@ -242,9 +237,7 @@ var ( Flags: slices.Concat(listFlags, trashedFlags, filterFlags), Before: beforeCommands, Action: func(_ *cli.Context) error { - log.Debugf("searching in directory %s for files", trashDir) - - fls, err := files.FindTrash(trashDir, ogdir, fltr) + fls, err := files.FindInAllTrashes(ogdir, fltr) var msg string log.Debugf("filter '%s' is blank? %t in %s", fltr, fltr.Blank(), ogdir) @@ -273,9 +266,7 @@ var ( Flags: slices.Concat(cleanRestoreFlags, trashedFlags, filterFlags), Before: beforeCommands, Action: func(_ *cli.Context) error { - log.Debugf("searching in directory %s for files", trashDir) - - fls, err := files.FindTrash(trashDir, ogdir, fltr) + fls, err := files.FindInAllTrashes(ogdir, fltr) if len(fls) == 0 { fmt.Fprintln(os.Stdout, "no files to restore") return nil @@ -304,7 +295,7 @@ var ( Flags: slices.Concat(cleanRestoreFlags, trashedFlags, filterFlags), Before: beforeCommands, Action: func(_ *cli.Context) error { - fls, err := files.FindTrash(trashDir, ogdir, fltr) + fls, err := files.FindInAllTrashes(ogdir, fltr) if len(fls) == 0 { fmt.Fprintln(os.Stdout, "no files to clean") return nil