summaryrefslogtreecommitdiff
path: root/internal/apply/apply_test.go
blob: cd217c014963f76e519bf774d2359c2087f9bb6a (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
287
288
289
290
291
292
293
// SPDX-License-Identifier: GPL-3.0-or-later

package apply

import (
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
	"time"

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

// chainFor builds a Chain whose File describes path as it is on disk now, so
// the executor's "changed since plan" check passes.
func chainFor(t *testing.T, root, rel string, steps ...plan.Step) plan.Chain {
	t.Helper()
	p := filepath.Join(root, rel)
	fi, err := os.Stat(p)
	if err != nil {
		t.Fatal(err)
	}
	return plan.Chain{
		File:  scan.File{Path: p, Rel: rel, Name: filepath.Base(rel), Size: fi.Size(), ModTime: fi.ModTime(), Mode: fi.Mode()},
		Steps: steps,
	}
}

func TestChainRunsStepsInOrder(t *testing.T) {
	root := t.TempDir()
	write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
	c := chainFor(t, root, "x.pdf",
		plan.Step{Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "B", "x.pdf")},
		plan.Step{Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "W", "x.pdf")},
	)
	got := Chain(c)
	if len(got) != 2 || got[0].Status != "ok" || got[1].Status != "ok" {
		t.Fatalf("results = %+v", got)
	}
	if b, err := os.ReadFile(filepath.Join(root, "B", "x.pdf")); err != nil || string(b) != "content" {
		t.Errorf("the copy is missing: %q %v", b, err)
	}
	if b, err := os.ReadFile(filepath.Join(root, "W", "x.pdf")); err != nil || string(b) != "content" {
		t.Errorf("the move did not arrive: %q %v", b, err)
	}
	if _, err := os.Stat(filepath.Join(root, "x.pdf")); !os.IsNotExist(err) {
		t.Error("the original survived the move")
	}
	if len(got[0].Made) == 0 {
		t.Error("the created directory was not recorded in Made")
	}
	if got[1].Size != int64(len("content")) {
		t.Errorf("Size = %d, want the size at Dst afterwards", got[1].Size)
	}
}

// TestChainStopsWhenFileChanged is the guard that matters most: a file
// rewritten between planning and applying must not be acted on at all.
func TestChainStopsWhenFileChanged(t *testing.T) {
	root := t.TempDir()
	src := write(t, filepath.Join(root, "x.pdf"), "planned", 0o644)
	c := chainFor(t, root, "x.pdf",
		plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf")},
		plan.Step{Kind: plan.Rename, Rule: "b", Src: filepath.Join(root, "W", "x.pdf"), Dst: filepath.Join(root, "W", "y.pdf")},
	)
	write(t, src, "rewritten since the plan was made", 0o644)

	got := Chain(c)
	if got[0].Status != "failed" || !strings.Contains(got[0].Detail, "changed since plan") {
		t.Fatalf("first step = %+v; want failed \"changed since plan\"", got[0])
	}
	if got[1].Status != "skipped" {
		t.Errorf("second step = %+v; want skipped after the failure", got[1])
	}
	if b, _ := os.ReadFile(src); string(b) != "rewritten since the plan was made" {
		t.Error("the changed file was modified anyway")
	}
}

