Follow no link below a home as root, and judge more ways to root
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

An account could replace ~/.claude with a link to /etc and have the
node-engine chown, chmod or write through it on the next apply. Below a
person's or an agent's home every component is now opened without
following a link, and a link refuses the resource in words.

The root judge also reads doas and polkit rules, the container runtimes'
sockets with their ACLs, ACLs on the secrets, and setuid-root programs no
package owns, in the C locale; Judged and NotJudged write down exactly
what it covers (hq ADR 0266 review).
This commit is contained in:
jochen
2026-10-08 20:36:46 +02:00
parent 8390fab5cb
commit 5fc37b44a9
12 changed files with 1347 additions and 33 deletions
+32 -8
View File
@@ -918,6 +918,13 @@ type Unseal func(sealed string) ([]byte, error)
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner,
changed map[string]bool, in inputs, previous store.Applied,
unseal Unseal, keepFound Keep) (Outcome, error) {
// Nothing below a home is touched through a link an account put there (novox/hq ADR 0266).
switch r.(type) {
case *declaration.Directory, *declaration.File, *declaration.Archive:
if err := refuseLinksUnderHome(r.Target()); err != nil {
return begin(r), err
}
}
switch res := r.(type) {
case *declaration.Directory:
return applyDirectory(res)
@@ -972,7 +979,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
return out, err
}
before, err := os.Stat(r.Path)
before, err := statPath(r.Path)
existed := err == nil
if err != nil && !errors.Is(err, os.ErrNotExist) {
return out, err
@@ -989,12 +996,12 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
// Set explicitly even when it existed: MkdirAll applies the mode only on creation, and a
// permission set at creation is not a permission maintained — a lesson this repository
// already paid for once, with world-readable environment files.
if err := os.Chmod(r.Path, mode); err != nil {
if err := chmodPath(r.Path, mode); err != nil {
return out, err
}
// Read back.
after, err := os.Stat(r.Path)
after, err := statPath(r.Path)
if err != nil {
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
}
@@ -1136,7 +1143,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
return out, err
}
existing, readErr := os.ReadFile(r.Path)
existing, readErr := readPath(r.Path)
existed := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return out, readErr
@@ -1157,7 +1164,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
var beforeMode os.FileMode
beforeOwner := ""
if existed {
if info, err := os.Stat(r.Path); err == nil {
if info, err := statPath(r.Path); err == nil {
beforeMode = info.Mode().Perm()
if uid, gid, ok := ownerOf(info); ok {
beforeOwner = fmt.Sprintf("%d:%d", uid, gid)
@@ -1189,20 +1196,20 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
return out, err
}
} else if !modeSame {
if err := os.Chmod(r.Path, mode); err != nil {
if err := chmodPath(r.Path, mode); err != nil {
return out, err
}
}
// Read back — the file, not the call that wrote it.
written, err := os.ReadFile(r.Path)
written, err := readPath(r.Path)
if err != nil {
return out, fmt.Errorf("wrote %s and cannot read it back: %w", r.Path, err)
}
if string(written) != content {
return out, fmt.Errorf("%s does not contain what was declared after writing it", r.Path)
}
info, err := os.Stat(r.Path)
info, err := statPath(r.Path)
if err != nil {
return out, err
}
@@ -1260,7 +1267,17 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
//
// A reader of a managed file must never see half of one. The mesh's own configuration is read
// by daemons that reload on change, so a torn write is a service reading a truncated config.
//
// Below a home it is written through its directory's descriptor, following no link (home_links.go).
func writeAtomically(path string, content []byte, mode os.FileMode) error {
if home := homeAbove(path); home != "" {
return writeUnder(home, path, content, mode)
}
return writeAtomicallyByPath(path, content, mode)
}
// writeAtomicallyByPath is writeAtomically for a path below no home.
func writeAtomicallyByPath(path string, content []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".mesh-host-*")
if err != nil {
return err
@@ -1610,6 +1627,13 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
made map[string]bool) (string, string, error) {
switch declaration.Type(a.Type) {
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
if err := refuseLinksUnderHome(a.Target); err != nil {
return "", "", err
}
}
switch declaration.Type(a.Type) {
case declaration.TypeDirectory:
// **A directory with anything left in it is kept, and that is the rule that protects
// data.** Everything the mesh put inside is itself a declared resource, and orphans are
+162
View File
@@ -0,0 +1,162 @@
package apply
// A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08).
//
// The node-engine runs as root and places files and directories under homes: the operator's shell
// configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that
// replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and
// mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of
// their own, that account is exactly the one that must not become root.
//
// So for a path strictly below a home:
//
// - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said
// in words, and nothing is done to it — before anything is read, written, chowned or chmodded;
// - **the owner and mode are set through a descriptor opened without following a link**, each component
// opened from the one above it (homes_linux.go), so a link put in place after the check is not followed
// either; a link that is itself the thing to own is owned as a link, never its target;
// - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within
// that directory, so neither the file nor a parent can be swapped for a link between check and write.
//
// The home itself is the machine's, made by root, and is followed as it is. A path outside every home is
// handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is).
//
// Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow
// account, and every home under /home. A service account's home under /var/lib is a module's own directory,
// and is left as it was.
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// passwdFile is the user database the homes are read from; a test names its own.
var passwdFile = "/etc/passwd"
// homes is every home of a person's or an agent's account on this machine. A variable so a test can name the
// homes it made.
var homes = func() []string {
f, err := os.Open(passwdFile)
if err != nil {
return nil
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Split(sc.Text(), ":")
if len(fields) < 6 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
home := filepath.Clean(fields[5])
if home == "/" || home == "." || home == "" {
continue
}
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
out = append(out, home)
}
}
return out
}
// homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none.
func homeAbove(path string) string {
path = filepath.Clean(path)
hs := homes()
sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) })
for _, h := range hs {
if strings.HasPrefix(path, h+string(os.PathSeparator)) {
return h
}
}
return ""
}
// LinkUnderHomeError is a resource refused because a component below a home is a symbolic link.
type LinkUnderHomeError struct {
Path, Link, Home string
}
func (e *LinkUnderHomeError) Error() string {
return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+
"follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+
"directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link)
}
// refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a
// symbolic link. Components that do not exist yet end the walk: what is missing is made without following.
func refuseLinksUnderHome(path string) error {
home := homeAbove(path)
if home == "" {
return nil
}
rel, err := filepath.Rel(home, filepath.Clean(path))
if err != nil {
return err
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
info, err := os.Lstat(at)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return nil
}
// statPath is os.Stat, except below a home, where it never follows a link.
func statPath(path string) (os.FileInfo, error) {
if homeAbove(path) != "" {
return os.Lstat(path)
}
return os.Stat(path)
}
// chmodPath sets a mode; below a home through a descriptor that followed no link.
func chmodPath(path string, mode os.FileMode) error {
home := homeAbove(path)
if home == "" {
return os.Chmod(path, mode)
}
return chmodUnder(home, path, mode)
}
// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is
// owned as a link.
func chownPath(path string, uid, gid int) error {
home := homeAbove(path)
if home == "" {
return os.Chown(path, uid, gid)
}
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
return os.Lchown(path, uid, gid)
}
return chownUnder(home, path, uid, gid)
}
// readPath reads a file; below a home without following a link.
func readPath(path string) ([]byte, error) {
home := homeAbove(path)
if home == "" {
return os.ReadFile(path)
}
return readUnder(home, path)
}
+205
View File
@@ -0,0 +1,205 @@
//go:build linux
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0266 (the review of 2026-10-08): below a home, the node-engine — root — follows no
// symbolic link an account can put there: not to chown or chmod, not to write, not to make a directory.
// aLinkedHome is a home the test names as one, with `.claude` replaced by a link to a directory outside it,
// as an account would to have root change /etc.
func aLinkedHome(t *testing.T) (home, outside string) {
t.Helper()
root := t.TempDir()
home = filepath.Join(root, "home", "agent")
outside = filepath.Join(root, "etc")
for _, d := range []string{home, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("root's"), 0o600); err != nil {
t.Fatal(err)
}
was := homes
homes = func() []string { return []string{home} }
t.Cleanup(func() { homes = was })
return home, outside
}
func link(t *testing.T, target, at string) {
t.Helper()
if err := os.Symlink(target, at); err != nil {
t.Fatal(err)
}
}
func applyOneResource(t *testing.T, resource string) error {
t.Helper()
d := declare(t, resource)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
return err
}
func mustBeLinkRefusal(t *testing.T, err error) {
t.Helper()
var refused *LinkUnderHomeError
if !errors.As(err, &refused) {
t.Fatalf("refused as a link under a home, got %v", err)
}
if !strings.Contains(err.Error(), "never follows a link") {
t.Fatalf("said in words: %v", err)
}
}
func modeOfPath(t *testing.T, p string) os.FileMode {
t.Helper()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return info.Mode().Perm()
}
func TestADirectoryUnderAHomeThatIsALinkIsRefusedAndItsTargetUntouched(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"claude-code.agent-home","type":"directory","path":"`+
filepath.Join(home, ".claude")+`","mode":"0700"}`)
mustBeLinkRefusal(t, err)
if m := modeOfPath(t, outside); m != 0o755 {
t.Fatalf("the link's target was chmodded to %o", m)
}
}
func TestAFileThroughALinkedParentUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".claude", "shadow")+
`","content":"the mesh's\n","mode":"0644"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
}
func TestAFileThatIsItselfALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, filepath.Join(outside, "shadow"), filepath.Join(home, ".zshrc"))
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".zshrc")+
`","content":"x\n"}`)
mustBeLinkRefusal(t, err)
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
t.Fatalf("written through the link: %q", got)
}
}
func TestADirectoryAndAFileUnderAHomeAreMadeAsBefore(t *testing.T) {
home, _ := aLinkedHome(t)
dir := filepath.Join(home, ".claude")
if err := applyOneResource(t, `{"id":"m.d","type":"directory","path":"`+dir+`","mode":"0700"}`); err != nil {
t.Fatal(err)
}
if m := modeOfPath(t, dir); m != 0o700 {
t.Fatalf("mode %o", m)
}
file := filepath.Join(home, ".config", "mesh", "env.sh")
if err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+file+`","content":"A=1\n","mode":"0640"}`); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(file); string(got) != "A=1\n" || modeOfPath(t, file) != 0o640 {
t.Fatalf("written %q mode %o", got, modeOfPath(t, file))
}
}
// The descriptor half: a link put in place after any check is still not followed.
func TestTheDescriptorCallsFollowNoLinkBelowAHome(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude"), 0o777))
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude", "shadow"), 0o777))
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
t.Fatalf("chmodded through the link: %o", m)
}
mustBeLinkRefusal(t, writeAtomically(filepath.Join(home, ".claude", "new"), []byte("x"), 0o644))
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
t.Fatal("written through the link")
}
if _, err := makeDirsSaying(filepath.Join(home, ".claude", "a", "b"), 0o755, ""); err == nil {
t.Fatal("made directories through the link")
}
if _, err := os.Stat(filepath.Join(outside, "a")); !os.IsNotExist(err) {
t.Fatal("made a directory through the link")
}
if _, err := readPath(filepath.Join(home, ".claude", "shadow")); err == nil {
t.Fatal("read through the link")
}
me := os.Getuid()
// A link itself is owned as a link, never its target.
if err := chownPath(filepath.Join(home, ".claude"), me, os.Getgid()); err != nil {
t.Fatalf("a link is owned as a link: %v", err)
}
}
func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
}
}
func TestAPathOutsideEveryHomeIsHandledAsBefore(t *testing.T) {
_, outside := aLinkedHome(t)
elsewhere := filepath.Join(filepath.Dir(outside), "srv")
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
t.Fatal(err)
}
link(t, outside, filepath.Join(elsewhere, "linked"))
if homeAbove(filepath.Join(elsewhere, "linked", "x")) != "" {
t.Fatal("not below a home")
}
if err := refuseLinksUnderHome(filepath.Join(elsewhere, "linked", "x")); err != nil {
t.Fatal("a system path may be a link the machine set up; only a home's are refused")
}
}
func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
dir := t.TempDir()
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/bash\n"+
"postgres:x:968:968::/var/lib/postgres:/usr/bin/nologin\n"+
"nobody:x:65534:65534::/:/usr/bin/nologin\n"+
"operator:x:1000:1000::/home/operator:/bin/zsh\n"+
"agent:x:1001:1001::/home/agent:/bin/bash\n"+
"svc:x:990:990::/home/svc:/usr/bin/nologin\n"), 0o644); err != nil {
t.Fatal(err)
}
was := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = was })
got := strings.Join(homes(), ",")
if got != "/home/operator,/home/agent,/home/svc" {
t.Fatalf("homes %s", got)
}
if homeAbove("/home/agent/.claude") != "/home/agent" || homeAbove("/home/agent") != "" ||
homeAbove("/var/lib/postgres/data") != "" || homeAbove("/root/.ssh") != "" {
t.Fatal("strictly below a person's or an agent's home, and nothing else")
}
}
+178
View File
@@ -0,0 +1,178 @@
//go:build linux
package apply
// The descriptor half of home_links.go: below a home, every component is opened from the one above it with
// O_NOFOLLOW, so a link is refused by the kernel at the moment of use, not only at the check before it.
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
)
// openDirUnder opens the directory rel names below home, following no link below the home.
func openDirUnder(home, dir string) (int, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return -1, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return -1, &os.PathError{Op: "open", Path: home, Err: err}
}
if rel == "." {
return fd, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
unix.Close(fd)
if err != nil {
return -1, linkOr(at, home, dir, "open", err)
}
fd = next
}
return fd, nil
}
// linkOr says a refused link in the mesh's words, and any other failure as the system's.
func linkOr(at, home, path, op string, err error) error {
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
if info, lerr := os.Lstat(at); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
}
}
return &os.PathError{Op: op, Path: at, Err: err}
}
// openUnder opens the file or directory at path below home, following no link, for its metadata.
func openUnder(home, path string) (int, error) {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return -1, err
}
defer unix.Close(dir)
fd, err := unix.Openat(dir, filepath.Base(path), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
if err != nil {
return -1, linkOr(path, home, path, "open", err)
}
return fd, nil
}
func chmodUnder(home, path string, mode os.FileMode) error {
fd, err := openUnder(home, path)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return &os.PathError{Op: "chmod", Path: path, Err: err}
}
return nil
}
func chownUnder(home, path string, uid, gid int) error {
fd, err := openUnder(home, path)
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Fchown(fd, uid, gid); err != nil {
return &os.PathError{Op: "chown", Path: path, Err: err}
}
return nil
}
func readUnder(home, path string) ([]byte, error) {
fd, err := openUnder(home, path)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), path)
defer f.Close()
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, fmt.Errorf("%s is not a regular file", path)
}
return io.ReadAll(f)
}
// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor
// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does.
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
rel, err := filepath.Rel(home, filepath.Clean(dir))
if err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("%s is not below %s", dir, home)
}
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: home, Err: err}
}
defer func() { unix.Close(fd) }()
var made []string
if rel == "." {
return nil, nil
}
at := home
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
at = filepath.Join(at, part)
if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil {
made = append([]string{at}, made...)
} else if !errors.Is(err, unix.EEXIST) {
return made, &os.PathError{Op: "mkdir", Path: at, Err: err}
}
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return made, linkOr(at, home, dir, "open", err)
}
unix.Close(fd)
fd = next
}
return made, nil
}
// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW
// under a name of its own, given its mode, and renamed over the file within that directory.
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
dir, err := openDirUnder(home, filepath.Dir(path))
if err != nil {
return err
}
defer unix.Close(dir)
name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid())
fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
if err != nil {
return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err}
}
f := os.NewFile(uintptr(fd), name)
_, werr := f.Write(content)
if werr == nil {
werr = f.Sync()
}
if werr == nil {
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
werr = &os.PathError{Op: "chmod", Path: path, Err: err}
}
}
if cerr := f.Close(); werr == nil {
werr = cerr
}
if werr == nil {
if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil {
werr = &os.PathError{Op: "rename", Path: path, Err: err}
}
}
if werr != nil {
_ = unix.Unlinkat(dir, name, 0)
}
return werr
}
+53
View File
@@ -0,0 +1,53 @@
//go:build !linux
package apply
// Where there is no openat with O_NOFOLLOW to rely on, the check before use is all: a link below a home is
// refused, and the call is made by path. The node-engine runs on Linux; this keeps the package building.
import (
"os"
"path/filepath"
)
func chmodUnder(home, path string, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func chownUnder(home, path string, uid, gid int) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return os.Lchown(path, uid, gid)
}
func readUnder(home, path string) ([]byte, error) {
if err := refuseLinksUnderHome(path); err != nil {
return nil, err
}
return os.ReadFile(path)
}
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
if err := refuseLinksUnderHome(dir); err != nil {
return nil, err
}
var made []string
for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) {
if _, err := os.Lstat(d); err == nil {
break
}
made = append(made, d)
}
return made, os.MkdirAll(dir, mode)
}
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
if err := refuseLinksUnderHome(path); err != nil {
return err
}
return writeAtomicallyByPath(path, content, mode)
}
+30 -7
View File
@@ -302,7 +302,7 @@ func own(path, owner string) error {
if err != nil {
return fmt.Errorf("%s should belong to %q: %w", path, owner, err)
}
if err := os.Chown(path, uid, gid); err != nil {
if err := chownPath(path, uid, gid); err != nil {
return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
}
return nil
@@ -359,7 +359,7 @@ func ownedBy(path, owner string) (bool, error) {
if err != nil {
return false, nil
}
info, err := os.Stat(path)
info, err := statPath(path)
if err != nil {
return false, err
}
@@ -392,6 +392,15 @@ func makeDirs(dir string, mode os.FileMode, owner string) error {
// can take away on removal the parents it made to reach its directory (novox/hq issue 162).
func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) {
var made []string
if below := homeAbove(dir); below != "" {
// Below a home, each directory is made in its parent's descriptor and none is reached through a link
// (novox/hq ADR 0266).
var err error
if made, err = mkdirAllUnder(below, dir, mode); err != nil {
return made, err
}
return made, giveMade(made, owner)
}
for d := filepath.Clean(dir); ; d = filepath.Dir(d) {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
break
@@ -404,14 +413,19 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
if err := os.MkdirAll(dir, mode); err != nil {
return nil, err
}
return made, giveMade(made, owner)
}
// giveMade gives the directories the host made inside the owner's home to the owner.
func giveMade(made []string, owner string) error {
if owner == "" || len(made) == 0 {
return made, nil
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 made, nil
return nil
}
home = filepath.Clean(home)
for _, d := range made {
@@ -419,10 +433,10 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
continue
}
if err := ownMade(d, owner); err != nil {
return made, err
return err
}
}
return made, nil
return nil
}
// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to
@@ -444,10 +458,19 @@ func ownAll(root, owner string) error {
if owner == "" {
return nil
}
return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// A link in the tree is owned as a link: chown follows one, and root must never give away what it
// points at (novox/hq ADR 0266).
if info != nil && info.Mode()&os.ModeSymlink != 0 {
uid, gid, ierr := idsOf(owner)
if ierr != nil {
return fmt.Errorf("%s should belong to %q: %w", path, owner, ierr)
}
return os.Lchown(path, uid, gid)
}
return own(path, owner)
})
}