carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux && rootrunner
2
3package host
4
5import (
6 "bytes"
7 "crypto/sha256"
8 "encoding/hex"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "syscall"
14 "testing"
15
16 "golang.org/x/sys/unix"
17)
18
19// consoleUpgradeFixture is a protected console directory installed by
20// install-console with build A, and source files for builds A to D.
21type consoleUpgradeFixture struct {
22 t *testing.T
23 d, dir string
24 src map[string]string // build name to source path
25 body map[string][]byte
26 sha map[string]string
27}
28
29func newConsoleUpgradeFixture(t *testing.T) *consoleUpgradeFixture {
30 t.Helper()
31 d := activationRootFixture(t)
32 f := &consoleUpgradeFixture{t: t, d: d, dir: filepath.Join(d, "opt", "system", "console"), src: map[string]string{}, body: map[string][]byte{}, sha: map[string]string{}}
33 f.check(os.Chmod(d, 0o755))
34 f.check(os.MkdirAll(filepath.Join(d, "src"), 0o755))
35 for _, name := range []string{"A", "B", "C", "D"} {
36 f.body[name] = []byte("console build " + name)
37 f.sha[name] = artifactSHA(f.body[name])
38 f.src[name] = filepath.Join(d, "src", name)
39 f.check(os.WriteFile(f.src[name], f.body[name], 0o755))
40 }
41 if got, err := installProtectedConsole(f.dir, f.src["A"], f.sha["A"], false); err != nil || got.Already {
42 t.Fatal("first install", got, err)
43 }
44 f.want("A", "A")
45 return f
46}
47
48func (f *consoleUpgradeFixture) check(err error) {
49 f.t.Helper()
50 if err != nil {
51 f.t.Fatal(err)
52 }
53}
54
55func (f *consoleUpgradeFixture) path(name string) string { return filepath.Join(f.dir, name) }
56
57// slot requires name to be a root:root 0755 one-link regular file holding
58// build b.
59func (f *consoleUpgradeFixture) slot(name, b string) {
60 f.t.Helper()
61 got, err := os.ReadFile(f.path(name))
62 st, serr := os.Lstat(f.path(name))
63 if err != nil || serr != nil || !bytes.Equal(got, f.body[b]) {
64 f.t.Fatalf("%s: want build %s, got %q %v %v", name, b, got, err, serr)
65 }
66 sys := st.Sys().(*syscall.Stat_t)
67 if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 {
68 f.t.Fatalf("%s mode %v uid %d gid %d links %d", name, st.Mode(), sys.Uid, sys.Gid, sys.Nlink)
69 }
70}
71
72// entries lists the directory's names.
73func (f *consoleUpgradeFixture) entries() string {
74 f.t.Helper()
75 list, err := os.ReadDir(f.dir)
76 f.check(err)
77 var names []string
78 for _, e := range list {
79 names = append(names, e.Name())
80 }
81 sort.Strings(names)
82 return strings.Join(names, ",")
83}
84
85// want requires console to hold current, console.prev to hold prev and
86// nothing else in the directory.
87func (f *consoleUpgradeFixture) want(current, prev string) {
88 f.t.Helper()
89 f.slot("console", current)
90 f.slot("console.prev", prev)
91 if got := f.entries(); got != "console,console.prev" {
92 f.t.Fatal("directory holds", got)
93 }
94}
95
96// upgrade runs the upgrade to build to, from the sha of build from.
97func (f *consoleUpgradeFixture) upgrade(to, from string, dry bool) (consoleInstalled, error) {
98 return upgradeProtectedConsole(f.dir, f.src[to], f.sha[to], f.sha[from], dry)
99}
100
101// refused requires the upgrade to build to from build from to refuse, dry
102// and not, and the directory listing to be unchanged by either.
103func (f *consoleUpgradeFixture) refused(what, to, from string) {
104 f.t.Helper()
105 before := f.entries()
106 for _, dry := range []bool{true, false} {
107 if got, err := f.upgrade(to, from, dry); err == nil {
108 f.t.Fatal("accepted", what, dry, got)
109 }
110 if after := f.entries(); after != before {
111 f.t.Fatal(what, "changed the directory", before, after)
112 }
113 }
114}
115
116// TestRootActivationConsoleUpgrade is install-console --upgrade as real
117// root: the swap, its refusals, crash recovery, a leftover temp file, the
118// Already rerun and the dry run.
119func TestRootActivationConsoleUpgrade(t *testing.T) {
120 f := newConsoleUpgradeFixture(t)
121
122 // A wrong --from refuses and changes nothing; so does a malformed one.
123 f.refused("wrong from", "B", "C")
124 f.want("A", "A")
125 for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} {
126 for _, dry := range []bool{true, false} {
127 if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil {
128 t.Fatalf("accepted from %q", from)
129 }
130 }
131 }
132 // A source that does not hash to the target refuses.
133 if _, err := upgradeProtectedConsole(f.dir, f.src["C"], f.sha["B"], f.sha["A"], false); err == nil {
134 t.Fatal("accepted another source")
135 }
136 f.want("A", "A")
137
138 // The dry run checks and reports, and changes nothing.
139 got, err := f.upgrade("B", "A", true)
140 if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] || got.Path != f.path("console") || got.Previous != f.path("console.prev") {
141 t.Fatal("dry run", got, err)
142 }
143 f.want("A", "A")
144
145 // Upgrade A to B: prev is A, current is B, no temp file left.
146 got, err = f.upgrade("B", "A", false)
147 if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] {
148 t.Fatal("upgrade", got, err)
149 }
150 f.want("B", "A")
151
152 // A rerun is Already and changes nothing, dry or not.
153 for _, dry := range []bool{true, false} {
154 got, err = f.upgrade("B", "A", dry)
155 if err != nil || !got.Already || got.PreviousSHA != f.sha["A"] {
156 t.Fatal("rerun", dry, got, err)
157 }
158 f.want("B", "A")
159 }
160
161 // A malformed --from refuses even when the console is already current.
162 for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} {
163 for _, dry := range []bool{true, false} {
164 if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil {
165 t.Fatalf("Already with from %q", from)
166 }
167 }
168 }
169
170 // Without --upgrade an occupied slot still refuses, as before.
171 for _, b := range []string{"B", "C"} {
172 for _, dry := range []bool{true, false} {
173 if _, err := installProtectedConsole(f.dir, f.src[b], f.sha[b], dry); err == nil {
174 t.Fatal("first install over an installed console", b, dry)
175 }
176 }
177 }
178 f.want("B", "A")
179
180 // The current console's shape: each refuses, nothing changes.
181 cur := f.path("console")
182 shape := func(what string, setup, undo func()) {
183 t.Helper()
184 setup()
185 f.refused(what, "C", "B")
186 undo()
187 f.want("B", "A")
188 }
189 shape("current symlink", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Symlink(cur+".real", cur)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) })
190 shape("current not root's", func() { f.check(os.Chown(cur, 1000, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) })
191 shape("current owner not root", func() { f.check(os.Chown(cur, 1000, 0)) }, func() { f.check(os.Chown(cur, 0, 0)) })
192 shape("current group not root", func() { f.check(os.Chown(cur, 0, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) })
193 shape("current two links", func() { f.check(os.Link(cur, cur+".link")) }, func() { f.check(os.Remove(cur + ".link")) })
194 shape("current group writable", func() { f.check(os.Chmod(cur, 0o775)) }, func() { f.check(os.Chmod(cur, 0o755)) })
195 shape("current not executable by others", func() { f.check(os.Chmod(cur, 0o750)) }, func() { f.check(os.Chmod(cur, 0o755)) })
196 shape("current directory", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Mkdir(cur, 0o755)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) })
197
198 // console.prev's shape: a symlink, a directory or the wrong owner each
199 // refuses.
200 prev := f.path("console.prev")
201 shape("prev symlink", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Symlink(prev+".real", prev)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) })
202 shape("prev directory", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Mkdir(prev, 0o755)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) })
203 shape("prev not root's", func() { f.check(os.Chown(prev, 1000, 1000)) }, func() { f.check(os.Chown(prev, 0, 0)) })
204 shape("prev owner not root", func() { f.check(os.Chown(prev, 1000, 0)) }, func() { f.check(os.Chown(prev, 0, 0)) })
205 shape("prev two links", func() { f.check(os.Link(prev, prev+".link")) }, func() { f.check(os.Remove(prev + ".link")) })
206 shape("prev group writable", func() { f.check(os.Chmod(prev, 0o775)) }, func() { f.check(os.Chmod(prev, 0o755)) })
207
208 // console.prev's bytes are not checked: it is about to be replaced.
209 f.check(os.WriteFile(prev+".x", []byte("an older build"), 0o755))
210 f.check(os.Rename(prev+".x", prev))
211 if got, err = f.upgrade("C", "B", false); err != nil || got.Already || got.PreviousSHA != f.sha["B"] {
212 t.Fatal("upgrade over any prev", got, err)
213 }
214 f.want("C", "B")
215
216 // A run cut off after renaming console to console.prev: console is
217 // absent, console.prev holds the old build and console.next the new.
218 f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["D"], 0o755))
219 f.check(os.Rename(cur, prev))
220 if got := f.entries(); got != "console.next,console.prev" {
221 t.Fatal("crash state", got)
222 }
223 f.refused("crash state, wrong from", "D", "B")
224 f.refused("crash state, from the new build", "D", "D")
225 if got, err = f.upgrade("D", "C", true); err != nil || got.Already || got.PreviousSHA != f.sha["C"] {
226 t.Fatal("crash state dry run", got, err)
227 }
228 if got := f.entries(); got != "console.next,console.prev" {
229 t.Fatal("crash state dry run changed", got)
230 }
231 if got, err = f.upgrade("D", "C", false); err != nil || got.Already || got.From != f.sha["C"] || got.PreviousSHA != f.sha["C"] {
232 t.Fatal("crash recovery", got, err)
233 }
234 f.want("D", "C")
235
236 // With both slots gone there is nothing to upgrade from.
237 f.check(os.Rename(cur, cur+".keep"))
238 f.check(os.Rename(prev, prev+".keep"))
239 f.refused("no console", "A", "D")
240 f.refused("no console, from prev", "A", "C")
241 f.check(os.Rename(cur+".keep", cur))
242 f.check(os.Rename(prev+".keep", prev))
243
244 // A run cut off before its renames leaves a partial console.next: the
245 // dry run leaves it, a rerun removes it and completes.
246 f.check(os.WriteFile(f.path(consoleUpgradeTemp), []byte("partial"), 0o600))
247 if _, err = f.upgrade("A", "D", true); err != nil {
248 t.Fatal("dry run over a leftover", err)
249 }
250 if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); string(b) != "partial" {
251 t.Fatal("dry run touched the leftover")
252 }
253 if got, err = f.upgrade("A", "D", false); err != nil || got.Already || got.PreviousSHA != f.sha["D"] {
254 t.Fatal("upgrade over a leftover", got, err)
255 }
256 f.want("A", "D")
257
258 // A console.next that is not a regular file refuses and is left.
259 f.check(os.Mkdir(f.path(consoleUpgradeTemp), 0o700))
260 f.refused("console.next directory", "B", "A")
261 f.check(os.Remove(f.path(consoleUpgradeTemp)))
262 f.check(os.Symlink(f.src["B"], f.path(consoleUpgradeTemp)))
263 f.refused("console.next symlink", "B", "A")
264 f.check(os.Remove(f.path(consoleUpgradeTemp)))
265 f.want("A", "D")
266
267 // An upgrade never creates the directory.
268 missing := filepath.Join(f.d, "opt", "system", "other")
269 for _, dry := range []bool{true, false} {
270 if _, err := upgradeProtectedConsole(missing, f.src["B"], f.sha["B"], f.sha["A"], dry); err == nil {
271 t.Fatal("upgraded a missing directory", dry)
272 }
273 }
274 if _, err := os.Lstat(missing); !os.IsNotExist(err) {
275 t.Fatal("upgrade created the directory", err)
276 }
277
278 // A group-writable directory refuses, as for the first install.
279 f.check(os.Chmod(f.dir, 0o775))
280 f.refused("group-writable directory", "B", "A")
281 f.check(os.Chmod(f.dir, 0o755))
282 f.want("A", "D")
283}
284
285// TestRootActivationConsoleUpgradeIdentity runs the upgrade through
286// runInstallConsole: the staged identity must be the published one and
287// name the running build, or nothing changes.
288func TestRootActivationConsoleUpgradeIdentity(t *testing.T) {
289 f := newConsoleUpgradeFixture(t)
290 w := newArtifactWorld(t)
291 identity := w.run(t, w.identityPlan(t)).Output
292 idPath := filepath.Join(f.d, "identity.json")
293 f.check(os.WriteFile(idPath, identity, 0o644))
294 f.body["I"] = []byte("console build")
295 f.sha["I"] = artifactSHA(f.body["I"])
296 f.src["I"] = filepath.Join(f.d, "src", "I")
297 f.check(os.WriteFile(f.src["I"], f.body["I"], 0o755))
298
299 for _, build := range []string{"v0.28.1", "v0.28.3", ""} {
300 for _, dry := range []bool{true, false} {
301 if _, _, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, build, "linux-arm64", f.sha["A"], dry); err == nil {
302 t.Fatal("accepted build", build, got)
303 }
304 }
305 }
306 for _, dry := range []bool{true, false} {
307 if _, _, _, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-amd64", f.sha["A"], dry); err == nil {
308 t.Fatal("accepted another platform")
309 }
310 }
311 f.want("A", "A")
312
313 tag, sha, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], true)
314 if err != nil || tag != "v0.28.2" || sha != f.sha["I"] || got.Already || got.PreviousSHA != f.sha["A"] {
315 t.Fatal("dry run", tag, sha, got, err)
316 }
317 f.want("A", "A")
318 if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || got.Already {
319 t.Fatal("upgrade", got, err)
320 }
321 f.want("I", "A")
322 if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || !got.Already {
323 t.Fatal("rerun", got, err)
324 }
325 // Without --upgrade the installed console is still refused.
326 if _, _, _, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", "", false); err == nil {
327 t.Fatal("first install over an upgraded console")
328 }
329 f.want("I", "A")
330}
331
332// TestRootActivationConsoleUpgradeSourceAlias: an upgrade never takes its
333// source from its own slots or temp file, whether named by a path inside
334// the directory or reached through a bind mount of it (same device and
335// inode, another path). Each refuses with nothing changed.
336func TestRootActivationConsoleUpgradeSourceAlias(t *testing.T) {
337 f := newConsoleUpgradeFixture(t)
338 // console = A, console.prev = B: the target B is in console.prev.
339 prev := f.path("console.prev")
340 f.check(os.WriteFile(prev+".x", f.body["B"], 0o755))
341 f.check(os.Rename(prev+".x", prev))
342 alias := filepath.Join(f.d, "alias")
343 f.check(os.Mkdir(alias, 0o755))
344 f.check(unix.Mount(f.dir, alias, "", unix.MS_BIND, ""))
345 t.Cleanup(func() { unix.Unmount(alias, unix.MNT_DETACH) })
346
347 refuse := func(what, source, to, from string) {
348 t.Helper()
349 before := f.entries()
350 for _, dry := range []bool{true, false} {
351 if got, err := upgradeProtectedConsole(f.dir, source, f.sha[to], f.sha[from], dry); err == nil {
352 t.Fatal("accepted", what, dry, got)
353 }
354 if after := f.entries(); after != before {
355 t.Fatal(what, "changed the directory", before, after)
356 }
357 }
358 }
359 refuse("console.prev by path", prev, "B", "A")
360 refuse("console.prev through a bind mount", filepath.Join(alias, "console.prev"), "B", "A")
361 refuse("console by path", f.path("console"), "A", "B")
362 refuse("console through a bind mount", filepath.Join(alias, "console"), "A", "B")
363 // Another file inside the directory, not a slot, is refused by path.
364 f.check(os.WriteFile(f.path("console.src"), f.body["C"], 0o755))
365 refuse("a file in the directory", f.path("console.src"), "C", "A")
366 f.check(os.Remove(f.path("console.src")))
367
368 // A leftover console.next holding the target.
369 f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["C"], 0o755))
370 refuse("console.next by path", f.path(consoleUpgradeTemp), "C", "A")
371 refuse("console.next through a bind mount", filepath.Join(alias, consoleUpgradeTemp), "C", "A")
372 if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); !bytes.Equal(b, f.body["C"]) {
373 t.Fatal("console.next changed")
374 }
375 f.check(os.Remove(f.path(consoleUpgradeTemp)))
376 f.slot("console", "A")
377 f.slot("console.prev", "B")
378
379 // The same target from a source outside the directory upgrades.
380 if got, err := f.upgrade("B", "A", false); err != nil || got.Already {
381 t.Fatal("upgrade from an outside source", got, err)
382 }
383 f.want("B", "A")
384}
385
386// TestRootActivationConsoleUpgradeAlreadySyncs: a run cut off after its
387// second rename, before the directory fsync, leaves console = target; the
388// rerun is Already and fsyncs the directory first, and a failed fsync is
389// the rerun's error. A dry rerun does not sync.
390func TestRootActivationConsoleUpgradeAlreadySyncs(t *testing.T) {
391 f := newConsoleUpgradeFixture(t)
392 var dirSt unix.Stat_t
393 f.check(unix.Stat(f.dir, &dirSt))
394 var calls int
395 var fail error
396 saved := consoleUpgradeSync
397 t.Cleanup(func() { consoleUpgradeSync = saved })
398 consoleUpgradeSync = func(fd int) error {
399 var st unix.Stat_t
400 if err := unix.Fstat(fd, &st); err != nil || st.Dev != dirSt.Dev || st.Ino != dirSt.Ino {
401 t.Fatal("synced something other than the directory", err)
402 }
403 calls++
404 if fail != nil {
405 return fail
406 }
407 return saved(fd)
408 }
409
410 // The state just before the final fsync.
411 f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["B"], 0o755))
412 f.check(os.Rename(f.path("console"), f.path("console.prev")))
413 f.check(os.Rename(f.path(consoleUpgradeTemp), f.path("console")))
414 f.want("B", "A")
415
416 if got, err := f.upgrade("B", "A", true); err != nil || !got.Already || calls != 0 {
417 t.Fatal("dry rerun", got, err, calls)
418 }
419 if got, err := f.upgrade("B", "A", false); err != nil || !got.Already || calls != 1 {
420 t.Fatal("rerun", got, err, calls)
421 }
422 fail = syscall.EIO
423 if got, err := f.upgrade("B", "A", false); err != syscall.EIO || calls != 2 {
424 t.Fatal("rerun with a failing fsync", got, err, calls)
425 }
426 fail = nil
427 f.want("B", "A")
428
429 // A full upgrade syncs once, after its renames.
430 if got, err := f.upgrade("C", "B", false); err != nil || got.Already || calls != 3 {
431 t.Fatal("upgrade", got, err, calls)
432 }
433 f.want("C", "B")
434}
435
436// TestRootActivationConsoleUpgradeOversizedSlot: a slot larger than
437// consoleMaxBytes is refused, not hashed by its prefix, so --from naming
438// the prefix's digest cannot match it.
439func TestRootActivationConsoleUpgradeOversizedSlot(t *testing.T) {
440 f := newConsoleUpgradeFixture(t)
441 cur := f.path("console")
442 big := cur + ".big"
443 f.check(os.WriteFile(big, nil, 0o755))
444 f.check(os.Truncate(big, consoleMaxBytes+2)) // sparse: zeros
445 f.check(os.Rename(big, cur))
446 prefix := zeroDigest(t, consoleMaxBytes+1) // what a LimitReader(max+1) hashes
447 f.sha["prefix"] = prefix
448 f.refused("oversized console", "B", "prefix")
449 if _, err := os.Lstat(cur); err != nil {
450 t.Fatal(err)
451 }
452 // As console.prev in the crash state it is refused too.
453 f.check(os.Rename(cur, f.path("console.prev")))
454 f.refused("oversized console.prev", "B", "prefix")
455}
456
457// zeroDigest is the sha256 of n zero bytes.
458func zeroDigest(t *testing.T, n int64) string {
459 t.Helper()
460 h := sha256.New()
461 buf := make([]byte, 1<<20)
462 for n > 0 {
463 k := int64(len(buf))
464 if n < k {
465 k = n
466 }
467 h.Write(buf[:k])
468 n -= k
469 }
470 return hex.EncodeToString(h.Sum(nil))
471}
472