summaryrefslogtreecommitdiff
path: root/internal/apply/swap_test.go
blob: dd7cc078a2912cd542138712e4424eaf2701b40e (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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
// SPDX-License-Identifier: GPL-3.0-or-later

package apply

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// planned writes body to root/rel and returns the chain a plan would build
// for it, identity included, with steps.
func planned(t *testing.T, root, rel, body string, steps ...plan.Step) plan.Chain {
	t.Helper()
	p := filepath.Join(root, rel)
	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	info, err := os.Lstat(p)
	if err != nil {
		t.Fatal(err)
	}
	return plan.Chain{File: scan.NewFile(p, rel, info), Steps: steps}
}

// TestChainRefusesSourceSwappedForSymlink: a file replaced by a symlink
// between plan and apply - even to a file of the same size and modification
// time - is not acted on, so a copy never reads through the link.
func TestChainRefusesSourceSwappedForSymlink(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.txt")
	out := filepath.Join(root, "Out", "a.txt")
	c := planned(t, root, "a.txt", "12345", plan.Step{Kind: plan.Copy, Src: p, Dst: out})
	target := filepath.Join(root, "secret.txt")
	if err := os.WriteFile(target, []byte("54321"), 0o600); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(target, c.File.ModTime, c.File.ModTime); err != nil {
		t.Fatal(err)
	}
	if err := os.Remove(p); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(target, p); err != nil {
		t.Fatal(err)
	}
	res := Chain(c)
	if res[0].Status != "failed" || !strings.HasPrefix(res[0].Detail, "changed since plan") {
		t.Fatalf("step = %s %q, want failed: changed since plan", res[0].Status, res[0].Detail)
	}
	if _, err := os.Lstat(out); !os.IsNotExist(err) {
		t.Errorf("the copy was made through the symlink: %v", err)
	}
}

// TestChainRefusesSourceReplacedByAnotherFile: another file renamed into
// the planned path, with the same size and modification time, has another
// inode: it is not the file that was planned.
func TestChainRefusesSourceReplacedByAnotherFile(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.txt")
	c := planned(t, root, "a.txt", "12345", plan.Step{Kind: plan.Move, Src: p, Dst: filepath.Join(root, "Out", "a.txt")})
	other := filepath.Join(root, "other.txt")
	if err := os.WriteFile(other, []byte("54321"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(other, c.File.ModTime, c.File.ModTime); err != nil {
		t.Fatal(err)
	}
	if err := os.Rename(other, p); err != nil {
		t.Fatal(err)
	}
	res := Chain(c)
	if res[0].Status != "failed" || !strings.HasPrefix(res[0].Detail, "changed since plan") {
		t.Fatalf("step = %s %q, want failed: changed since plan", res[0].Status, res[0].Detail)
	}
	if b, err := os.ReadFile(p); err != nil || string(b) != "54321" {
		t.Errorf("the replacement was moved: %q, %v", b, err)
	}
}

// TestChainFollowsItsOwnFile: the file's identity check does not stop a
// chain that renames and then moves the planned file itself.
func TestChainFollowsItsOwnFile(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.txt")
	b := filepath.Join(root, "b.txt")
	c := planned(t, root, "a.txt", "12345",
		plan.Step{Kind: plan.Rename, Src: p, Dst: b},
		plan.Step{Kind: plan.Move, Src: b, Dst: filepath.Join(root, "Out", "b.txt")},
	)
	for i, r := range Chain(c) {
		if r.Status != "ok" {
			t.Errorf("step %d: %s %q", i+1, r.Status, r.Detail)
		}
	}
}

// TestChainStopsWhenAStepLandsElsewhere: a move that found its planned name
// taken at apply time lands at a free name, and every later step - planned
// against the name it did not get - is skipped rather than acting on
// whatever is at the planned path (review M3).
func TestChainStopsWhenAStepLandsElsewhere(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.pdf")
	plannedPath := filepath.Join(root, "W", "a.pdf")
	c := planned(t, root, "a.pdf", "planned-file",
		plan.Step{Kind: plan.Move, Src: p, Dst: plannedPath},
		plan.Step{Kind: plan.DeletePermanent, Src: plannedPath},
	)
	if err := os.MkdirAll(filepath.Dir(plannedPath), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(plannedPath, []byte("OTHER--FILE!"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(plannedPath, c.File.ModTime, c.File.ModTime); err != nil {
		t.Fatal(err)
	}
	res := Chain(c)
	if res[0].Status != "ok" || res[1].Status != "skipped" || !strings.Contains(res[1].Detail, "not the planned") {
		t.Fatalf("steps = %s %q, %s %q; want ok, then skipped naming the planned path", res[0].Status, res[0].Dst, res[1].Status, res[1].Detail)
	}
	if b, err := os.ReadFile(plannedPath); err != nil || string(b) != "OTHER--FILE!" {
		t.Errorf("the file at the planned path was acted on: %q %v", b, err)
	}
}

// TestChainLoggedReportsEachStepBeforeTheNext: done is called for each step
// as soon as it has run - the file is at the first step's destination and
// the second step has not happened yet - so a caller logging from done
// never has a completed step missing from the log (review M9).
func TestChainLoggedReportsEachStepBeforeTheNext(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.pdf")
	moved := filepath.Join(root, "W", "a.pdf")
	renamed := filepath.Join(root, "W", "b.pdf")
	c := planned(t, root, "a.pdf", "body",
		plan.Step{Kind: plan.Move, Src: p, Dst: moved},
		plan.Step{Kind: plan.Rename, Src: moved, Dst: renamed},
	)
	var calls []int
	res, err := ChainLogged(context.Background(), c, func(i int, sr StepResult) error {
		calls = append(calls, i)
		if i == 0 {
			if _, err := os.Lstat(moved); err != nil {
				t.Errorf("at done(0) the move has not happened: %v", err)
			}
			if _, err := os.Lstat(renamed); !os.IsNotExist(err) {
				t.Errorf("at done(0) the rename already happened")
			}
		}
		return nil
	}, nil)
	if err != nil || len(calls) != 2 || res[1].Status != "ok" {
		t.Fatalf("calls %v, err %v, results %+v", calls, err, res)
	}
}

// TestChainRefusesToDisplaceANonRegularTarget: the file overwrite was
// planned to replace is re-checked before it is trashed; a directory now in
// its place is left alone (review M4).
func TestChainRefusesToDisplaceANonRegularTarget(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", "")
	p := filepath.Join(root, "a.pdf")
	target := filepath.Join(root, "W", "a.pdf")
	c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: p, Dst: target, Displaces: target})
	if err := os.MkdirAll(filepath.Join(target, "inside"), 0o755); err != nil {
		t.Fatal(err)
	}
	res := Chain(c)
	if res[0].Status != "failed" {
		t.Fatalf("step = %s %q; want failed", res[0].Status, res[0].Detail)
	}
	if _, err := os.Stat(filepath.Join(target, "inside")); err != nil {
		t.Errorf("the directory was displaced: %v", err)
	}
}

// TestChainLoggedStopsBetweenStepsWhenInterrupted: once the context is
// cancelled, the chain finishes the step it is on and skips the rest, so an
// interrupt stops after the current step, not after the file's whole chain
// (re-review pa F7).
func TestChainLoggedStopsBetweenStepsWhenInterrupted(t *testing.T) {
	root := t.TempDir()
	p := filepath.Join(root, "a.pdf")
	moved := filepath.Join(root, "W", "a.pdf")
	c := planned(t, root, "a.pdf", "body",
		plan.Step{Kind: plan.Move, Src: p, Dst: moved},
		plan.Step{Kind: plan.Rename, Src: moved, Dst: filepath.Join(root, "W", "b.pdf")},
	)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	res, err := ChainLogged(ctx, c, func(i int, sr StepResult) error {
		if i == 0 {
			cancel()
		}
		return nil
	}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if res[0].Status != "ok" || res[1].Status != "skipped" || res[1].Detail != "interrupted" {
		t.Errorf("steps = %s, %s %q; want ok, then skipped as interrupted", res[0].Status, res[1].Status, res[1].Detail)
	}
	if _, err := os.Lstat(moved); err != nil {
		t.Errorf("the finished step was undone or never ran: %v", err)
	}
}

// TestDisplaceIsReportedBeforeTheStepThatNeededIt: trashing the file in the
// way is a destructive act of its own, and it is durable the moment
// trash.Put returns. Reporting it only when the whole step finishes leaves
// a window - the entire data transfer of a copy or a cross-device move -
// in which the user's file is in the Trash with nothing recording that it
// went there. Killed in that window, krino log said "nothing applied".
func TestDisplaceIsReportedBeforeTheStepThatNeededIt(t *testing.T) {
	root := t.TempDir()
	// The Trash must be on the same filesystem as the file being trashed.
	t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
	src := filepath.Join(root, "a.pdf")
	dst := filepath.Join(root, "W", "a.pdf")
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil {
		t.Fatal(err)
	}
	c := planned(t, root, "a.pdf", "incoming",
		plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst},
	)

	var order []string
	res, err := ChainLogged(context.Background(), c,
		func(i int, sr StepResult) error {
			order = append(order, "step")
			return nil
		},
		func(i int, step plan.Step, entry string) error {
			order = append(order, "displace")
			if entry == "" {
				t.Error("the displace was reported with no trash entry")
			}
			// The file is in the Trash already; the copy has not begun.
			if b, err := os.ReadFile(dst); err == nil && string(b) == "incoming" {
				t.Error("the displace was reported only after the copy had run")
			}
			return nil
		})
	if err != nil {
		t.Fatal(err)
	}
	if len(order) != 2 || order[0] != "displace" || order[1] != "step" {
		t.Errorf("order = %v; want the displace reported first", order)
	}
	if res[0].Status != "ok" {
		t.Errorf("step = %+v", res[0])
	}
}

// TestDisplaceThatCannotBeReportedFailsTheStep: if the displace cannot be
// written to the log, the step must not go on to use the name - the user's
// file is already in the Trash and nothing would record it.
func TestDisplaceThatCannotBeReportedFailsTheStep(t *testing.T) {
	root := t.TempDir()
	t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
	src := filepath.Join(root, "a.pdf")
	dst := filepath.Join(root, "W", "a.pdf")
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil {
		t.Fatal(err)
	}
	c := planned(t, root, "a.pdf", "incoming",
		plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst},
	)
	res, err := ChainLogged(context.Background(), c, nil,
		func(i int, step plan.Step, entry string) error {
			return errors.New("log is closed")
		})
	if err != nil {
		t.Fatal(err)
	}
	if res[0].Status != "failed" || !strings.Contains(res[0].Detail, "could not be logged") {
		t.Errorf("step = %+v; want a failure naming the unlogged displace", res[0])
	}
	if res[0].DisplacedEntry == "" {
		t.Error("the failure does not carry the trash entry, so nothing can say where the file went")
	}
	if b, rerr := os.ReadFile(dst); rerr == nil && string(b) == "incoming" {
		t.Error("the copy ran even though the displace could not be logged")
	}
}

// TestSymlinkInsideTheSortedDirectoryStopsTheStep: placeholders are already
// stopped from redirecting a step out of the directory a rule named. A
// symlink is a name too: one planted in the sorted directory - by an
// unpacked archive, say - named after a rule's destination sends the file
// anywhere, while the plan the user approved shows only "Out/".
func TestSymlinkInsideTheSortedDirectoryStopsTheStep(t *testing.T) {
	root := t.TempDir()
	outside := t.TempDir()
	if err := os.Symlink(outside, filepath.Join(root, "Out")); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	src := filepath.Join(root, "a.pdf")
	dst := filepath.Join(root, "Out", "a.pdf")
	c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: src, Dst: dst})
	c.Root = root

	res, err := ChainLogged(context.Background(), c, nil, nil)
	if err != nil {
		t.Fatal(err)
	}
	if res[0].Status != "failed" || !strings.Contains(res[0].Detail, "symlink") {
		t.Errorf("step = %+v; want a failure naming the symlink", res[0])
	}
	if _, err := os.Lstat(filepath.Join(outside, "a.pdf")); err == nil {
		t.Error("the file left the sorted directory through the symlink")
	}
	if _, err := os.Lstat(src); err != nil {
		t.Errorf("the file is no longer where it started: %v", err)
	}
}

// TestASymlinkedDestinationOutsideTheSortedDirectoryIsFine: a destination
// the configuration itself names - "~/docs/work", where ~/docs is a symlink
// to another disk - is the user's own arrangement, not something planted,
// and must keep working.
func TestASymlinkedDestinationOutsideTheSortedDirectoryIsFine(t *testing.T) {
	root := t.TempDir()
	elsewhere := t.TempDir()
	real := filepath.Join(elsewhere, "real")
	if err := os.MkdirAll(real, 0o755); err != nil {
		t.Fatal(err)
	}
	link := filepath.Join(elsewhere, "docs")
	if err := os.Symlink(real, link); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	src := filepath.Join(root, "a.pdf")
	dst := filepath.Join(link, "a.pdf")
	c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: src, Dst: dst})
	c.Root = root

	res, err := ChainLogged(context.Background(), c, nil, nil)
	if err != nil {
		t.Fatal(err)
	}
	if res[0].Status != "ok" {
		t.Fatalf("step = %+v; want it to go through", res[0])
	}
	if _, err := os.Lstat(filepath.Join(real, "a.pdf")); err != nil {
		t.Errorf("the file did not reach the configured destination: %v", err)
	}
}