Files
mapsize/internal/platform/ops_linux.go
T
Andy Dixon 8d9344cfef mapsize 1.2.0: permanent delete and in-map reveal when headless
On a headless Linux or BSD system (no DISPLAY or WAYLAND_DISPLAY) nothing
empties the freedesktop trash, so trashing never freed space. There, d now
deletes permanently after an explicit confirmation, checking owner and type
against the scan and removing through an os.Root on the parent so a swapped
symlink cannot redirect it. o shows the folder in the map instead of
launching a file manager. Desktop sessions, macOS and Windows are unchanged.
2026-10-03 12:50:07 +01:00

133 lines
3.5 KiB
Go

package platform
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"syscall"
"time"
)
// TrashName is the user-facing name of the trash.
const TrashName = "trash"
// Reveal opens the item's directory in the desktop file manager.
func Reveal(path string) error {
if err := checkAbs(path); err != nil {
return err
}
fi, err := os.Lstat(path)
if err == nil && !fi.IsDir() {
path = filepath.Dir(path)
}
return startDetached("xdg-open", path)
}
// Trash moves path to the freedesktop.org trash: the home trash when on the
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
// It never copies across filesystems and never deletes.
func Trash(path string, want Expect) error {
if err := checkAbs(path); err != nil {
return err
}
st, err := os.Lstat(path)
if err != nil {
return err
}
dev := st.Sys().(*syscall.Stat_t).Dev
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
dataHome = filepath.Join(home, ".local", "share")
}
home := filepath.Join(dataHome, "Trash")
var trash, infoPath string
if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev {
trash, infoPath = home, path
} else {
top := mountTop(path, dev)
trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid()))
rel, err := filepath.Rel(top, path)
if err != nil {
return err
}
infoPath = rel
}
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
if trash == home {
if err := os.MkdirAll(trash, 0o700); err != nil {
return err
}
} else if err := ensurePrivateDir(trash, true); err != nil {
// On a shared filesystem another user could plant a symlink or a
// world-writable directory here; refuse rather than follow it.
return fmt.Errorf("trash directory %s is unsafe: %w", trash, err)
}
for _, d := range []string{files, info} {
if err := ensurePrivateDir(d, trash != home); err != nil {
return fmt.Errorf("trash directory %s is unsafe: %w", d, err)
}
}
base := filepath.Base(path)
for i := 1; i < 10000; i++ {
name := base
if i > 1 {
name = fmt.Sprintf("%s.%d", base, i)
}
dst := filepath.Join(files, name)
if _, err := os.Lstat(dst); err == nil {
continue // never overwrite an earlier trashed item (even without .trashinfo)
}
ip := filepath.Join(info, name+".trashinfo")
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return err
}
u := (&url.URL{Path: infoPath}).EscapedPath()
_, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05"))
cerr := f.Close()
if werr != nil || cerr != nil {
os.Remove(ip)
return errors.Join(werr, cerr)
}
if err := renameChecked(path, dst, want); err != nil {
os.Remove(ip)
return err
}
return nil
}
return errors.New("trash: too many items with the same name")
}
// mountTop returns the top directory of the filesystem containing path.
func mountTop(path string, dev uint64) string {
cur := filepath.Dir(path)
for {
parent := filepath.Dir(cur)
if parent == cur {
return cur
}
st, err := os.Stat(parent)
if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev {
return cur
}
cur = parent
}
}
// Headless reports whether there is no graphical session (no X or Wayland
// display), so there is no file manager to open and no desktop trash anyone
// will empty.
func Headless() bool {
return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
}