mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-30 17:42:06 -04:00
dock: add trash CLI, refine implementation
This commit is contained in:
455
core/internal/trash/trash.go
Normal file
455
core/internal/trash/trash.go
Normal file
@@ -0,0 +1,455 @@
|
||||
// Package trash implements the FreeDesktop.org Trash specification 1.0.
|
||||
// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
|
||||
package trash
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const trashInfoExt = ".trashinfo"
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
OriginalPath string `json:"originalPath"`
|
||||
DeletionDate string `json:"deletionDate"`
|
||||
TrashDir string `json:"trashDir"`
|
||||
FilesPath string `json:"filesPath"`
|
||||
InfoPath string `json:"infoPath"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
|
||||
func homeTrashDir() (string, error) {
|
||||
xdg := os.Getenv("XDG_DATA_HOME")
|
||||
if xdg == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
xdg = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return filepath.Join(xdg, "Trash"), nil
|
||||
}
|
||||
|
||||
func ensureTrashDirs(trashDir string) error {
|
||||
if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700)
|
||||
}
|
||||
|
||||
func fsDevice(path string) (uint64, error) {
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(path, &st); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(st.Dev), nil
|
||||
}
|
||||
|
||||
func fsDeviceWalkUp(start string) (uint64, error) {
|
||||
cur := start
|
||||
for {
|
||||
if dev, err := fsDevice(cur); err == nil {
|
||||
return dev, nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return 0, fmt.Errorf("no existing ancestor for %s", start)
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func findTopDir(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dev, err := fsDevice(abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cur := abs
|
||||
for {
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return cur, nil
|
||||
}
|
||||
pdev, err := fsDevice(parent)
|
||||
if err != nil {
|
||||
return cur, nil
|
||||
}
|
||||
if pdev != dev {
|
||||
return cur, nil
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// isValidSharedTrash enforces the spec's checks on $topdir/.Trash:
|
||||
// must exist, must be a directory, must not be a symlink, must have sticky bit.
|
||||
func isValidSharedTrash(p string) bool {
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return false
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeSticky != 0
|
||||
}
|
||||
|
||||
// trashDirForPath chooses the correct trash dir per spec and returns the value
|
||||
// to store in the .trashinfo Path field (absolute for home, relative-to-topdir
|
||||
// for per-mountpoint trash).
|
||||
func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) {
|
||||
home, err := homeTrashDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pathDev, err := fsDevice(absPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
homeDev, err := fsDeviceWalkUp(home)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if pathDev == homeDev {
|
||||
return home, absPath, nil
|
||||
}
|
||||
|
||||
topDir, err := findTopDir(absPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
stored, rerr := filepath.Rel(topDir, absPath)
|
||||
if rerr != nil || strings.HasPrefix(stored, "..") {
|
||||
stored = absPath
|
||||
}
|
||||
|
||||
shared := filepath.Join(topDir, ".Trash")
|
||||
if isValidSharedTrash(shared) {
|
||||
return filepath.Join(shared, uid), stored, nil
|
||||
}
|
||||
return filepath.Join(topDir, ".Trash-"+uid), stored, nil
|
||||
}
|
||||
|
||||
// uniqueName returns a basename in trashDir that does not collide with an
|
||||
// existing entry in either files/ or info/.
|
||||
func uniqueName(trashDir, basename string) (string, error) {
|
||||
filesDir := filepath.Join(trashDir, "files")
|
||||
infoDir := filepath.Join(trashDir, "info")
|
||||
if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) {
|
||||
return basename, nil
|
||||
}
|
||||
ext := filepath.Ext(basename)
|
||||
stem := strings.TrimSuffix(basename, ext)
|
||||
for i := 2; i < 100000; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d%s", stem, i, ext)
|
||||
if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("could not find unique trash name")
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Lstat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/".
|
||||
func pathEncode(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i, seg := range parts {
|
||||
parts[i] = url.PathEscape(seg)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func pathDecode(p string) string {
|
||||
if d, err := url.PathUnescape(p); err == nil {
|
||||
return d
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func writeTrashInfo(infoPath, storedPath string, when time.Time) error {
|
||||
body := "[Trash Info]\nPath=" + pathEncode(storedPath) +
|
||||
"\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n"
|
||||
f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Put trashes a single file or directory.
|
||||
func Put(path string) (Entry, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
info, err := os.Lstat(abs)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
|
||||
trashDir, storedPath, err := trashDirForPath(abs)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := ensureTrashDirs(trashDir); err != nil {
|
||||
return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err)
|
||||
}
|
||||
|
||||
name, err := uniqueName(trashDir, filepath.Base(abs))
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
|
||||
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||
when := time.Now()
|
||||
if err := writeTrashInfo(infoPath, storedPath, when); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
|
||||
target := filepath.Join(trashDir, "files", name)
|
||||
if err := os.Rename(abs, target); err != nil {
|
||||
os.Remove(infoPath)
|
||||
return Entry{}, err
|
||||
}
|
||||
|
||||
return Entry{
|
||||
Name: name,
|
||||
OriginalPath: storedPath,
|
||||
DeletionDate: when.Format("2006-01-02T15:04:05"),
|
||||
TrashDir: trashDir,
|
||||
FilesPath: target,
|
||||
InfoPath: infoPath,
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// allTrashDirs returns the home trash plus every per-mountpoint trash dir
|
||||
// that exists (and passes the spec's safety checks for $topdir/.Trash).
|
||||
func allTrashDirs() []string {
|
||||
var dirs []string
|
||||
if h, err := homeTrashDir(); err == nil {
|
||||
dirs = append(dirs, h)
|
||||
}
|
||||
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
for _, mount := range readMountPoints() {
|
||||
shared := filepath.Join(mount, ".Trash")
|
||||
if isValidSharedTrash(shared) {
|
||||
candidate := filepath.Join(shared, uid)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
dirs = append(dirs, candidate)
|
||||
}
|
||||
}
|
||||
candidate := filepath.Join(mount, ".Trash-"+uid)
|
||||
if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
|
||||
dirs = append(dirs, candidate)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
|
||||
// skipping pseudo and system filesystems.
|
||||
func readMountPoints() []string {
|
||||
data, err := os.ReadFile("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
skipPrefixes := []string{"/proc", "/sys", "/dev"}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
mp := fields[4]
|
||||
if mp == "/" {
|
||||
continue
|
||||
}
|
||||
skip := false
|
||||
for _, p := range skipPrefixes {
|
||||
if mp == p || strings.HasPrefix(mp, p+"/") {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if skip || seen[mp] {
|
||||
continue
|
||||
}
|
||||
seen[mp] = true
|
||||
out = append(out, mp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func List() ([]Entry, error) {
|
||||
var entries []Entry
|
||||
for _, d := range allTrashDirs() {
|
||||
es, _ := listOne(d)
|
||||
entries = append(entries, es...)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func listOne(trashDir string) ([]Entry, error) {
|
||||
infoDir := filepath.Join(trashDir, "info")
|
||||
filesDir := filepath.Join(trashDir, "files")
|
||||
dir, err := os.ReadDir(infoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []Entry
|
||||
for _, ent := range dir {
|
||||
if !strings.HasSuffix(ent.Name(), trashInfoExt) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(ent.Name(), trashInfoExt)
|
||||
infoPath := filepath.Join(infoDir, ent.Name())
|
||||
filesPath := filepath.Join(filesDir, name)
|
||||
|
||||
body, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||
e.OriginalPath = pathDecode(v)
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
|
||||
e.DeletionDate = v
|
||||
}
|
||||
}
|
||||
if info, err := os.Lstat(filesPath); err == nil {
|
||||
e.Size = info.Size()
|
||||
e.IsDir = info.IsDir()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func Count() (int, error) {
|
||||
n := 0
|
||||
for _, d := range allTrashDirs() {
|
||||
ents, err := os.ReadDir(filepath.Join(d, "info"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range ents {
|
||||
if strings.HasSuffix(e.Name(), trashInfoExt) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func Empty() error {
|
||||
var firstErr error
|
||||
for _, d := range allTrashDirs() {
|
||||
if err := emptyOne(d); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func emptyOne(trashDir string) error {
|
||||
var firstErr error
|
||||
for _, sub := range []string{"files", "info"} {
|
||||
path := filepath.Join(trashDir, sub)
|
||||
ents, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range ents {
|
||||
if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Remove(filepath.Join(trashDir, "directorysizes"))
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Restore returns a trashed item to its original location.
|
||||
func Restore(name, trashDir string) error {
|
||||
if trashDir == "" {
|
||||
h, err := homeTrashDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir = h
|
||||
}
|
||||
|
||||
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||
filesPath := filepath.Join(trashDir, "files", name)
|
||||
|
||||
body, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stored string
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||
stored = pathDecode(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
if stored == "" {
|
||||
return errors.New("invalid .trashinfo: missing Path")
|
||||
}
|
||||
|
||||
target := stored
|
||||
if !filepath.IsAbs(stored) {
|
||||
topDir := filepath.Dir(trashDir)
|
||||
if filepath.Base(topDir) == ".Trash" {
|
||||
topDir = filepath.Dir(topDir)
|
||||
}
|
||||
target = filepath.Join(topDir, stored)
|
||||
}
|
||||
|
||||
if exists(target) {
|
||||
return fmt.Errorf("restore target already exists: %s", target)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(filesPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(infoPath)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user