summaryrefslogtreecommitdiff
path: root/internal/apply/apply.go
blob: fdeed5757896a8ec1908293a3e660a9c32b31ef9 (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
// SPDX-License-Identifier: GPL-3.0-or-later

// Package apply is the executor: it carries out one file's plan.Chain,
// actually moving, copying, renaming, trashing or permanently deleting real
// files. See docs/design.md §7.2 for the mechanism each action follows and
// §7.4's last paragraph for the execution-time conflict re-check.
package apply

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"git.labunix.xyz/krino/internal/plan"
	"git.labunix.xyz/krino/internal/scan"
	"git.labunix.xyz/krino/internal/trash"
)

// StepResult is what happened to one step, in the order the executor ran
// them.
type StepResult struct {
	Step    plan.Step
	Status  string    // "ok" | "failed" | "skipped"
	Detail  string    // the failure, or why it was skipped
	Dst     string    // where the file actually ended up (conflict names can change at execution time)
	Size    int64     // of the file at Dst afterwards
	ModTime time.Time // of the file at Dst afterwards
	Entry   string    // trash entry name, for Trash steps; "" otherwise
	// DisplacedEntry is the trash entry name of the file this step
	// displaced; "" when none. Entry and DisplacedEntry describe two
	// different files: the one being acted on (Entry, only for a Trash-kind
	// step), and the one that was in this step's way and had to be trashed
	// first (DisplacedEntry, only when Displaces was set). Spec §7.4's
	// overwrite policy and §9's "displace" action both depend on this name
	// being recoverable — it is chosen inside trash.Put, so nothing
	// downstream of Chain could otherwise re-derive it for undo.
	DisplacedEntry string
	Made           []string // directories this step created, outermost first
}

// Chain runs one file's steps in order and stops at the first failure,
// marking the rest skipped. It never touches a file whose size or mtime no
// longer matches what the plan recorded. It is ChainLogged with no done.
func Chain(c plan.Chain) []StepResult {
	results, _ := ChainLogged(context.Background(), c, nil, nil)
	return results
}

// ChainLogged is Chain, calling done with each step's result as soon as
// that step has run or been skipped, before the next one starts - so a
// caller that logs from done never has a completed step missing from the
// log when the process dies mid-chain. An error from done stops the chain
// at once and is returned with the results so far.
//
// A move or rename that had to take a free name at apply time, because its
// planned destination was taken since planning, stops the chain too: every
// later step was planned against the name the file did not get, and must
// not act on whatever is at that path.
//
// Once ctx is cancelled (an interrupt), the step already under way finishes
// and every later step is skipped as "interrupted": an interrupt stops after
// the current step, not after the file's whole chain (spec §11).
func ChainLogged(ctx context.Context, c plan.Chain, done func(i int, sr StepResult) error, displaced func(i int, step plan.Step, entry string) error) ([]StepResult, error) {
	results := make([]StepResult, len(c.Steps))
	stopWhy := ""

	for i, step := range c.Steps {
		if stopWhy == "" && ctx.Err() != nil {
			stopWhy = "interrupted"
		}
		switch {
		case stopWhy != "":
			results[i] = StepResult{Step: step, Status: "skipped", Detail: stopWhy}
		case step.Skip != "":
			// Planning already decided this step will not run; it must not
			// be attempted, so no pre-step check, no directory creation, no
			// touching the file (spec: a step already marked Skip is
			// reported, not attempted).
			results[i] = StepResult{Step: step, Status: "skipped", Detail: step.Skip}
		default:
			if err := checkUnchanged(step.Src, c.File); err != nil {
				results[i] = StepResult{Step: step, Status: "failed", Detail: err.Error()}
				stopWhy = "an earlier step in this chain failed"
				break
			}
			if at := UnderSymlink(filepath.Dir(step.Dst), c.Root); at != "" {
				results[i] = StepResult{Step: step, Status: "failed",
					Detail: "a symlink inside the sorted directory redirects this step: " + at}
				stopWhy = "an earlier step in this chain failed"
				break
			}
			res := runStep(step, func(entry string) error {
				if displaced == nil {
					return nil
				}
				return displaced(i, step, entry)
			})
			results[i] = res
			switch {
			case res.Status == "failed":
				stopWhy = "an earlier step in this chain failed"
			case (step.Kind == plan.Move || step.Kind == plan.Rename) && res.Dst != step.Dst:
				stopWhy = fmt.Sprintf("an earlier step put the file at %s, not the planned %s", res.Dst, step.Dst)
			}
		}
		if done != nil {
			if err := done(i, results[i]); err != nil {
				return results[:i+1], err
			}
		}
	}

	return results, nil
}

