From 60371aadad713728e0ab320e25b25a6c8f453ac4 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 23:25:37 +0000 Subject: [PATCH] Count the home of an account a declaration makes as a home for the directory placement guard, so its own .ssh is placed (issue 1542) --- internal/apply/declared_home_test.go | 88 ++++++++++++++++++++++++++++ internal/apply/mesh_dirs.go | 3 +- internal/apply/placement_guard.go | 47 ++++++++++++++- 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 internal/apply/declared_home_test.go diff --git a/internal/apply/declared_home_test.go b/internal/apply/declared_home_test.go new file mode 100644 index 0000000..116aa07 --- /dev/null +++ b/internal/apply/declared_home_test.go @@ -0,0 +1,88 @@ +package apply + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// A module that declares its own system account and a .ssh in that account's home is placed (novox/hq issue 1542): +// the account is not in the user database yet when the declaration is judged, and its home is under /var/lib. +func declaredHomeFixture(t *testing.T) (home string, user *declaration.User) { + t.Helper() + dir := t.TempDir() + passwd := filepath.Join(dir, "passwd") + if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + before := passwdFile + passwdFile = passwd + t.Cleanup(func() { passwdFile = before }) + home = filepath.Join(dir, "lib", "mesh-build-go") + return home, &declaration.User{ID: "mesh-build-go.account", Type: declaration.TypeUser, Name: "mesh-build-go", + Shell: "/usr/bin/nologin", Home: home} +} + +// 1542/AC1: the .ssh of a declared account, owned by it, is refused by nothing. +func TestIssue1542ADeclaredAccountsSSHIsPlaced(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"} + refused := refusedPlaces([]declaration.Resource{user, ssh}) + if len(refused) != 0 { + t.Fatalf("a declared account's own .ssh was refused: %v", refused) + } + if err := (inputs{refused: refused}).refused[filepath.Clean(ssh.Path)]; err != nil { + t.Fatalf("the apply-time check refuses it: %v", err) + } + if line := JudgeDirectories(&declaration.Declaration{Resources: []declaration.Resource{user, ssh}}); strings.Contains(line, "refused already by the placement guard: ") { + t.Fatalf("the read-only judgement refuses it: %s", line) + } +} + +// 1542/AC2: another account's directory below the declared home is refused as below any home, and a .ssh below +// no home of the machine or the declaration is refused with today's words. +func TestIssue1542OnlyTheDeclaredAccountsOwnIsPlaced(t *testing.T) { + home, user := declaredHomeFixture(t) + other := &declaration.Directory{ID: "notes.ssh-dir", Type: declaration.TypeDirectory, Path: home + "/.ssh", + Mode: "0700", Owner: "intruder"} + refused := refusedPlaces([]declaration.Resource{user, other}) + err := refused[filepath.Clean(other.Path)] + if err == nil || !strings.Contains(err.Error(), "below mesh-build-go's home and declared intruder's") { + t.Fatalf("another account's directory below a declared home: %v", err) + } + stray := &declaration.Directory{ID: "mesh-build-go.ssh-dir", Type: declaration.TypeDirectory, + Path: filepath.Join(filepath.Dir(home), "elsewhere", ".ssh"), Mode: "0700", Owner: "mesh-build-go"} + refused = refusedPlaces([]declaration.Resource{user, stray}) + err = refused[filepath.Clean(stray.Path)] + if err == nil || !strings.Contains(err.Error(), "a .ssh directory is placed only below its account's home, as that account's") { + t.Fatalf("a .ssh below no home: %v", err) + } + // Not a .ssh: a root-owned directory below the declared home is placed exactly as on main. + data := &declaration.Directory{ID: "mesh-build-go.data", Type: declaration.TypeDirectory, Path: home + "/data", + Mode: "0755", Owner: "root"} + if refused := refusedPlaces([]declaration.Resource{user, data}); len(refused) != 0 { + t.Fatalf("a root-owned directory below a declared home, not a .ssh, was refused: %v", refused) + } + // And without the account declared, the same .ssh is refused as it was. + own := &declaration.Directory{ID: "mesh-build-go.ssh-dir", Type: declaration.TypeDirectory, Path: home + "/.ssh", + Mode: "0700", Owner: "mesh-build-go"} + if refusedPlaces([]declaration.Resource{own})[filepath.Clean(own.Path)] == nil { + t.Fatal("a .ssh below an account nobody makes was placed") + } +} + +// 1542/AC3: a declared account is no home to anyone but the directory placement guard. +func TestIssue1542DeclaredAccountsAreNotHomesToTheOtherGuards(t *testing.T) { + home, user := declaredHomeFixture(t) + _ = refusedPlaces([]declaration.Resource{user}) + if _, ok := accountsOfHomes()[home]; ok { + t.Fatal("a declared account became a home of the machine") + } + if homeAbove(home+"/.ssh") != "" { + t.Fatal("a declared account became a home for the links and the move-out") + } +} diff --git a/internal/apply/mesh_dirs.go b/internal/apply/mesh_dirs.go index c550240..d21094e 100644 --- a/internal/apply/mesh_dirs.go +++ b/internal/apply/mesh_dirs.go @@ -94,13 +94,14 @@ func groupNamed(gid uint32) string { func JudgeDirectories(d *declaration.Declaration) string { var lines []string var placed, refused, guarded, homed int + declared := declaredHomes(d.Resources) for _, r := range d.Resources { dir, ok := r.(*declaration.Directory) if !ok { continue } path := filepath.Clean(dir.Path) - if err := refusePath(path, placingDirectory, moduleOfID(dir.ID), dir.Owner); err != nil { + if err := refusePathWith(path, placingDirectory, moduleOfID(dir.ID), dir.Owner, declared); err != nil { guarded++ lines = append(lines, path+": refused already by the placement guard: "+err.Error()) continue diff --git a/internal/apply/placement_guard.go b/internal/apply/placement_guard.go index ef26e43..28dd7a8 100644 --- a/internal/apply/placement_guard.go +++ b/internal/apply/placement_guard.go @@ -413,12 +413,42 @@ const ( // refusePath says why a path is not placed or mounted; nil when it may be. module is the resource's module, // owner a directory's declared owner. func refusePath(path string, kind placing, module, owner string) error { + return refusePathWith(path, kind, module, owner, nil) +} + +// declaredHomes is the home of each account the declaration itself makes (a user resource with a home), by the +// account's name; the account is not in the user database yet when the declaration is judged, and the engine makes +// it where it is declared, before the directory that is owned by it is reached (novox/hq issue 1542). Its uid is +// 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 out map[string]homeAccount + for _, r := range resources { + u, ok := r.(*declaration.User) + if !ok || u.Name == "" { + continue + } + home := filepath.Clean(u.Home) + if u.Home == "" || !filepath.IsAbs(home) || home == "/" { + continue + } + if out == nil { + out = map[string]homeAccount{} + } + out[home] = homeAccount{Name: u.Name, UID: -1} + } + 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 { clean := filepath.Clean(path) if !filepath.IsAbs(clean) { return nil // the declaration refuses a relative path already; a named volume is not a path } for _, p := range []string{clean, resolved(clean)} { - if err := refuseOne(p, kind, module, owner); err != nil { + if err := refuseOne(p, kind, module, owner, declared); err != nil { if p != clean { err.Why = fmt.Sprintf("through a link, it is %s, and %s", p, err.Why) err.Path = clean @@ -429,7 +459,7 @@ func refusePath(path string, kind placing, module, owner string) error { return nil } -func refuseOne(path string, kind placing, module, owner string) *PlacementRefusedError { +func refuseOne(path string, kind placing, module, owner string, declared map[string]homeAccount) *PlacementRefusedError { for _, root := range protectedRoots { if path == root || below(root, path) { return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " + @@ -482,6 +512,16 @@ func refuseOne(path string, kind placing, module, owner string) *PlacementRefuse return nil } homes := accountsOfHomes() + if ssh && len(declared) > 0 { + merged := map[string]homeAccount{} + for home, a := range homes { + merged[home] = a + } + for home, a := range declared { + merged[home] = a + } + homes = merged + } var deepest string for home := range homes { if below(path, home) && len(home) > len(deepest) { @@ -518,11 +558,12 @@ func moduleOfID(id string) string { // a file into it, because nothing judged the file's path. func refusedPlaces(resources []declaration.Resource) map[string]error { out := map[string]error{} + declared := declaredHomes(resources) for _, r := range resources { var err error switch res := r.(type) { case *declaration.Directory: - err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner) + err = refusePathWith(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner, declared) // And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a // link another account could make in its path refuses it, and with it what is below it, whatever // order they are declared in. The apply walks again at the moment of use. A directory handed over