diff --git a/internal/apply/declared_home_test.go b/internal/apply/declared_home_test.go index 116aa07..ed6515a 100644 --- a/internal/apply/declared_home_test.go +++ b/internal/apply/declared_home_test.go @@ -86,3 +86,73 @@ func TestIssue1542DeclaredAccountsAreNotHomesToTheOtherGuards(t *testing.T) { t.Fatal("a declared account became a home for the links and the move-out") } } + +// A declared account is added as a home and never takes one over (reviewer, issue 1542): the person's own .ssh +// stays refused to every other account, whatever a declaration says about a user. +func TestIssue1542ADeclaredAccountTakesNoExistingHome(t *testing.T) { + dir := t.TempDir() + passwd := filepath.Join(dir, "passwd") + body := "root:x:0:0::/root:/bin/sh\njochen:x:1000:1000::/home/jochen:/bin/sh\n" + + "svc:x:998:998::/var/lib/svc:/usr/bin/nologin\n" + if err := os.WriteFile(passwd, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + before := passwdFile + passwdFile = passwd + t.Cleanup(func() { passwdFile = before }) + newHome := filepath.Join(dir, "lib", "mesh-build-go") + ssh := func(path, owner string) *declaration.Directory { + return &declaration.Directory{ID: "x.ssh", Type: declaration.TypeDirectory, Path: path, Mode: "0700", Owner: owner} + } + user := func(name, home string) *declaration.User { + return &declaration.User{ID: "x.user", Type: declaration.TypeUser, Name: name, Home: home} + } + // A declared user over a person's home, whoever it is named for. + for _, name := range []string{"evil", "postgres", "root"} { + d := ssh("/home/jochen/.ssh", name) + if refusedPlaces([]declaration.Resource{user(name, "/home/jochen"), d})[d.Path] == nil { + t.Errorf("%s, declared over jochen's home, was given jochen's .ssh", name) + } + } + // A declared home below a person's home. + d := ssh("/home/jochen/sub/.ssh", "evil") + if refusedPlaces([]declaration.Resource{user("evil", "/home/jochen/sub"), d})[d.Path] == nil { + t.Error("a home declared below jochen's home was counted") + } + // A name the machine has already, declared with another home: that is not its home. + d = ssh(newHome+"/e/.ssh", "svc") + if refusedPlaces([]declaration.Resource{user("svc", newHome+"/e"), d})[d.Path] == nil { + t.Error("an existing account's declared home was counted") + } + // A number is no name: chown -1 leaves the owner as it was. + for _, owner := range []string{"-1", "-1:0", "0", "1001"} { + d := ssh(newHome+"/.ssh", owner) + if refusedPlaces([]declaration.Resource{user("mesh-build-go", newHome), d})[d.Path] == nil { + t.Errorf("owner %q was placed below a declared home", owner) + } + } + // And the account that is new, by name, still is. + d = ssh(newHome+"/.ssh", "mesh-build-go") + if got := refusedPlaces([]declaration.Resource{user("mesh-build-go", newHome), d}); len(got) != 0 { + t.Errorf("a new account's own .ssh was refused: %v", got) + } +} + +// The account the mesh made, as declared, is judged the same at the next apply (issue 1542): once it is in the +// user database with exactly the declared home, its .ssh is still placed. +func TestIssue1542TheAccountTheMeshMadeIsStillPlacedAtTheNextApply(t *testing.T) { + home, user := declaredHomeFixture(t) + ssh := &declaration.Directory{ID: "mesh-build-go.ssh-dir", Type: declaration.TypeDirectory, Path: home + "/.ssh", + Mode: "0700", Owner: "mesh-build-go"} + resources := []declaration.Resource{user, ssh} + if got := refusedPlaces(resources); len(got) != 0 { + t.Fatalf("first apply, the account not yet made: %v", got) + } + body := "root:x:0:0::/root:/bin/sh\nmesh-build-go:x:998:998::" + home + ":/usr/bin/nologin\n" + if err := os.WriteFile(passwdFile, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if got := refusedPlaces(resources); len(got) != 0 { + t.Fatalf("second apply, the account made as declared: %v", got) + } +} diff --git a/internal/apply/placement_guard.go b/internal/apply/placement_guard.go index 28dd7a8..864c4cb 100644 --- a/internal/apply/placement_guard.go +++ b/internal/apply/placement_guard.go @@ -384,6 +384,9 @@ func ownedByAccount(owner string, a homeAccount) bool { if owner == a.Name { return true } + if a.UID < 0 { + return false // an account the declaration makes has no uid yet: by name alone, never by a number + } user, _, _ := strings.Cut(owner, ":") if uid, err := strconv.Atoi(user); err == nil { return uid == a.UID @@ -422,6 +425,7 @@ func refusePath(path string, kind placing, module, owner string) error { // not known, so a directory is owned by it by name alone. Only for the directory placement guard; no other // reader of accountsOfHomes sees these. func declaredHomes(resources []declaration.Resource) map[string]homeAccount { + var accounts []passwdEntry var out map[string]homeAccount for _, r := range resources { u, ok := r.(*declaration.User) @@ -432,6 +436,28 @@ func declaredHomes(resources []declaration.Resource) map[string]homeAccount { if u.Home == "" || !filepath.IsAbs(home) || home == "/" { continue } + if accounts == nil { + accounts = passwdEntries() + } + // Only ever adds a home. An account the machine has keeps the home it has (applyUser never moves it), so + // a declared home of one is not its home, unless it is the very home it has: the account an earlier apply + // made, as declared, which must be judged the same at every later apply. And a home at or below another + // account's, or at or above a protected root, is not a new one. + taken := false + for _, e := range accounts { + if e.name == u.Name && e.home != home { + taken = true + } + if e.name != u.Name && e.home != "" && atOrBelow(home, e.home) { + taken = true + } + } + for _, root := range protectedRoots { + taken = taken || home == root || below(root, home) + } + if taken { + continue + } if out == nil { out = map[string]homeAccount{} } @@ -440,6 +466,32 @@ func declaredHomes(resources []declaration.Resource) map[string]homeAccount { return out } +// passwdEntry is an account of the user database and its home, whatever its uid. +type passwdEntry struct{ name, home string } + +// passwdEntries is every account of the user database with its home (empty and root homes as ""). +func passwdEntries() []passwdEntry { + out := []passwdEntry{} + f, err := os.Open(passwdFile) + if err != nil { + return out + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Split(sc.Text(), ":") + if len(fields) < 6 { + continue + } + home := filepath.Clean(fields[5]) + if home == "." || home == "/" || home == "/nonexistent" { + home = "" + } + out = append(out, passwdEntry{fields[0], home}) + } + return out +} + // refusePathWith is refusePath with the homes of the accounts the declaration makes (declaredHomes) counted // among the machine's. func refusePathWith(path string, kind placing, module, owner string, declared map[string]homeAccount) error { @@ -518,7 +570,9 @@ func refuseOne(path string, kind placing, module, owner string, declared map[str merged[home] = a } for home, a := range declared { - merged[home] = a + if _, held := merged[home]; !held { + merged[home] = a + } } homes = merged }