// checkUnchanged is the guard that matters most: before every step, the
// source is Lstat'd and compared against what the plan recorded for the
// whole file. A file rewritten or replaced between planning and applying
// must never be acted on (spec §15.1): its size and mtime must match, it
// must still be a regular file - not a symlink put in its place - and, at
// its planned path, it must be the same inode. The inode is not compared
// once an earlier step has moved the file: a move across filesystems
// copies it to a new inode, and the chain is still following its own file.
func checkUnchanged(src string, f scan.File) error {
	fi, err := os.Lstat(src)
	if err != nil {
		return fmt.Errorf("changed since plan: %w", err)
	}
	if !fi.Mode().IsRegular() {
		return errors.New("changed since plan: no longer a regular file")
	}
	if fi.Size() != f.Size || !fi.ModTime().Equal(f.ModTime) {
		return errors.New("changed since plan")
	}
	if src == f.Path && f.Ino != 0 {
		if now := scan.NewFile(src, f.Rel, fi); now.Dev != f.Dev || now.Ino != f.Ino {
			return errors.New("changed since plan: another file is in its place")
		}
	}
	return nil
}

// runStep dispatches one already-checked, non-skipped step to the code that
// actually carries it out. reportDisplace is called the moment a displaced
// file has reached the Trash, before the step that needed its name begins;
// a step whose displace cannot be reported does not go on to use the name.
func runStep(step plan.Step, reportDisplace func(entry string) error) StepResult {
	switch step.Kind {
	case plan.Copy, plan.Move, plan.Rename:
		return runFileStep(step, reportDisplace)
	case plan.Trash:
		return runTrashStep(step)
	case plan.DeletePermanent:
		return runDeleteStep(step)
	}
	panic(fmt.Sprintf("apply: unknown plan.Kind %d", int(step.Kind)))
}

