From 7e0d8494074398854f30feb75211c52ea5cc2635 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 21:28:11 +0200 Subject: plan 9: trash skips orphaned names, fits long names, exposes InfoPath --- internal/trash/trash.go | 65 +++++++++++++++++++++++++++++++++++--------- internal/trash/trash_test.go | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 13 deletions(-) (limited to 'internal') 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") + } +} -- cgit v1.3