1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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")
}
|