summaryrefslogtreecommitdiff
path: root/internal/trash/trash.go
blob: 5d6ebe08f77f23ccd4427333e92ca45198437d71 (plain) (blame)
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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
// 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/<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(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")
}