summaryrefslogtreecommitdiff
path: root/internal/apply/fs.go
blob: 0eee780589bc47cd1c667320bd8a5bfa6b201745 (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
275
276
277
278
279
280
281
282
283
284
285
286
// SPDX-License-Identifier: GPL-3.0-or-later

package apply

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"syscall"
)

// copyFile copies src to dst by streaming its content through a temporary
// file created in dst's directory, syncing it, then renaming it into place.
// dst must not already exist. Mode and modification time are preserved from
// src. On any failure the temporary file is removed and neither src nor a
// pre-existing dst is touched.
//
// This is not internal/config's replaceFile reused: that helper resolves a
// destination symlink and overwrites a file that is already there, and it
// takes the whole replacement as a []byte. copy's destination must not exist
// beforehand, and a []byte cannot stand in for a file that may be many
// gigabytes, so this is a separate, streaming equivalent kept local to
// internal/apply rather than shared with internal/config.
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()

	fi, err := in.Stat()
	if err != nil {
		return err
	}

	dir := filepath.Dir(dst)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}

	tmp, err := os.CreateTemp(dir, ".krino-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	done := false
	defer func() {
		if !done {
			os.Remove(tmpName)
		}
	}()

	if _, err := io.Copy(tmp, in); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(fi.Mode().Perm()); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Chtimes(tmpName, fi.ModTime(), fi.ModTime()); err != nil {
		return err
	}

	// Deliberate, not redundant: the executor's own conflict re-check
	// (runFileStep, apply.go) already found a name free of anything on disk
	// before ever calling copyFile, so dst existing here means something
	// else claimed it in the meantime. Refusing is the only safe response —
	// silently overwriting it, via os.Rename below, would destroy whatever
	// just raced us.
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("copy: destination already exists: %s", dst)
	} else if !os.IsNotExist(err) {
		return err
	}
	if err := os.Rename(tmpName, dst); err != nil {
		return err
	}
	done = true
	return nil
}

// moveFile moves src to dst. It tries os.Rename first, which is atomic when
// src and dst are on the same filesystem. Only when that fails with EXDEV
// (a different filesystem) does it fall back to copying src to dst through
// copyFile — itself leaving no temporary file and touching neither src nor
// dst on failure — and, only once that copy has landed at dst, removing src.
// A failure at any point before the copy has landed leaves src exactly
// where it was; a failure to remove src afterward leaves both a full copy
// at dst and the original at src rather than risk deleting the only good
// copy.
//
// The filesystem check is done by unwrapping the error for syscall.EXDEV,
// never by comparing a Stat_t's device field: that field's type differs
// across the platforms `make ci` vets (freebsd, openbsd), while syscall.EXDEV
// itself is defined identically on all three.
func moveFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	// This guard must hold independently of runFileStep's own pre-check,
	// layered rather than moved: trusting that some caller already checked
	// is exactly what lets a silently replacing helper cause harm. Placed
	// immediately before the operation that would otherwise clobber dst,
	// the same way copyFile's own guard sits right before its rename into
	// place.
	if err := refuseIfExists(dst); err != nil {
		return err
	}
	err := os.Rename(src, dst)
	if err == nil {
		return nil
	}
	if !errors.Is(err, syscall.EXDEV) {
		return err
	}
	if err := copyFile(src, dst); err != nil {
		return err
	}
	return os.Remove(src)
}

// renameFile renames src to dst, refusing on its own when dst already
// exists rather than trusting that a caller checked first (the same
// reasoning as moveFile's guard above): a bare os.Rename silently replaces
// an occupied destination, and runFileStep's own pre-check must not be the
// only thing standing between a rename step and that.
func renameFile(src, dst string) error {
	if err := refuseIfExists(dst); err != nil {
		return err
	}
	return os.Rename(src, dst)
}

// refuseIfExists reports an error naming dst if something is already there
// (os.Lstat succeeds, following no symlink), and propagates any other stat
// failure. A nil return means dst was confirmed absent at the moment of the
// check.
func refuseIfExists(dst string) error {
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("destination already exists: %s", dst)
	} else if !os.IsNotExist(err) {
		return err
	}
	return nil
}

// maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and
// internal/trash/trash.go each have their own cap of the same size, for the
// same reason given below: nextFreeName solves yet another, independent
// collision problem and is not sharing code with either.
const maxSuffixAttempts = 10000