func TestChainTrashAndPermanentDelete(t *testing.T) {
	root := t.TempDir()
	t.Setenv("HOME", root)
	t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
	t.Setenv("XDG_STATE_HOME", "")
	t.Setenv("XDG_CONFIG_HOME", "")
	t.Setenv("XDG_CACHE_HOME", "")

	gone := write(t, filepath.Join(root, "gone.pdf"), "trash me", 0o644)
	c1 := chainFor(t, root, "gone.pdf", plan.Step{Kind: plan.Trash, Rule: "dups", Src: gone})
	r1 := Chain(c1)
	if r1[0].Status != "ok" || r1[0].Entry == "" {
		t.Fatalf("trash step = %+v; want ok with an Entry name", r1[0])
	}
	if r1[0].DisplacedEntry != "" {
		t.Errorf("DisplacedEntry = %q, want empty: a plain trash step displaces nothing", r1[0].DisplacedEntry)
	}
	if _, err := os.Stat(gone); !os.IsNotExist(err) {
		t.Error("the trashed file is still in place")
	}

	nuked := write(t, filepath.Join(root, "nuked.pdf"), "unlink me", 0o644)
	c2 := chainFor(t, root, "nuked.pdf", plan.Step{Kind: plan.DeletePermanent, Rule: "old", Src: nuked})
	if r2 := Chain(c2); r2[0].Status != "ok" {
		t.Fatalf("permanent delete = %+v", r2[0])
	}
	if _, err := os.Stat(nuked); !os.IsNotExist(err) {
		t.Error("the permanently deleted file is still in place")
	}
}

func TestChainReChecksConflictAtExecutionTime(t *testing.T) {
	root := t.TempDir()
	src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
	dst := filepath.Join(root, "W", "x.pdf")
	c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst})
	// Something took the planned name between planning and applying.
	write(t, dst, "someone else got here first", 0o644)

	got := Chain(c)
	if got[0].Status != "ok" {
		t.Fatalf("step = %+v", got[0])
	}
	if got[0].Dst == dst {
		t.Error("the executor overwrote a name that appeared after planning")
	}
	if !strings.HasSuffix(got[0].Dst, "x_1.pdf") {
		t.Errorf("Dst = %q, want the next free name", got[0].Dst)
	}
	if b, _ := os.ReadFile(dst); string(b) != "someone else got here first" {
		t.Error("the file that took the planned name was overwritten")
	}
}

func TestChainSkippedStepIsNotAttempted(t *testing.T) {
	root := t.TempDir()
	src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
	c := chainFor(t, root, "x.pdf",
		plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf"), Skip: "target exists"},
	)
	if got := Chain(c); got[0].Status != "skipped" || got[0].Detail != "target exists" {
		t.Fatalf("result = %+v; want skipped carrying the planning reason", got[0])
	}
	if _, err := os.Stat(filepath.Join(root, "W")); !os.IsNotExist(err) {
		t.Error("a skipped step created its destination directory")
	}
	if _, err := os.Stat(src); err != nil {
		t.Error("a skipped step moved the file anyway")
	}
	_ = time.Now
}

// TestChainDisplacedFileRestoresFromDisplacedEntry: DisplacedEntry must be
// usable for undo, not merely present. It proves that by actually
// restoring the displaced file from the Trash and checking its content,
// not just that the field is non-empty. The displaced file sits at its own
// path, distinct from the step's own Dst: were the two the same (the
// ordinary overwrite shape), the mover's own file would already occupy
// that name by the time Restore ran, and Restore correctly refuses to land
// on an occupied path — this test isolates DisplacedEntry's own round-trip
// instead of also exercising that refusal.
func TestChainDisplacedFileRestoresFromDisplacedEntry(t *testing.T) {
	root := t.TempDir()
	t.Setenv("HOME", root)
	t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
	t.Setenv("XDG_STATE_HOME", "")
	t.Setenv("XDG_CONFIG_HOME", "")
	t.Setenv("XDG_CACHE_HOME", "")

	src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
	displaced := write(t, filepath.Join(root, "old", "y.pdf"), "displaced content", 0o644)
	dst := filepath.Join(root, "W", "x.pdf")

	c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst, Displaces: displaced})

	got := Chain(c)
	if got[0].Status != "ok" {
		t.Fatalf("step = %+v", got[0])
	}
	if got[0].DisplacedEntry == "" {
		t.Fatal("DisplacedEntry is empty; undo has no way to find the displaced file")
	}
	if got[0].Entry != "" {
		t.Errorf("Entry = %q, want empty: this step is a Move, not itself a Trash step", got[0].Entry)
	}
	if _, err := os.Stat(displaced); !os.IsNotExist(err) {
		t.Error("the displaced file is still at its old path")
	}
	if b, err := os.ReadFile(dst); err != nil || string(b) != "mine" {
		t.Errorf("the move's own destination = %q, %v", b, err)
	}

	restored, err := trash.Restore(got[0].DisplacedEntry)
	if err != nil {
		t.Fatalf("Restore(%q): %v", got[0].DisplacedEntry, err)
	}
	if restored != displaced {
		t.Errorf("restored = %q, want %q", restored, displaced)
	}
	if b, err := os.ReadFile(restored); err != nil || string(b) != "displaced content" {
		t.Errorf("restored content = %q, %v; want the displaced file's own content", b, err)
	}
}

