aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/trash/trash.go65
-rw-r--r--internal/trash/trash_test.go61
2 files changed, 113 insertions, 13 deletions
diff --git a/internal/trash/trash.go b/internal/trash/trash.go
index b96c3aa..fdd21f8 100644
--- a/internal/trash/trash.go
+++ b/internal/trash/trash.go
@@ -15,6 +15,7 @@ import (
"strings"
"syscall"
"time"
+ "unicode/utf8"
"krino/internal/xdg"
)
@@ -59,7 +60,7 @@ func Put(path string) (entry string, err error) {
return "", fmt.Errorf("trash: %w", err)
}
- entry, infoPath, f, err := claimName(filepath.Base(abs))
+ entry, infoPath, f, err := claimName(fitName(filepath.Base(abs), maxEntryName))
if err != nil {
return "", fmt.Errorf("trash: %w", err)
}
@@ -101,6 +102,11 @@ func claimName(base string) (entry, infoPath string, f *os.File, err error) {
if n > 0 {
candidate = stem + "_" + strconv.Itoa(n) + ext
}
+ // A name already used in files/ - a file left there without its
+ // trashinfo - is taken too: renaming onto it would destroy it.
+ if _, err := os.Lstat(filepath.Join(filesDir(), candidate)); err == nil {
+ continue
+ }
path := filepath.Join(infoDir(), candidate+".trashinfo")
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
@@ -113,6 +119,30 @@ func claimName(base string) (entry, infoPath string, f *os.File, err error) {
return "", "", nil, errors.New("too many conflicting names")
}
+// maxEntryName is the longest entry name Put uses: 255 bytes, the usual
+// limit of a file name, less ".trashinfo" and the longest suffix claimName
+// can add ("_10000").
+const maxEntryName = 255 - len(".trashinfo") - len("_10000")
+
+// fitName shortens name to at most max bytes for use as a trash entry: the
+// stem is cut at a character boundary and an extension shorter than 16 bytes
+// is kept. The trashinfo still records the full original path, so Restore
+// puts the file back under its own name.
+func fitName(name string, max int) string {
+ if len(name) <= max {
+ return name
+ }
+ stem, ext := splitExt(name)
+ if len(ext) >= 16 || len(ext) >= max {
+ stem, ext = name, ""
+ }
+ cut := max - len(ext)
+ for cut > 0 && !utf8.RuneStart(stem[cut]) {
+ cut--
+ }
+ return stem[:cut] + ext
+}
+
// 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) {
@@ -185,20 +215,11 @@ func percentDecode(s string) string {
// 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) {
- // An entry comes from the log; it must name something inside the Trash
- // (spec §15.1), never a path that climbs out of it.
- if entry == "" || entry == "." || entry == ".." || strings.ContainsRune(entry, '/') {
- return "", fmt.Errorf("trash: bad entry name %q", entry)
- }
- infoPath := filepath.Join(infoDir(), entry+".trashinfo")
- b, err := os.ReadFile(infoPath)
+ path, err := InfoPath(entry)
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)
+ return "", err
}
+ infoPath := filepath.Join(infoDir(), entry+".trashinfo")
if _, err := os.Lstat(path); err == nil {
return "", fmt.Errorf("trash: %s: already exists", path)
} else if !os.IsNotExist(err) {
@@ -218,6 +239,24 @@ func Restore(entry string) (restored string, err error) {
return path, nil
}
+// InfoPath returns the absolute original path the trash entry's trashinfo
+// records. An entry comes from the log; it must name something inside the
+// Trash (spec §15.1), never a path that climbs out of it.
+func InfoPath(entry string) (string, error) {
+ if entry == "" || entry == "." || entry == ".." || strings.ContainsRune(entry, '/') {
+ return "", fmt.Errorf("trash: bad entry name %q", entry)
+ }
+ b, err := os.ReadFile(filepath.Join(infoDir(), entry+".trashinfo"))
+ 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)
+ }
+ return path, nil
+}
+
// parsePath extracts and decodes the Path= line of a .trashinfo file. The
// path must be absolute: Restore must never resolve one against the
// working directory.
diff --git a/internal/trash/trash_test.go b/internal/trash/trash_test.go
index 24f4ea8..9c3eed2 100644
--- a/internal/trash/trash_test.go
+++ b/internal/trash/trash_test.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "unicode/utf8"
)
// sandbox points XDG_DATA_HOME at a temporary tree, so the real Trash is
@@ -171,3 +172,63 @@ func TestPutRefusesOtherFilesystem(t *testing.T) {
t.Errorf("a refused Put left %d orphaned info files", len(entries))
}
}
+
+// TestPutSkipsOrphanedFiles: a name already taken in files/ - left there
+// without its trashinfo - is not overwritten; Put takes the next free name
+// (review planapply F5).
+func TestPutSkipsOrphanedFiles(t *testing.T) {
+ h := sandbox(t)
+ orphan := filepath.Join(Dir(), "files", "a.pdf")
+ write(t, orphan, "OLD TRASHED CONTENT")
+ src := filepath.Join(h, "a.pdf")
+ write(t, src, "new")
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry != "a_1.pdf" {
+ t.Errorf("entry = %q, want a_1.pdf", entry)
+ }
+ if b, _ := os.ReadFile(orphan); string(b) != "OLD TRASHED CONTENT" {
+ t.Errorf("the orphaned file was overwritten: %q", b)
+ }
+}
+
+// TestPutTrashesLongNames: a name near the 255-byte limit still goes to the
+// Trash - its entry name is shortened so ".trashinfo" and a suffix fit - and
+// comes back under its full original name (review planapply F6).
+func TestPutTrashesLongNames(t *testing.T) {
+ h := sandbox(t)
+ name := strings.Repeat("ż", 123) + ".pdf" // 246 + 4 = 250 bytes
+ src := filepath.Join(h, name)
+ write(t, src, "long")
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entry) > 239 || !strings.HasSuffix(entry, ".pdf") || !utf8.ValidString(entry) {
+ t.Errorf("entry %q (%d bytes) does not fit, lost its extension, or split a character", entry, len(entry))
+ }
+ restored, err := Restore(entry)
+ if err != nil || restored != src {
+ t.Errorf("Restore = %q, %v; want %q", restored, err, src)
+ }
+}
+
+// TestInfoPath: the original path a trash entry records, and refusal of an
+// entry name that is not a plain name inside the Trash.
+func TestInfoPath(t *testing.T) {
+ h := sandbox(t)
+ src := filepath.Join(h, "dl", "a.pdf")
+ write(t, src, "x")
+ entry, err := Put(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p, err := InfoPath(entry); err != nil || p != src {
+ t.Errorf("InfoPath = %q, %v; want %q", p, err, src)
+ }
+ if _, err := InfoPath("../x"); err == nil {
+ t.Error("InfoPath accepted ../x")
+ }
+}