// 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" "unicode/utf8" "git.labunix.xyz/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/.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(fitName(filepath.Base(abs), maxEntryName)) 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 } // 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 { return candidate, path, f, nil } if !os.IsExist(err) { return "", "", nil, err } } 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) { 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. It also // refuses an entry that is not a plain name inside the Trash, and a // trashinfo whose Path is not absolute. // // 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) { path, err := InfoPath(entry) if err != nil { 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) { 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 } // 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. func parsePath(info string) (string, error) { for _, line := range strings.Split(info, "\n") { if v, ok := strings.CutPrefix(line, "Path="); ok { p := percentDecode(v) if !filepath.IsAbs(p) { return "", fmt.Errorf("trashinfo path %q is not absolute", p) } return p, nil } } return "", errors.New("trashinfo has no path") }