aboutsummaryrefslogtreecommitdiff
path: root/internal/trash
diff options
context:
space:
mode:
Diffstat (limited to 'internal/trash')
-rw-r--r--internal/trash/trash.go222
-rw-r--r--internal/trash/trash_test.go173
2 files changed, 395 insertions, 0 deletions
diff --git a/internal/trash/trash.go b/internal/trash/trash.go
new file mode 100644
index 0000000..cc9bc23
--- /dev/null
+++ b/internal/trash/trash.go
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package trash implements the freedesktop.org Trash specification well
+// enough for krino's (delete) action to be recoverable: Put moves a file
+// into $XDG_DATA_HOME/Trash and records where it came from, and Restore
+// undoes that. See docs/design.md §7.2.
+package trash
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "krino/internal/xdg"
+)
+
+// ErrOtherFilesystem is returned by Put when path is not on the same
+// filesystem as the Trash: the file is left exactly where it was.
+var ErrOtherFilesystem = errors.New("not on the same filesystem as the trash")
+
+// maxSuffixAttempts bounds the collision loop in claimName. internal/plan
+// has its own suffixed() with the same cap (internal/plan/conflict.go), but
+// the two solve different problems and are free to diverge independently:
+// plan's avoids collisions with other planned destinations, this one avoids
+// collisions among entries already inside the Trash. They are not shared
+// because internal/trash's dependencies are stdlib plus internal/xdg only —
+// importing internal/plan for its three-line suffix logic would pull in
+// config, scan and dup transitively for that.
+const maxSuffixAttempts = 10000
+
+// Dir is $XDG_DATA_HOME/Trash, with its files/ and info/ subdirectories.
+func Dir() string { return filepath.Join(xdg.DataHome(), "Trash") }
+
+func filesDir() string { return filepath.Join(Dir(), "files") }
+func infoDir() string { return filepath.Join(Dir(), "info") }
+
+// Put moves path into the Trash and writes its .trashinfo. It returns the
+// entry name (the base name inside files/), which the log records so undo
+// can find it again.
+//
+// The name is claimed first: info/<entry>.trashinfo is created with
+// O_CREATE|O_EXCL before anything is moved, so two trash clients racing for
+// the same name cannot collide. If the subsequent move fails, the info file
+// is removed so no orphan is left.
+func Put(path string) (entry string, err error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ if err := os.MkdirAll(filesDir(), 0o700); err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ if err := os.MkdirAll(infoDir(), 0o700); err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+
+ entry, infoPath, f, err := claimName(filepath.Base(abs))
+ if err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+
+ info := "[Trash Info]\n" +
+ "Path=" + percentEncode(abs) + "\n" +
+ "DeletionDate=" + time.Now().Format("2006-01-02T15:04:05") + "\n"
+ if _, err := f.WriteString(info); err != nil {
+ f.Close()
+ os.Remove(infoPath)
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ if err := f.Close(); err != nil {
+ os.Remove(infoPath)
+ return "", fmt.Errorf("trash: %w", err)
+ }
+
+ dst := filepath.Join(filesDir(), entry)
+ if err := os.Rename(abs, dst); err != nil {
+ os.Remove(infoPath)
+ if errors.Is(err, syscall.EXDEV) {
+ return "", ErrOtherFilesystem
+ }
+ return "", fmt.Errorf("trash: %w", err)
+ }
+
+ return entry, nil
+}
+
+// claimName finds a free entry name derived from base and atomically creates
+// its .trashinfo, so the name is reserved before anything is moved. On
+// collision it tries stem_1.ext, stem_2.ext, ... — the same shape as
+// internal/plan's suffixing, but resolving a different, unrelated set of
+// collisions; see maxSuffixAttempts for why the two are not shared code.
+func claimName(base string) (entry, infoPath string, f *os.File, err error) {
+ stem, ext := splitExt(base)
+ for n := 0; n <= maxSuffixAttempts; n++ {
+ candidate := base
+ if n > 0 {
+ candidate = stem + "_" + strconv.Itoa(n) + ext
+ }
+ path := filepath.Join(infoDir(), candidate+".trashinfo")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err == nil {
+ return candidate, path, f, nil
+ }
+ if !os.IsExist(err) {
+ return "", "", nil, err
+ }
+ }
+ return "", "", nil, errors.New("too many conflicting names")
+}
+
+// splitExt splits name on its last dot, which does not count when it is the
+// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "".
+func splitExt(name string) (stem, ext string) {
+ i := strings.LastIndexByte(name, '.')
+ if i <= 0 {
+ return name, ""
+ }
+ return name[:i], name[i:]
+}
+
+// percentEncode RFC-2396-encodes s, leaving unreserved characters and '/'
+// literal. A byte loop is used rather than url.PathEscape, which also
+// escapes '/' and would produce a Path no other trash implementation can
+// read.
+func percentEncode(s string) string {
+ const hex = "0123456789ABCDEF"
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if isUnreserved(c) || c == '/' {
+ b.WriteByte(c)
+ continue
+ }
+ b.WriteByte('%')
+ b.WriteByte(hex[c>>4])
+ b.WriteByte(hex[c&0xf])
+ }
+ return b.String()
+}
+
+// isUnreserved reports whether c is unreserved under RFC 2396: letters,
+// digits, and -_.~, which percentEncode passes through unchanged.
+func isUnreserved(c byte) bool {
+ switch {
+ case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
+ return true
+ case c == '-' || c == '_' || c == '.' || c == '~':
+ return true
+ }
+ return false
+}
+
+// percentDecode reverses percentEncode.
+func percentDecode(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); i++ {
+ if s[i] == '%' && i+2 < len(s) {
+ if v, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil {
+ b.WriteByte(byte(v))
+ i += 2
+ continue
+ }
+ }
+ b.WriteByte(s[i])
+ }
+ return b.String()
+}
+
+// Restore moves an entry back to the Path recorded in its .trashinfo and
+// removes the .trashinfo. It refuses when that path already exists.
+//
+// Once the rename back to the original path has succeeded, removing the
+// .trashinfo is best-effort: that file back in place is the substantive
+// result, and a caller must be able to trust a non-error return means the
+// restore happened. So a failure to remove the .trashinfo is not reported
+// as an error — Restore returns (path, nil) regardless — and the
+// .trashinfo may survive as a stale, otherwise-harmless record.
+func Restore(entry string) (restored string, err error) {
+ infoPath := filepath.Join(infoDir(), entry+".trashinfo")
+ b, err := os.ReadFile(infoPath)
+ if err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ path, err := parsePath(string(b))
+ if err != nil {
+ return "", fmt.Errorf("trash: %s: %w", entry, err)
+ }
+ if _, err := os.Lstat(path); err == nil {
+ return "", fmt.Errorf("trash: %s: already exists", path)
+ } else if !os.IsNotExist(err) {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+
+ src := filepath.Join(filesDir(), entry)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ if err := os.Rename(src, path); err != nil {
+ return "", fmt.Errorf("trash: %w", err)
+ }
+ // The file is back; removing its bookkeeping is best-effort from here
+ // (see the doc comment above).
+ _ = os.Remove(infoPath)
+ return path, nil
+}
+
+// parsePath extracts and decodes the Path= line of a .trashinfo file.
+func parsePath(info string) (string, error) {
+ for _, line := range strings.Split(info, "\n") {
+ if v, ok := strings.CutPrefix(line, "Path="); ok {
+ return percentDecode(v), nil
+ }
+ }
+ return "", errors.New("trashinfo has no path")
+}
diff --git a/internal/trash/trash_test.go b/internal/trash/trash_test.go
new file mode 100644
index 0000000..24f4ea8
--- /dev/null
+++ b/internal/trash/trash_test.go
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package trash
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// sandbox points XDG_DATA_HOME at a temporary tree, so the real Trash is
+// never touched.
+func sandbox(t *testing.T) string {
+ t.Helper()
+ h := t.TempDir()
+ t.Setenv("HOME", h)
+ t.Setenv("XDG_DATA_HOME", filepath.Join(h, "share"))
+ t.Setenv("XDG_CONFIG_HOME", "")
+ t.Setenv("XDG_STATE_HOME", "")
+ t.Setenv("XDG_CACHE_HOME", "")
+ return h
+}
+
+func write(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPutWritesBothParts(t *testing.T) {
+ h := sandbox(t)
+ src := filepath.Join(h, "dl", "old report.pdf")
+ write(t, src, "pdf")
+
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry != "old report.pdf" {
+ t.Errorf("entry = %q, want the base name", entry)
+ }
+ if _, err := os.Stat(src); !os.IsNotExist(err) {
+ t.Error("the original is still in place")
+ }
+ if b, err := os.ReadFile(filepath.Join(Dir(), "files", entry)); err != nil || string(b) != "pdf" {
+ t.Errorf("trashed content = %q, %v", b, err)
+ }
+ info, err := os.ReadFile(filepath.Join(Dir(), "info", entry+".trashinfo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(info)
+ if !strings.HasPrefix(got, "[Trash Info]\n") {
+ t.Errorf("trashinfo lacks its header:\n%s", got)
+ }
+ if !strings.Contains(got, "Path="+strings.ReplaceAll(src, " ", "%20")+"\n") {
+ t.Errorf("Path is not the absolute percent-encoded original:\n%s", got)
+ }
+ // DeletionDate is local time with no offset: 19 characters, no Z, no +.
+ for _, line := range strings.Split(got, "\n") {
+ if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
+ if len(v) != 19 || strings.ContainsAny(v, "Z+") {
+ t.Errorf("DeletionDate = %q; want local time like 2026-04-23T16:04:23", v)
+ }
+ }
+ }
+}
+
+func TestPutSuffixesOnCollision(t *testing.T) {
+ h := sandbox(t)
+ first := filepath.Join(h, "a", "x.pdf")
+ second := filepath.Join(h, "b", "x.pdf")
+ write(t, first, "one")
+ write(t, second, "two")
+
+ if _, err := Put(first); err != nil {
+ t.Fatal(err)
+ }
+ entry, err := Put(second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry != "x_1.pdf" {
+ t.Fatalf("second entry = %q, want x_1.pdf", entry)
+ }
+ if b, _ := os.ReadFile(filepath.Join(Dir(), "files", "x.pdf")); string(b) != "one" {
+ t.Error("the first entry was overwritten")
+ }
+ if b, _ := os.ReadFile(filepath.Join(Dir(), "files", "x_1.pdf")); string(b) != "two" {
+ t.Error("the second entry holds the wrong content")
+ }
+ info, err := os.ReadFile(filepath.Join(Dir(), "info", "x_1.pdf.trashinfo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantPath := "Path=" + strings.ReplaceAll(second, " ", "%20") + "\n"
+ if !strings.Contains(string(info), wantPath) {
+ t.Errorf("x_1.pdf.trashinfo does not point at its own original %q:\n%s", second, info)
+ }
+}
+
+func TestRestoreRoundTrips(t *testing.T) {
+ h := sandbox(t)
+ src := filepath.Join(h, "dl", "zażółć gęślą.pdf")
+ write(t, src, "polish")
+
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ restored, err := Restore(entry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restored != src {
+ t.Errorf("restored to %q, want %q", restored, src)
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "polish" {
+ t.Errorf("content after restore = %q, %v", b, err)
+ }
+ if _, err := os.Stat(filepath.Join(Dir(), "info", entry+".trashinfo")); !os.IsNotExist(err) {
+ t.Error("the .trashinfo was left behind")
+ }
+}
+
+func TestRestoreRefusesWhenTargetExists(t *testing.T) {
+ h := sandbox(t)
+ src := filepath.Join(h, "dl", "x.pdf")
+ write(t, src, "one")
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ write(t, src, "something new")
+ if _, err := Restore(entry); err == nil {
+ t.Fatal("Restore overwrote a file that had taken the original path")
+ }
+ if b, _ := os.ReadFile(src); string(b) != "something new" {
+ t.Error("the file at the original path was modified")
+ }
+ if _, err := os.Stat(filepath.Join(Dir(), "files", entry)); err != nil {
+ t.Error("the trash entry was consumed by a refused restore")
+ }
+}
+
+// TestPutRefusesOtherFilesystem needs a second filesystem. /dev/shm is one on
+// Linux; the test skips where there is none.
+func TestPutRefusesOtherFilesystem(t *testing.T) {
+ sandbox(t)
+ other, err := os.MkdirTemp("/dev/shm", "krino-trash-")
+ if err != nil {
+ t.Skip("no second filesystem available:", err)
+ }
+ defer os.RemoveAll(other)
+ src := filepath.Join(other, "x.pdf")
+ write(t, src, "elsewhere")
+
+ if _, err := Put(src); !errors.Is(err, ErrOtherFilesystem) {
+ t.Fatalf("Put across filesystems: err = %v, want ErrOtherFilesystem", err)
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "elsewhere" {
+ t.Errorf("the file was disturbed by a refused Put: %q, %v", b, err)
+ }
+ if entries, _ := os.ReadDir(filepath.Join(Dir(), "info")); len(entries) != 0 {
+ t.Errorf("a refused Put left %d orphaned info files", len(entries))
+ }
+}