// TestChainRunsRenameStep: apply_test.go's only other Rename (in
// TestChainStopsWhenFileChanged) is always reported "skipped", because the
// Move before it is made to fail on purpose, so "case plan.Rename: err =
// os.Rename(step.Src, dst)" is never exercised by a passing test. A
// reversed-argument typo there would compile, pass every other test, pass
// make ci, and surface only as live data corruption.
func TestChainRunsRenameStep(t *testing.T) {
	root := t.TempDir()
	src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
	dst := filepath.Join(root, "y.pdf")
	c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Rename, Rule: "a", Src: src, Dst: dst})

	got := Chain(c)
	if got[0].Status != "ok" {
		t.Fatalf("step = %+v", got[0])
	}
	if _, err := os.Stat(src); !os.IsNotExist(err) {
		t.Error("the old name still exists after a successful rename")
	}
	if b, err := os.ReadFile(dst); err != nil || string(b) != "content" {
		t.Errorf("the new name = %q, %v; want the original content at the new name", b, err)
	}
}

// TestChainMadeIsOutermostFirstForNestedDirectories: every other test
// creates at most one missing directory level, so mkdirAllTracked's
// outermost-first ordering is correct by trace but unpinned by any
// assertion. Undo removes these directories in reverse, so a later
// accidental reordering would break it while passing everything else
// here.
func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) {
	root := t.TempDir()
	write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
	dst := filepath.Join(root, "A", "B", "x.pdf")
	c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "a", Src: filepath.Join(root, "x.pdf"), Dst: dst})

	got := Chain(c)
	if got[0].Status != "ok" {
		t.Fatalf("step = %+v", got[0])
	}
	want := []string{filepath.Join(root, "A"), filepath.Join(root, "A", "B")}
	if !slices.Equal(got[0].Made, want) {
		t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want)
	}
}

// TestMoveFileRefusesOccupiedDestination: moveFile must refuse an occupied
// destination on its own, not merely rely on runFileStep having already
// checked - trusting that some caller had checked is exactly what let a
// silently replacing helper cause harm. Called directly, bypassing
// runFileStep's own pre-check entirely.
func TestMoveFileRefusesOccupiedDestination(t *testing.T) {
	dir := t.TempDir()
	src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
	dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)

	if err := moveFile(src, dst); err == nil {
		t.Fatal("moveFile overwrote an existing destination")
	}
	if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
		t.Errorf("moveFile touched its source: %q, %v", b, err)
	}
	if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
		t.Errorf("moveFile touched its destination: %q, %v", b, err)
	}
}

// TestRenameFileRefusesOccupiedDestination: runFileStep's bare os.Rename
// call for the Rename kind was just as unguarded in itself as moveFile
// was. renameFile is the helper that now carries the same independent
// guard, called directly here.
func TestRenameFileRefusesOccupiedDestination(t *testing.T) {
	dir := t.TempDir()
	src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
	dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)

	if err := renameFile(src, dst); err == nil {
		t.Fatal("renameFile overwrote an existing destination")
	}
	if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
		t.Errorf("renameFile touched its source: %q, %v", b, err)
	}
	if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
		t.Errorf("renameFile touched its destination: %q, %v", b, err)
	}
}