diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
| commit | 3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch) | |
| tree | ec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/trash | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip | |
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/trash')
| -rw-r--r-- | internal/trash/trash.go | 222 | ||||
| -rw-r--r-- | internal/trash/trash_test.go | 173 |
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)) + } +} |
