//go:build linux && rootrunner package host import ( "bytes" "os" "path/filepath" "syscall" "testing" ) // TestRootActivationConsoleInstall is install-console's copy step as real // root: modes, ownership, checked ancestry, symlinks, extra links, partial // state, dry runs and refusals that leave everything as it was. func TestRootActivationConsoleInstall(t *testing.T) { d := activationRootFixture(t) check := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } // The fixture root is created 0700; the service user must be able to // search every ancestor of the protected copy. check(os.Chmod(d, 0o755)) body := []byte("console build abc1234") sha := artifactSHA(body) source := filepath.Join(d, "src", "console") check(os.MkdirAll(filepath.Dir(source), 0o755)) check(os.WriteFile(source, body, 0o755)) check(os.Chown(source, 1000, 1000)) // a carlos-owned versions tree other := filepath.Join(d, "src", "other") check(os.WriteFile(other, []byte("console build def5678"), 0o755)) absent := func(p string) { t.Helper() if _, err := os.Lstat(p); !os.IsNotExist(err) { t.Fatal("present", p, err) } } slot := func(p string) { t.Helper() got, err := os.ReadFile(p) st, serr := os.Lstat(p) if err != nil || serr != nil || !bytes.Equal(got, body) { t.Fatal("slot", p, err, serr) } sys := st.Sys().(*syscall.Stat_t) if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 { t.Fatalf("slot %s mode %v uid %d gid %d links %d", p, st.Mode(), sys.Uid, sys.Gid, sys.Nlink) } } dir := filepath.Join(d, "opt", "system", "console") // Bytes that do not match, and a symlinked source, are refused before // anything is created. link := filepath.Join(d, "src", "link") check(os.Symlink(source, link)) check(os.Symlink(filepath.Join(d, "src"), filepath.Join(d, "srclink"))) for _, src := range []string{other, link, filepath.Join(d, "srclink", "console"), "relative/console", source + "/../src/console"} { for _, dry := range []bool{true, false} { if _, err := installProtectedConsole(dir, src, sha, dry); err == nil { t.Fatal("accepted source", src, dry) } } } absent(filepath.Join(d, "opt")) // A dry run with nothing there writes nothing. if _, err := installProtectedConsole(dir, source, sha, true); err != nil { t.Fatal(err) } absent(filepath.Join(d, "opt")) got, err := installProtectedConsole(dir, source, sha, false) if err != nil || got.Already || got.Path != filepath.Join(dir, "console") || got.Previous != filepath.Join(dir, "console.prev") { t.Fatal(got, err) } slot(got.Path) slot(got.Previous) for _, p := range []string{filepath.Join(d, "opt"), filepath.Join(d, "opt", "system"), dir} { st, err := os.Stat(p) if err != nil || st.Mode().Perm() != 0o755 || st.Sys().(*syscall.Stat_t).Uid != 0 { t.Fatal("directory", p, st.Mode(), err) } } entries, _ := os.ReadDir(dir) if len(entries) != 2 { t.Fatal("staging left behind", entries) } if again, err := installProtectedConsole(dir, source, sha, false); err != nil || !again.Already { t.Fatal("rerun", again, err) } if again, err := installProtectedConsole(dir, source, sha, true); err != nil || !again.Already { t.Fatal("dry rerun", again, err) } // A run cut off after .prev: the next run completes it. check(os.Remove(got.Path)) if again, err := installProtectedConsole(dir, source, sha, false); err != nil || again.Already { t.Fatal("partial", again, err) } slot(got.Path) // Anything else in a slot is refused and left as it is. refuse := func(name string, setup, undo func()) { t.Helper() setup() for _, dry := range []bool{true, false} { if _, err := installProtectedConsole(dir, source, sha, dry); err == nil { t.Fatal("accepted", name, dry) } } undo() slot(got.Previous) } foreign := []byte("someone else's console") refuse("other bytes", func() { check(os.WriteFile(got.Path+".x", foreign, 0o755)); check(os.Rename(got.Path+".x", got.Path)) }, func() { if b, _ := os.ReadFile(got.Path); !bytes.Equal(b, foreign) { t.Fatal("refusal replaced the slot") } check(os.Remove(got.Path)) }) refuse("symlink", func() { check(os.Symlink(source, got.Path)) }, func() { check(os.Remove(got.Path)) }) refuse("extra link", func() { check(os.Link(got.Previous, got.Path)) }, func() { check(os.Remove(got.Path)) }) refuse("group writable", func() { check(os.Chmod(got.Previous, 0o775)) }, func() { check(os.Chmod(got.Previous, 0o755)) }) refuse("not root's", func() { check(os.Chown(got.Previous, 1000, 1000)) }, func() { check(os.Chown(got.Previous, 0, 0)) }) // Ancestry: a writable or unsearchable directory, or a symlinked one. refuse("group-writable directory", func() { check(os.Chmod(dir, 0o775)) }, func() { check(os.Chmod(dir, 0o755)) }) refuse("private directory", func() { check(os.Chmod(dir, 0o700)) }, func() { check(os.Chmod(dir, 0o755)) }) refuse("world-writable ancestor", func() { check(os.Chmod(filepath.Join(d, "opt"), 0o777)) }, func() { check(os.Chmod(filepath.Join(d, "opt"), 0o755)) }) refuse("carlos-owned ancestor", func() { check(os.Chown(filepath.Join(d, "opt"), 1000, 1000)) }, func() { check(os.Chown(filepath.Join(d, "opt"), 0, 0)) }) moved := filepath.Join(d, "opt", "system", "real") refuse("symlinked directory", func() { check(os.Rename(dir, moved)); check(os.Symlink(moved, dir)) }, func() { check(os.Remove(dir)); check(os.Rename(moved, dir)) }) if again, err := installProtectedConsole(dir, source, sha, false); err != nil { t.Fatal("restored state", again, err) } slot(got.Path) slot(got.Previous) }