// nextFreeName finds the first stem_N.ext, N starting at 1, that does not
// currently exist on disk. It is the executor's own conflict re-check (spec
// §7.4, last paragraph): planning already resolved every conflict once
// against the filesystem as it was then, but something else can claim the
// planned name before the executor gets to it, so the executor looks again,
// right before acting, using only the real filesystem — it has no run-wide
// claim set to consult, unlike planning's.
//
// internal/plan's own suffixed() and splitExt are unexported, so they are
// not reachable from here; nextFreeName and splitExt below are a second,
// small implementation, the same shape as internal/plan's and
// internal/trash's for the same reason those two do not share code with
// each other either — each resolves a distinct, independently changing set
// of collisions (planned destinations; entries already in the Trash; names
// that appeared on disk since this plan was made).
func nextFreeName(dst string) (string, error) {
	dir, base := filepath.Split(dst)
	stem, ext := splitExt(base)
	for n := 1; n <= maxSuffixAttempts; n++ {
		candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext))
		if _, err := os.Lstat(candidate); os.IsNotExist(err) {
			return candidate, nil
		}
	}
	return "", 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", "".
// Duplicated from internal/plan/conflict.go and internal/trash/trash.go
// (unexported in both) rather than shared; see nextFreeName's comment.
func splitExt(name string) (stem, ext string) {
	i := strings.LastIndexByte(name, '.')
	if i <= 0 {
		return name, ""
	}
	return name[:i], name[i:]
}

// MkdirAllTracked creates dir and any missing ancestors (mode 0755),
// returning every directory it actually created, outermost first, so undo
// can later remove the empty ones again. A directory that already existed
// is not included, and nothing is created or returned on error.
func MkdirAllTracked(dir string) ([]string, error) {
	dir = filepath.Clean(dir)
	// Lstat, not Stat: a dangling symlink here reports ENOENT to Stat, so
	// the "already a directory" branch is missed, os.Mkdir then fails with
	// EEXIST, and the path used to be recorded as one krino created - which
	// undo would later remove, unlinking a symlink krino never made.
	if fi, err := os.Lstat(dir); err == nil {
		if fi.Mode()&fs.ModeSymlink != 0 {
			if resolved, serr := os.Stat(dir); serr != nil || !resolved.IsDir() {
				return nil, fmt.Errorf("%s is a symlink that does not lead to a directory", dir)
			}
			return nil, nil
		}
		if !fi.IsDir() {
			return nil, fmt.Errorf("%s exists and is not a directory", dir)
		}
		return nil, nil
	} else if !os.IsNotExist(err) {
		return nil, err
	}

	parent := filepath.Dir(dir)
	var made []string
	if parent != dir {
		parentMade, err := MkdirAllTracked(parent)
		if err != nil {
			return nil, err
		}
		made = parentMade
	}
	if err := os.Mkdir(dir, 0o755); err != nil {
		if os.IsExist(err) {
			// Something else created it between the Lstat and here: it is
			// not ours to remove again.
			return made, nil
		}
		return made, err
	}
	return append(made, dir), nil
}

// UnderSymlink reports the first component of path, at or below root, that
// is a symlink - "" when there is none, and "" when path is not under root
// at all.
//
// Placeholders are already stopped from redirecting a step out of the
// directory a rule named (internal/plan's expandDir); a symlink is a name
// too, and one planted inside the directory krino sorts - by an unpacked
// archive, say - redirects a move or a copy exactly the same way, while the
// plan the user approved shows only the text. A destination the
// configuration itself names, outside the sorted directory, is the user's
// own arrangement and is not second-guessed.
func UnderSymlink(path, root string) string {
	if root == "" {
		return ""
	}
	root = filepath.Clean(root)
	path = filepath.Clean(path)
	rel, err := filepath.Rel(root, path)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return ""
	}
	at := root
	for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
		if part == "." || part == "" {
			continue
		}
		at = filepath.Join(at, part)
		fi, err := os.Lstat(at)
		if err != nil {
			// Not there yet: nothing below it can be a symlink either.
			return ""
		}
		if fi.Mode()&fs.ModeSymlink != 0 {
			return at
		}
	}
	return ""
}