From 2a5f4c82701e4435ab350ca0989cb22c6ec370ff Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 04:07:40 +0200 Subject: [PATCH] Parents the host makes inside an owner's home are the owner's (hq ADR 0182, to-be 41) A file or archive placed under a fresh account's home with an owner left the parents it created, such as ~/.config or ~/.local/share, owned by root, so the person's own programs could not write there. Parents that already existed, and any outside the owner's home, are left as before. --- internal/apply/apply.go | 4 +- internal/apply/archive.go | 2 +- internal/apply/block.go | 2 +- internal/apply/home_parents_test.go | 126 ++++++++++++++++++++++++++++ internal/apply/into.go | 2 +- internal/apply/user.go | 63 ++++++++++++++ 6 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 internal/apply/home_parents_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 22f3244..1d489d0 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -772,7 +772,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) { } if !existed { - if err := os.MkdirAll(r.Path, mode); err != nil { + if err := makeDirs(r.Path, mode, r.Owner); err != nil { return out, err } } @@ -968,7 +968,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF return out, fmt.Errorf("keeping the original of %s before writing over it: %w", r.Path, err) } } - if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { + if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil { return out, err } if err := writeAtomically(r.Path, []byte(content), mode); err != nil { diff --git a/internal/apply/archive.go b/internal/apply/archive.go index 526e0bb..460682f 100644 --- a/internal/apply/archive.go +++ b/internal/apply/archive.go @@ -89,7 +89,7 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap // removed only once the new one is in place. A failed unpack leaves the old tree untouched. func replaceWith(body []byte, path, owner string) (int, error) { parent := filepath.Dir(path) - if err := os.MkdirAll(parent, 0o755); err != nil { + if err := makeDirs(parent, 0o755, owner); err != nil { return 0, err } fresh := path + ".unpacking" diff --git a/internal/apply/block.go b/internal/apply/block.go index 0ebca87..ca025cc 100644 --- a/internal/apply/block.go +++ b/internal/apply/block.go @@ -183,7 +183,7 @@ func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) { } else if mode, err = modeOf(r.Mode, mode); err != nil { return out, err } - if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil { + if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil { return out, err } if err := writeAtomically(real, []byte(next), mode); err != nil { diff --git a/internal/apply/home_parents_test.go b/internal/apply/home_parents_test.go new file mode 100644 index 0000000..8970633 --- /dev/null +++ b/internal/apply/home_parents_test.go @@ -0,0 +1,126 @@ +package apply + +import ( + "context" + "os" + osuser "os/user" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0182 and to-be 41: a parent the host makes inside an owner's home is the +// owner's, one that was there is held as found, and one outside the home is made as before. + +// aHome gives the account running the test a home in a directory the test owns, and writes down +// every directory the host gives to whom. The account's own name, so what the host chowns resolves +// without being root; the record, so what was given is told apart from what was merely made. +func aHome(t *testing.T) (home, owner string, given map[string]string) { + t.Helper() + me, err := osuser.Current() + if err != nil { + t.Skip("no current user to own anything") + } + home = t.TempDir() + given = map[string]string{} + wasHome, wasOwn := homeOf, ownMade + homeOf = func(name string) (string, error) { + if name == me.Username { + return home, nil + } + return wasHome(name) + } + ownMade = func(path, owner string) error { + given[path] = owner + return wasOwn(path, owner) + } + t.Cleanup(func() { homeOf, ownMade = wasHome, wasOwn }) + return home, me.Username, given +} + +func givenPaths(given map[string]string) []string { + var paths []string + for p := range given { + paths = append(paths, p) + } + sort.Strings(paths) + return paths +} + +func TestAFileUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) { + home, owner, given := aHome(t) + target := filepath.Join(home, ".config", "mesh", "environment.sh") + d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+ + `","content":"export A=1\n","owner":"`+owner+`"}]}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + noServices, nil, nil); err != nil { + t.Fatal(err) + } + want := []string{filepath.Join(home, ".config"), filepath.Join(home, ".config", "mesh")} + if got := givenPaths(given); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("given to the owner: %v, want %v", got, want) + } + for _, p := range want { + if given[p] != owner { + t.Errorf("%s given to %q", p, given[p]) + } + } +} + +func TestAnArchiveUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) { + home, owner, given := aHome(t) + body, digest := anArchive(t, map[string]string{"p10k.zsh": "theme"}) + target := filepath.Join(home, ".local", "share", "powerlevel10k") + d := declare(t, `{"id":"shell.theme","type":"archive","source":"`+serving(t, body)+ + `","digest":"`+digest+`","path":"`+target+`","owner":"`+owner+`"}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + noServices, nil, nil); err != nil { + t.Fatal(err) + } + for _, p := range []string{filepath.Join(home, ".local"), filepath.Join(home, ".local", "share")} { + if given[p] != owner { + t.Errorf("%s, made by the host, was not given to the owner: %v", p, givenPaths(given)) + } + } +} + +func TestAParentThatWasThereIsHeldAsFound(t *testing.T) { + home, owner, given := aHome(t) + config := filepath.Join(home, ".config") + if err := os.Mkdir(config, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(config, "mesh", "environment.sh") + d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+ + `","content":"export A=1\n","owner":"`+owner+`"}]}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + noServices, nil, nil); err != nil { + t.Fatal(err) + } + if _, touched := given[config]; touched { + t.Error("a parent that was already there was given to the owner") + } + if info, _ := os.Stat(config); info.Mode().Perm() != 0o700 { + t.Errorf("a parent that was already there changed mode: %o", info.Mode().Perm()) + } + if given[filepath.Join(config, "mesh")] != owner { + t.Errorf("the parent the host made was not given to the owner: %v", givenPaths(given)) + } +} + +func TestAParentOutsideTheHomeIsMadeAsBefore(t *testing.T) { + _, owner, given := aHome(t) + target := filepath.Join(t.TempDir(), "var", "lib", "module", "settings.conf") + d := parse(t, `{"declaration":1,"resources":[{"id":"module.conf","type":"file","path":"`+target+ + `","content":"a=1\n","owner":"`+owner+`"}]}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, + noServices, nil, nil); err != nil { + t.Fatal(err) + } + if len(given) != 0 { + t.Errorf("parents outside the owner's home were given to it: %v", givenPaths(given)) + } +} diff --git a/internal/apply/into.go b/internal/apply/into.go index eefcbeb..a02e7da 100644 --- a/internal/apply/into.go +++ b/internal/apply/into.go @@ -132,7 +132,7 @@ func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) { mode = m } } - if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { + if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil { return out, err } if err := writeAtomically(r.Path, want, mode); err != nil { diff --git a/internal/apply/user.go b/internal/apply/user.go index d882586..81e80ff 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -2,6 +2,7 @@ package apply import ( "context" + "errors" "fmt" "os" osuser "os/user" @@ -258,6 +259,68 @@ func ownedBy(path, owner string) (bool, error) { return uid == wantUID && gid == wantGID, nil } +// makeDirs makes a directory and any parent of it that is missing, as MkdirAll does — and gives +// each one it made inside the owner's home to the owner (novox/hq ADR 0182, to-be 41). +// +// **A parent made as root inside a home is a home the person cannot use.** A module writing +// ~/.config/mesh/environment.sh, or unpacking into ~/.local/share/powerlevel10k, on a fresh account +// made ~/.config and ~/.local/share owned by root: the file was the person's, the directory every +// program of theirs writes into was not. So what the host creates between the home and the target +// is the owner's, as the target is. +// +// **Only what the host created.** A parent that was already there is never chowned or chmodded: +// what a person or another program made is held as found (ADR 0182). And only inside the owner's +// home, read from the user database, not guessed from a prefix on /home: a module's directory under +// /var/lib is made exactly as before, whoever its files belong to. +func makeDirs(dir string, mode os.FileMode, owner string) error { + var made []string + for d := filepath.Clean(dir); ; d = filepath.Dir(d) { + if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) { + break + } + made = append(made, d) + if filepath.Dir(d) == d { + break + } + } + if err := os.MkdirAll(dir, mode); err != nil { + return err + } + if owner == "" || len(made) == 0 { + return nil + } + home, err := homeOf(owner) + if err != nil || home == "" { + // A numeric owner — a container's user — has no home, and a name the machine does not + // know fails where the target is given to it. Either way nothing here is a home's. + return nil + } + home = filepath.Clean(home) + for _, d := range made { + if d != home && !strings.HasPrefix(d, home+string(os.PathSeparator)) { + continue + } + if err := ownMade(d, owner); err != nil { + return err + } + } + return nil +} + +// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to +// its owner. Variables so a test can give an owner a home it owns, and see what was given to whom +// without being root. +var ( + homeOf = func(owner string) (string, error) { + found, err := osuser.Lookup(owner) + if err != nil { + return "", err + } + return found.HomeDir, nil + } + ownMade = own +) + // ownAll gives a whole tree to a user, for an archive that was unpacked into it. func ownAll(root, owner string) error { if owner == "" {