// runFileStep carries out copy, move and rename. It re-checks the planned
// destination against the filesystem as it is now (spec §7.4): if something
// with Displaces set claims the file to trash first, that happens before
// anything else, and if the displace fails nothing further is attempted for
// this file. Otherwise, if the planned Dst now exists, the step moves to the
// next free stem_N.ext and records the real name in Dst rather than
// overwriting a file the plan never accounted for. Missing destination
// directories are created and recorded in Made, outermost first, whether or
// not the step that needed them goes on to succeed.
//
// The displace is reported through reportDisplace as soon as trash.Put
// returns, not when this step finishes: the user's file is in the Trash
// from that moment, durably, and for a copy or a cross-device move the rest
// of the step is the whole data transfer. A process killed in that window
// used to leave the file in the Trash with nothing in the log to say so.
func runFileStep(step plan.Step, reportDisplace func(entry string) error) StepResult {
	dst := step.Dst
	var displacedEntry string

	if step.Displaces != "" {
		// Re-checked at apply time: only a regular file may be trashed to
		// make room, never a directory or link put there since.
		if fi, err := os.Lstat(step.Displaces); err != nil || !fi.Mode().IsRegular() {
			return StepResult{Step: step, Status: "failed", Detail: "the file to replace is gone or no longer a regular file"}
		}
		// overwrite policy: the file already at dst must be trashed before
		// this step's own destination name is used, so no free-name search
		// applies here — the whole point of displacing was to clear this
		// exact name. The entry name is captured regardless of what happens
		// next in this step: spec §9 logs "displace" as its own action with
		// its own line, independent of whether the move/copy/rename that
		// needed the name then goes on to succeed, so every return below
		// (failure included) carries it once trashing has succeeded.
		entry, err := trash.Put(step.Displaces)
		if err != nil {
			return StepResult{Step: step, Status: "failed", Detail: "displacing the existing file: " + err.Error()}
		}
		displacedEntry = entry
		if reportDisplace != nil {
			if err := reportDisplace(entry); err != nil {
				// The file is already in the Trash and cannot be recorded.
				// Using the name now would compound an unlogged destructive
				// act with a second one.
				return StepResult{Step: step, Status: "failed", DisplacedEntry: entry,
					Detail: "the file it replaces went to the Trash but could not be logged, so undo cannot see it: " + err.Error()}
			}
		}
	} else if _, err := os.Lstat(dst); err == nil {
		free, err := nextFreeName(dst)
		if err != nil {
			return StepResult{Step: step, Status: "failed", Detail: err.Error()}
		}
		dst = free
	} else if !os.IsNotExist(err) {
		return StepResult{Step: step, Status: "failed", Detail: err.Error()}
	}

	made, err := MkdirAllTracked(filepath.Dir(dst))
	if err != nil {
		return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
	}

	switch step.Kind {
	case plan.Copy:
		err = copyFile(step.Src, dst)
	case plan.Move:
		err = moveFile(step.Src, dst)
	case plan.Rename:
		err = renameFile(step.Src, dst)
	}
	if err != nil {
		return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
	}

	fi, err := os.Stat(dst)
	if err != nil {
		return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
	}
	return StepResult{Step: step, Status: "ok", Dst: dst, Size: fi.Size(), ModTime: fi.ModTime(), Made: made, DisplacedEntry: displacedEntry}
}

// runTrashStep carries out (delete): the file goes to the freedesktop.org
// Trash via trash.Put. A file on a different filesystem from the Trash is
// not trashed at all; the spec requires the failure to name the two ways
// forward, since otherwise the user has no path out of it.
//
// Dst, Size and ModTime describe the file at trash.Dir()/files/<entry>,
// even though plan.Step.Dst is always "" for a delete (there is nothing to
// compute or conflict-check at plan time). That is a deliberate reading of
// §9 rather than an oversight forced by the empty plan.Step.Dst: those
// journal columns describe the file at Dst after the step, and after a
// trash step the file genuinely lives there, so recording it is more
// useful than an empty column and stays greppable. It also cannot confuse
// undo: the refusal condition for reversing a trash step is "the entry is
// gone, or Src now exists" — it reads Entry and Src, never Dst.
func runTrashStep(step plan.Step) StepResult {
	entry, err := trash.Put(step.Src)
	if err != nil {
		detail := err.Error()
		if errors.Is(err, trash.ErrOtherFilesystem) {
			detail += "; use (delete permanent) or a move instead"
		}
		return StepResult{Step: step, Status: "failed", Detail: detail}
	}

	dst := filepath.Join(trash.Dir(), "files", entry)
	var size int64
	var modTime time.Time
	if fi, err := os.Stat(dst); err == nil {
		size, modTime = fi.Size(), fi.ModTime()
	}
	return StepResult{Step: step, Status: "ok", Dst: dst, Size: size, ModTime: modTime, Entry: entry}
}

// runDeleteStep carries out (delete permanent): a plain unlink, with no
// Trash and no way back.
func runDeleteStep(step plan.Step) StepResult {
	if err := os.Remove(step.Src); err != nil {
		return StepResult{Step: step, Status: "failed", Detail: err.Error()}
	}
	return StepResult{Step: step, Status: "ok"}
}