//go:build linux package host import ( "context" "errors" "fmt" "io/fs" "maps" "os" "path/filepath" "slices" "syscall" "testing" "golang.org/x/sys/unix" ) // floorDirsOpt is an opt directory with system/ and system/guard at 0755, // as guard-arm leaves them, reached with no symlink on the way. The tests // walk from opt itself as the base, owned by the test's own uid and made // 0755 whatever the test's umask: the real ancestors of a scratch directory // are not the test's. func floorDirsOpt(t *testing.T) string { t.Helper() opt, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } guard := systemGuardDir(opt) if err := os.MkdirAll(guard, 0o755); err != nil { t.Fatal(err) } for _, dir := range []string{opt, filepath.Join(opt, "system"), guard} { if err := os.Chmod(dir, 0o755); err != nil { t.Fatal(err) } } return opt } func floorDirMode(t *testing.T, dir string) os.FileMode { t.Helper() st, err := os.Lstat(dir) if err != nil { t.Fatal(err) } return st.Mode() } // journal-migrate runs from the window's umask 077 root shell. Both public // floor directories come out 0755, whether they are missing, left 0700 by // an earlier attempt, or left by an earlier build's interrupted // mkdirAllExact (mkdir done under the umask, chmod never reached). func TestRootFloorDirsIgnoreUmask(t *testing.T) { old := syscall.Umask(0o077) defer syscall.Umask(old) uid, gid := uint32(os.Getuid()), uint32(os.Getgid()) for name, earlier := range map[string]func(opt string) error{ "missing": func(string) error { return nil }, "moves 0700": func(opt string) error { return os.Mkdir(rootFloorDir(opt), 0o700) }, "seeds 0700": func(opt string) error { return os.Mkdir(rootSeedsFloorDir(opt), 0o700) }, "interrupted": func(opt string) error { return os.MkdirAll(rootSeedsFloorDir(opt), 0o755) }, // an earlier build's mkdirAllExact mkdir, under 077 "both 0700": func(opt string) error { return errors.Join(os.Mkdir(rootFloorDir(opt), 0o700), os.Mkdir(rootSeedsFloorDir(opt), 0o700)) }, } { t.Run(name, func(t *testing.T) { opt := floorDirsOpt(t) if err := earlier(opt); err != nil { t.Fatal(err) } if err := prepareRootFloorDirsOwned(opt, opt, uid, gid); err != nil { t.Fatal(err) } for _, dir := range []string{rootFloorDir(opt), rootSeedsFloorDir(opt), systemGuardDir(opt)} { if m := floorDirMode(t, dir); !m.IsDir() || m.Perm() != 0o755 { t.Fatalf("%s is %v, want a 0755 directory", dir, m) } } }) } } // The repair touches only a real directory with the expected owner: a // symlink (at the directory or on its way), a file, or another owner is // refused and left as it was. func TestRootFloorDirsRefuseWhatTheyDoNotOwn(t *testing.T) { uid, gid := uint32(os.Getuid()), uint32(os.Getgid()) for name, c := range map[string]struct { setup func(t *testing.T, opt string) string // returns the path whose mode must not change uid, gid uint32 want []error // the refusal is one of these, so it is this case's own }{ "symlink": {setup: func(t *testing.T, opt string) string { target := filepath.Join(opt, "elsewhere") if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } if err := os.Symlink(target, rootFloorDir(opt)); err != nil { t.Fatal(err) } return target }, uid: uid, gid: gid, want: []error{unix.ELOOP, unix.ENOTDIR}}, "symlink on the way": {setup: func(t *testing.T, opt string) string { real := filepath.Join(opt, "realguard") if err := os.Rename(systemGuardDir(opt), real); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(real, "moves"), 0o700); err != nil { t.Fatal(err) } if err := os.Symlink(real, systemGuardDir(opt)); err != nil { t.Fatal(err) } return filepath.Join(real, "moves") }, uid: uid, gid: gid, want: []error{unix.ELOOP, unix.ENOTDIR}}, "file": {setup: func(t *testing.T, opt string) string { if err := os.WriteFile(rootFloorDir(opt), nil, 0o600); err != nil { t.Fatal(err) } return rootFloorDir(opt) }, uid: uid, gid: gid, want: []error{unix.ENOTDIR}}, // Unprivileged, every directory here is the test's, so another // owner refuses at the base, before the leaf; the leaf's own owner // check is the root runner's (the Foreign rerun witness). "other owner": {setup: func(t *testing.T, opt string) string { if err := os.Mkdir(rootFloorDir(opt), 0o700); err != nil { t.Fatal(err) } return rootFloorDir(opt) }, uid: uid + 1, gid: gid, want: []error{os.ErrPermission}}, "other group": {setup: func(t *testing.T, opt string) string { if err := os.Mkdir(rootFloorDir(opt), 0o700); err != nil { t.Fatal(err) } return rootFloorDir(opt) }, uid: uid, gid: gid + 1, want: []error{os.ErrPermission}}, } { t.Run(name, func(t *testing.T) { opt := floorDirsOpt(t) left := c.setup(t, opt) before := floorDirMode(t, left) err := prepareRootFloorDirsOwned(opt, opt, c.uid, c.gid) if !slices.ContainsFunc(c.want, func(w error) bool { return errors.Is(err, w) }) { t.Fatalf("repair = %v, want a refusal with one of %v", err, c.want) } if after := floorDirMode(t, left); after != before { t.Fatalf("%s changed from %v to %v", left, before, after) } }) } // Production's owner is root: unprivileged, the test's own directories // are another owner's, so prepareRootFloorDirs refuses them untouched. if os.Geteuid() != 0 { opt := floorDirsOpt(t) if err := os.Mkdir(rootFloorDir(opt), 0o700); err != nil { t.Fatal(err) } if err := prepareRootFloorDirs(opt); !errors.Is(err, os.ErrPermission) { t.Fatalf("prepareRootFloorDirs over a non-root directory = %v", err) } if m := floorDirMode(t, rootFloorDir(opt)); m.Perm() != 0o700 { t.Fatalf("refused directory is now %v", m) } } } // floorTree records every path under roots, with its mode, owner and, for // a symlink, its target, so a refusal can be shown to have changed nothing. func floorTree(t *testing.T, roots ...string) map[string]string { t.Helper() out := map[string]string{} for _, root := range roots { err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { if err != nil { return err } st, err := os.Lstat(path) if err != nil { return err } sys := st.Sys().(*syscall.Stat_t) v := fmt.Sprintf("%v %d:%d", st.Mode(), sys.Uid, sys.Gid) if st.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(path) if err != nil { return err } v += " -> " + target } out[path] = v return nil }) if err != nil { t.Fatal(err) } } return out } // Fix round 2: the repair checks every directory from its base to the // floor directory's parent before it makes or changes anything. A symlink // or a missing directory on the way, an ancestor writable by group or // others, or one with another owner refuses, whether the floor directory // is missing (nothing is made, in either tree) or left 0700 (it stays so). func TestRootFloorDirsCheckAncestryFirst(t *testing.T) { uid, gid := uint32(os.Getuid()), uint32(os.Getgid()) elsewhere := func(t *testing.T) string { t.Helper() dir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } return dir } moves0700 := func(t *testing.T, opt string) { t.Helper() if err := os.Mkdir(rootFloorDir(opt), 0o700); err != nil { t.Fatal(err) } } for name, c := range map[string]struct { // setup returns any tree outside opt that must not change either. setup func(t *testing.T, opt string) []string uid, gid uint32 want []error // the refusal is one of these, so it is this case's own }{ "symlinked guard, moves missing": {setup: func(t *testing.T, opt string) []string { target := elsewhere(t) if err := os.Remove(systemGuardDir(opt)); err != nil { t.Fatal(err) } if err := os.Symlink(target, systemGuardDir(opt)); err != nil { t.Fatal(err) } return []string{target} }, uid: uid, gid: gid, want: []error{unix.ELOOP, unix.ENOTDIR}}, "symlinked system, moves missing": {setup: func(t *testing.T, opt string) []string { target := elsewhere(t) if err := os.Mkdir(filepath.Join(target, "guard"), 0o755); err != nil { t.Fatal(err) } if err := os.RemoveAll(filepath.Join(opt, "system")); err != nil { t.Fatal(err) } if err := os.Symlink(target, filepath.Join(opt, "system")); err != nil { t.Fatal(err) } return []string{target} }, uid: uid, gid: gid, want: []error{unix.ELOOP, unix.ENOTDIR}}, "guard missing": {setup: func(t *testing.T, opt string) []string { if err := os.Remove(systemGuardDir(opt)); err != nil { t.Fatal(err) } return nil }, uid: uid, gid: gid, want: []error{unix.ENOENT}}, "group-writable system, moves 0700": {setup: func(t *testing.T, opt string) []string { moves0700(t, opt) if err := os.Chmod(filepath.Join(opt, "system"), 0o775); err != nil { t.Fatal(err) } return nil }, uid: uid, gid: gid, want: []error{os.ErrPermission}}, "world-writable guard, moves missing": {setup: func(t *testing.T, opt string) []string { if err := os.Chmod(systemGuardDir(opt), 0o777); err != nil { t.Fatal(err) } return nil }, uid: uid, gid: gid, want: []error{os.ErrPermission}}, "world-writable base, moves 0700": {setup: func(t *testing.T, opt string) []string { moves0700(t, opt) if err := os.Chmod(opt, 0o757); err != nil { t.Fatal(err) } return nil }, uid: uid, gid: gid, want: []error{os.ErrPermission}}, // Every ancestor is another owner's; the floor directory would be // made by the test's own uid, so a create before the ancestry check // shows in the tree. "other-owned ancestors, moves missing": {setup: func(t *testing.T, opt string) []string { return nil }, uid: uid + 1, gid: gid, want: []error{os.ErrPermission}}, } { t.Run(name, func(t *testing.T) { opt := floorDirsOpt(t) others := c.setup(t, opt) roots := append([]string{opt}, others...) before := floorTree(t, roots...) err := prepareRootFloorDirsOwned(opt, opt, c.uid, c.gid) if err == nil { t.Fatal("repair accepted it") } if !slices.ContainsFunc(c.want, func(w error) bool { return errors.Is(err, w) }) { t.Fatalf("refused with %v, want one of %v", err, c.want) } if after := floorTree(t, roots...); !maps.Equal(before, after) { t.Fatalf("the refused repair changed the tree:\nbefore %v\nafter %v", before, after) } for _, root := range others { if _, err := os.Lstat(filepath.Join(root, "moves")); !errors.Is(err, os.ErrNotExist) { t.Fatal("moves made through the symlink", err) } } }) } // The repair's own argument checks: a floor directory not beneath the // base, or the base itself, refuses before any open. opt := floorDirsOpt(t) for _, base := range []string{rootFloorDir(opt), filepath.Join(opt, "elsewhere"), "relative"} { if err := repairRootFloorDir(base, rootFloorDir(opt), uid, gid); !errors.Is(err, os.ErrPermission) { t.Fatalf("base %s: %v", base, err) } } if _, err := os.Lstat(rootFloorDir(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("moves made", err) } } // The migration repairs the floor directories under its lock and quiesce, // before guardReady's first floor read; a refused repair stops it there, // with no census and no record written. func TestRootMigrationPreparesFloorsBeforeGuardReady(t *testing.T) { m, _, _ := migrationFixture(t) var order []string quiesce := m.quiesce m.quiesce = func(ctx context.Context) (func(), error) { order = append(order, "quiesce"); return quiesce(ctx) } m.prepareFloors = func(context.Context) error { order = append(order, "prepareFloors"); return nil } m.guardReady = func(context.Context) error { order = append(order, "guardReady"); return nil } observe := m.observe m.observe = func(ctx context.Context) (rootMigrationSnapshot, error) { order = append(order, "observe") return observe(ctx) } if err := m.run(t.Context()); err != nil { t.Fatal(err) } if len(order) < 4 || !slices.Equal(order[:4], []string{"quiesce", "prepareFloors", "guardReady", "observe"}) { t.Fatalf("order = %v", order) } m, _, _ = migrationFixture(t) guarded := false m.prepareFloors = func(context.Context) error { return os.ErrPermission } m.guardReady = func(context.Context) error { guarded = true; return nil } if err := m.run(t.Context()); !errors.Is(err, os.ErrPermission) || guarded { t.Fatalf("refused repair: run = %v, guardReady ran = %v", err, guarded) } if _, err := m.execution.Read("census.json"); !os.IsNotExist(err) { t.Fatal("census before the floor repair", err) } if _, err := m.state.Read("migration.json"); !os.IsNotExist(err) { t.Fatal("migration record before the floor repair", err) } m, _, _ = migrationFixture(t) m.prepareFloors = nil if err := m.run(t.Context()); !errors.Is(err, errRootMigrationBusy) { t.Fatalf("no floor repair wired: run = %v", err) } }