summaryrefslogtreecommitdiff
path: root/internal/apply/apply.go
blob: f177a17abaa6cf7c67bfd271877fd7fbb336d603 (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
// 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 (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"krino/internal/plan"
	"krino/internal/scan"
	"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.
func Chain(c plan.Chain) []StepResult {
	results := make([]StepResult, len(c.Steps))
	stopped := false

	for i, step := range c.Steps {
		if stopped {
			results[i] = StepResult{Step: step, Status: "skipped", Detail: "an earlier step in this chain failed"}
			continue
		}
		if 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}
			continue
		}
		if err := checkUnchanged(step.Src, c.File); err != nil {
			results[i] = StepResult{Step: step, Status: "failed", Detail: err.Error()}
			stopped = true
			continue
		}

		res := runStep(step)
		results[i] = res
		if res.Status == "failed" {
			stopped = true
		}
	}

	return results
}

// checkUnchanged is the guard that matters most: before every step, the
// source is stat'd and compared against the size and mtime the plan
// recorded for the whole file. A file rewritten or replaced between
// planning and applying must never be acted on.
func checkUnchanged(src string, f scan.File) error {
	fi, err := os.Stat(src)
	if err != nil {
		return fmt.Errorf("changed since plan: %w", err)
	}
	if fi.Size() != f.Size || !fi.ModTime().Equal(f.ModTime) {
		return errors.New("changed since plan")
	}
	return nil
}

// runStep dispatches one already-checked, non-skipped step to the code that
// actually carries it out.
func runStep(step plan.Step) StepResult {
	switch step.Kind {
	case plan.Copy, plan.Move, plan.Rename:
		return runFileStep(step)
	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.
func runFileStep(step plan.Step) StepResult {
	dst := step.Dst
	var displacedEntry string

	if step.Displaces != "" {
		// 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
	} 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: Task 5's 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"}
}