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/trash.go | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip | |
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/trash/trash.go')
| -rw-r--r-- | internal/trash/trash.go | 222 |
1 files changed, 222 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") +} |
