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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package apply
import (
"os"
"path/filepath"
"strings"
"testing"
"krino/internal/plan"
"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(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
})
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)
}
}
|