| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "syscall" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // TestRootActivationConsoleInstall is install-console's copy step as real |
| 14 | // root: modes, ownership, checked ancestry, symlinks, extra links, partial |
| 15 | // state, dry runs and refusals that leave everything as it was. |
| 16 | func TestRootActivationConsoleInstall(t *testing.T) { |
| 17 | d := activationRootFixture(t) |
| 18 | check := func(err error) { |
| 19 | t.Helper() |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | } |
| 24 | // The fixture root is created 0700; the service user must be able to |
| 25 | // search every ancestor of the protected copy. |
| 26 | check(os.Chmod(d, 0o755)) |
| 27 | body := []byte("console build abc1234") |
| 28 | sha := artifactSHA(body) |
| 29 | source := filepath.Join(d, "src", "console") |
| 30 | check(os.MkdirAll(filepath.Dir(source), 0o755)) |
| 31 | check(os.WriteFile(source, body, 0o755)) |
| 32 | check(os.Chown(source, 1000, 1000)) // a carlos-owned versions tree |
| 33 | other := filepath.Join(d, "src", "other") |
| 34 | check(os.WriteFile(other, []byte("console build def5678"), 0o755)) |
| 35 | absent := func(p string) { |
| 36 | t.Helper() |
| 37 | if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| 38 | t.Fatal("present", p, err) |
| 39 | } |
| 40 | } |
| 41 | slot := func(p string) { |
| 42 | t.Helper() |
| 43 | got, err := os.ReadFile(p) |
| 44 | st, serr := os.Lstat(p) |
| 45 | if err != nil || serr != nil || !bytes.Equal(got, body) { |
| 46 | t.Fatal("slot", p, err, serr) |
| 47 | } |
| 48 | sys := st.Sys().(*syscall.Stat_t) |
| 49 | if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 { |
| 50 | t.Fatalf("slot %s mode %v uid %d gid %d links %d", p, st.Mode(), sys.Uid, sys.Gid, sys.Nlink) |
| 51 | } |
| 52 | } |
| 53 | dir := filepath.Join(d, "opt", "system", "console") |
| 54 | |
| 55 | // Bytes that do not match, and a symlinked source, are refused before |
| 56 | // anything is created. |
| 57 | link := filepath.Join(d, "src", "link") |
| 58 | check(os.Symlink(source, link)) |
| 59 | check(os.Symlink(filepath.Join(d, "src"), filepath.Join(d, "srclink"))) |
| 60 | for _, src := range []string{other, link, filepath.Join(d, "srclink", "console"), "relative/console", source + "/../src/console"} { |
| 61 | for _, dry := range []bool{true, false} { |
| 62 | if _, err := installProtectedConsole(dir, src, sha, dry); err == nil { |
| 63 | t.Fatal("accepted source", src, dry) |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | absent(filepath.Join(d, "opt")) |
| 68 | |
| 69 | // A dry run with nothing there writes nothing. |
| 70 | if _, err := installProtectedConsole(dir, source, sha, true); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | absent(filepath.Join(d, "opt")) |
| 74 | |
| 75 | got, err := installProtectedConsole(dir, source, sha, false) |
| 76 | if err != nil || got.Already || got.Path != filepath.Join(dir, "console") || got.Previous != filepath.Join(dir, "console.prev") { |
| 77 | t.Fatal(got, err) |
| 78 | } |
| 79 | slot(got.Path) |
| 80 | slot(got.Previous) |
| 81 | for _, p := range []string{filepath.Join(d, "opt"), filepath.Join(d, "opt", "system"), dir} { |
| 82 | st, err := os.Stat(p) |
| 83 | if err != nil || st.Mode().Perm() != 0o755 || st.Sys().(*syscall.Stat_t).Uid != 0 { |
| 84 | t.Fatal("directory", p, st.Mode(), err) |
| 85 | } |
| 86 | } |
| 87 | entries, _ := os.ReadDir(dir) |
| 88 | if len(entries) != 2 { |
| 89 | t.Fatal("staging left behind", entries) |
| 90 | } |
| 91 | if again, err := installProtectedConsole(dir, source, sha, false); err != nil || !again.Already { |
| 92 | t.Fatal("rerun", again, err) |
| 93 | } |
| 94 | if again, err := installProtectedConsole(dir, source, sha, true); err != nil || !again.Already { |
| 95 | t.Fatal("dry rerun", again, err) |
| 96 | } |
| 97 | |
| 98 | // A run cut off after .prev: the next run completes it. |
| 99 | check(os.Remove(got.Path)) |
| 100 | if again, err := installProtectedConsole(dir, source, sha, false); err != nil || again.Already { |
| 101 | t.Fatal("partial", again, err) |
| 102 | } |
| 103 | slot(got.Path) |
| 104 | |
| 105 | // Anything else in a slot is refused and left as it is. |
| 106 | refuse := func(name string, setup, undo func()) { |
| 107 | t.Helper() |
| 108 | setup() |
| 109 | for _, dry := range []bool{true, false} { |
| 110 | if _, err := installProtectedConsole(dir, source, sha, dry); err == nil { |
| 111 | t.Fatal("accepted", name, dry) |
| 112 | } |
| 113 | } |
| 114 | undo() |
| 115 | slot(got.Previous) |
| 116 | } |
| 117 | foreign := []byte("someone else's console") |
| 118 | refuse("other bytes", func() { check(os.WriteFile(got.Path+".x", foreign, 0o755)); check(os.Rename(got.Path+".x", got.Path)) }, func() { |
| 119 | if b, _ := os.ReadFile(got.Path); !bytes.Equal(b, foreign) { |
| 120 | t.Fatal("refusal replaced the slot") |
| 121 | } |
| 122 | check(os.Remove(got.Path)) |
| 123 | }) |
| 124 | refuse("symlink", func() { check(os.Symlink(source, got.Path)) }, func() { check(os.Remove(got.Path)) }) |
| 125 | refuse("extra link", func() { check(os.Link(got.Previous, got.Path)) }, func() { check(os.Remove(got.Path)) }) |
| 126 | refuse("group writable", func() { check(os.Chmod(got.Previous, 0o775)) }, func() { check(os.Chmod(got.Previous, 0o755)) }) |
| 127 | refuse("not root's", func() { check(os.Chown(got.Previous, 1000, 1000)) }, func() { check(os.Chown(got.Previous, 0, 0)) }) |
| 128 | |
| 129 | // Ancestry: a writable or unsearchable directory, or a symlinked one. |
| 130 | refuse("group-writable directory", func() { check(os.Chmod(dir, 0o775)) }, func() { check(os.Chmod(dir, 0o755)) }) |
| 131 | refuse("private directory", func() { check(os.Chmod(dir, 0o700)) }, func() { check(os.Chmod(dir, 0o755)) }) |
| 132 | refuse("world-writable ancestor", func() { check(os.Chmod(filepath.Join(d, "opt"), 0o777)) }, func() { check(os.Chmod(filepath.Join(d, "opt"), 0o755)) }) |
| 133 | refuse("carlos-owned ancestor", func() { check(os.Chown(filepath.Join(d, "opt"), 1000, 1000)) }, func() { check(os.Chown(filepath.Join(d, "opt"), 0, 0)) }) |
| 134 | moved := filepath.Join(d, "opt", "system", "real") |
| 135 | refuse("symlinked directory", func() { check(os.Rename(dir, moved)); check(os.Symlink(moved, dir)) }, func() { check(os.Remove(dir)); check(os.Rename(moved, dir)) }) |
| 136 | if again, err := installProtectedConsole(dir, source, sha, false); err != nil { |
| 137 | t.Fatal("restored state", again, err) |
| 138 | } |
| 139 | slot(got.Path) |
| 140 | slot(got.Previous) |
| 141 | } |
| 142 | |