aboutsummaryrefslogtreecommitdiff
path: root/internal/trash/trash.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:28:11 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:28:11 +0200
commit7e0d8494074398854f30feb75211c52ea5cc2635 (patch)
treef8ab4b7ce5dac8e75aded9ef4d4cd488d948d339 /internal/trash/trash.go
parentaca389f0e63713b890214cad950ac3aee7e6aaf4 (diff)
downloadkrino-7e0d8494074398854f30feb75211c52ea5cc2635.tar.gz
krino-7e0d8494074398854f30feb75211c52ea5cc2635.zip
plan 9: trash skips orphaned names, fits long names, exposes InfoPath
Diffstat (limited to 'internal/trash/trash.go')
-rw-r--r--internal/trash/trash.go65
1 files changed, 52 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.