Follow no link below a home as root, and judge more ways to root
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:
@@ -1095,7 +1095,7 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// And which units its service managers say failed, and whose each is (novox/hq issue 315).
|
||||
unitJudge = units.New(units.Exec{Run: apply.ExecRunner, System: builtFor})
|
||||
// And whether an account a module put in a group has it where it runs (novox/hq ADR 0252).
|
||||
accountJudge = accounts.New(accounts.Exec{Run: accounts.Runner(apply.ExecRunner)})
|
||||
accountJudge = accounts.New(accounts.Exec{Run: accounts.CLocale, Cache: &accounts.SetuidCache{Every: time.Hour}})
|
||||
}
|
||||
|
||||
// **Standing aside for a successor happens between reconciles and nowhere else** (novox/hq ADR
|
||||
|
||||
@@ -5,11 +5,11 @@ go 1.26.0
|
||||
require (
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
|
||||
+29
-14
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner).
|
||||
@@ -27,6 +28,14 @@ type Exec struct {
|
||||
Proc string
|
||||
// Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own.
|
||||
Stat func(path string) (FileMode, error)
|
||||
// ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's.
|
||||
ReadFile func(path string) ([]byte, error)
|
||||
ReadDir func(path string) ([]fs.DirEntry, error)
|
||||
ACL func(path string) ([]ACLEntry, error)
|
||||
// Cache keeps the search for setuid programs between looks; nil searches on every look.
|
||||
Cache *SetuidCache
|
||||
// Now is the clock the cache is kept by; nil is the machine's.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits.
|
||||
@@ -35,9 +44,11 @@ type FileMode struct {
|
||||
Perm fs.FileMode
|
||||
}
|
||||
|
||||
// Escalation is every way account can become root without a person (novox/hq ADR 0266): its uid, a group
|
||||
// of RootGroups the user database lists it in, any sudo rule naming it or a group of it, and any of secrets
|
||||
// it can read by owner, group or other bits. A secret not there yet is skipped: there is nothing to read.
|
||||
// Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge
|
||||
// looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database
|
||||
// lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other
|
||||
// bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns.
|
||||
// sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read.
|
||||
// The parent directories are not walked, so a file the bits allow and a directory hides is still said:
|
||||
// the judge errs toward saying a way that is not, never toward missing one that is.
|
||||
func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) {
|
||||
@@ -70,17 +81,17 @@ func (e Exec) Escalation(ctx context.Context, account string, secrets []string)
|
||||
if len(rules) > 0 {
|
||||
ways = append(ways, "sudo grants it: "+strings.Join(rules, ", "))
|
||||
}
|
||||
gidsOut, err := e.Run(ctx, "id", "-G", account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
|
||||
}
|
||||
gids := map[int]bool{}
|
||||
for _, f := range strings.Fields(gidsOut) {
|
||||
if n, err := strconv.Atoi(f); err == nil {
|
||||
gids[n] = true
|
||||
}
|
||||
}
|
||||
if len(secrets) > 0 {
|
||||
gidsOut, err := e.Run(ctx, "id", "-G", account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
|
||||
}
|
||||
gids := map[int]bool{}
|
||||
for _, f := range strings.Fields(gidsOut) {
|
||||
if n, err := strconv.Atoi(f); err == nil {
|
||||
gids[n] = true
|
||||
}
|
||||
}
|
||||
stat := e.Stat
|
||||
if stat == nil {
|
||||
stat = statOf
|
||||
@@ -98,7 +109,11 @@ func (e Exec) Escalation(ctx context.Context, account string, secrets []string)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ways, nil
|
||||
more, err := e.moreWays(ctx, account, uid, names, gids, secrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(ways, more...), nil
|
||||
}
|
||||
|
||||
// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as
|
||||
|
||||
@@ -27,6 +27,47 @@ type agentMachine struct {
|
||||
files map[string]FileMode
|
||||
failsID bool
|
||||
asked []string
|
||||
// texts are files' contents (doas's, polkit's); dirs a directory's files by name; acls a file's ACL;
|
||||
// setuid what find prints, and packaged the paths a package owns.
|
||||
texts map[string]string
|
||||
dirs map[string][]string
|
||||
acls map[string][]ACLEntry
|
||||
setuid string
|
||||
findErr error
|
||||
packaged map[string]bool
|
||||
}
|
||||
|
||||
func (m *agentMachine) readFile(path string) ([]byte, error) {
|
||||
if t, ok := m.texts[path]; ok {
|
||||
return []byte(t), nil
|
||||
}
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
|
||||
func (m *agentMachine) readDir(path string) ([]fs.DirEntry, error) {
|
||||
names, ok := m.dirs[path]
|
||||
if !ok {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
var out []fs.DirEntry
|
||||
for _, n := range names {
|
||||
out = append(out, fakeEntry(n))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeEntry string
|
||||
|
||||
func (f fakeEntry) Name() string { return string(f) }
|
||||
func (f fakeEntry) IsDir() bool { return false }
|
||||
func (f fakeEntry) Type() fs.FileMode { return 0 }
|
||||
func (f fakeEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrNotExist }
|
||||
|
||||
func (m *agentMachine) acl(path string) ([]ACLEntry, error) {
|
||||
if _, ok := m.files[path]; !ok {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return m.acls[path], nil
|
||||
}
|
||||
|
||||
func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -43,6 +84,13 @@ func (m *agentMachine) run(_ context.Context, name string, args ...string) (stri
|
||||
return m.gids + "\n", nil
|
||||
case line == "sudo -l -U agent":
|
||||
return m.sudo, m.sudoErr
|
||||
case name == "find":
|
||||
return m.setuid, m.findErr
|
||||
case name == "pacman" && len(args) == 2 && args[0] == "-Qqo":
|
||||
if m.packaged[args[1]] {
|
||||
return "somepackage\n", nil
|
||||
}
|
||||
return "", errors.New("pacman exited 1: error: No package owns " + args[1])
|
||||
}
|
||||
return "", errors.New("not a command the judge may run: " + line)
|
||||
}
|
||||
@@ -66,14 +114,15 @@ func clean() *agentMachine {
|
||||
|
||||
func lookAgent(t *testing.T, m *agentMachine) Verdict {
|
||||
t.Helper()
|
||||
j := New(Exec{Run: m.run, Stat: m.stat})
|
||||
j := New(Exec{Run: m.run, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
|
||||
j.Set([]Account{agent})
|
||||
st, _ := j.Look(t.Context())
|
||||
if len(st.Accounts) != 1 {
|
||||
t.Fatalf("the statement: %+v", st)
|
||||
}
|
||||
for _, a := range m.asked {
|
||||
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" {
|
||||
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "find / -xdev") &&
|
||||
!strings.HasPrefix(a, "pacman -Qqo ") {
|
||||
t.Errorf("the judge asked something that is not a read: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package accounts
|
||||
|
||||
// The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of
|
||||
// 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it,
|
||||
// and a way added here is a line added there.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Judged is every way to root the judge looks for, in the words its reasons use.
|
||||
var Judged = []string{
|
||||
"its uid is 0",
|
||||
"membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "),
|
||||
"any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)",
|
||||
"any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)",
|
||||
"any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " +
|
||||
"/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted",
|
||||
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
|
||||
"POSIX ACL",
|
||||
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
|
||||
"a setuid- or setgid-root program that no installed package owns, anywhere on the root filesystem",
|
||||
}
|
||||
|
||||
// NotJudged is what the judge does not look for: each is a way to root it would miss.
|
||||
var NotJudged = []string{
|
||||
"a root-run unit, timer, cron entry or script the account can write",
|
||||
"a directory on root's PATH, or in /etc/profile.d, the account can write",
|
||||
"root's or the operator's ssh keys or authorized_keys readable or writable by it",
|
||||
"a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " +
|
||||
"refuses links there)",
|
||||
"file capabilities (setcap) on a program",
|
||||
"a setuid program a package installed that has a flaw of its own",
|
||||
"a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)",
|
||||
}
|
||||
|
||||
// RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root.
|
||||
var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock",
|
||||
"/run/containerd/containerd.sock"}
|
||||
|
||||
// DoasConfigs and PolkitDirs are where those grants live.
|
||||
var (
|
||||
DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"}
|
||||
PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"}
|
||||
)
|
||||
|
||||
// ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write
|
||||
// once the ACL's mask is applied.
|
||||
type ACLEntry struct {
|
||||
User bool
|
||||
ID int
|
||||
Read, Write bool
|
||||
}
|
||||
|
||||
// The tags and bits of the kernel's ACL xattr.
|
||||
const (
|
||||
aclUser = 0x02
|
||||
aclGroup = 0x08
|
||||
aclMask = 0x10
|
||||
)
|
||||
|
||||
// ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the
|
||||
// named users' and groups' entries are answered with the mask applied.
|
||||
func ParseACL(raw []byte) ([]ACLEntry, error) {
|
||||
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
|
||||
return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw))
|
||||
}
|
||||
type entry struct {
|
||||
tag, perm uint16
|
||||
id uint32
|
||||
}
|
||||
var es []entry
|
||||
mask := uint16(7)
|
||||
for at := 4; at < len(raw); at += 8 {
|
||||
e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]),
|
||||
binary.LittleEndian.Uint32(raw[at+4:])}
|
||||
if e.tag == aclMask {
|
||||
mask = e.perm
|
||||
}
|
||||
es = append(es, e)
|
||||
}
|
||||
var out []ACLEntry
|
||||
for _, e := range es {
|
||||
if e.tag != aclUser && e.tag != aclGroup {
|
||||
continue
|
||||
}
|
||||
p := e.perm & mask
|
||||
out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// aclOf is a file's ACL from the machine: none when it has none.
|
||||
func aclOf(path string) ([]ACLEntry, error) {
|
||||
buf := make([]byte, 1024)
|
||||
n, err := unix.Getxattr(path, "system.posix_acl_access", buf)
|
||||
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "getxattr", Path: path, Err: err}
|
||||
}
|
||||
return ParseACL(buf[:n])
|
||||
}
|
||||
|
||||
// grantsByACL says whether an ACL entry gives the account read (or write).
|
||||
func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool {
|
||||
for _, e := range acl {
|
||||
if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) {
|
||||
if (write && e.Write) || (!write && e.Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Writable is Readable's twin for the write bits.
|
||||
func Writable(m FileMode, uid int, gids map[int]bool) bool {
|
||||
switch {
|
||||
case uid == 0:
|
||||
return true
|
||||
case m.UID == uid:
|
||||
return m.Perm&0o200 != 0
|
||||
case gids[m.GID]:
|
||||
return m.Perm&0o020 != 0
|
||||
default:
|
||||
return m.Perm&0o002 != 0
|
||||
}
|
||||
}
|
||||
|
||||
// DoasRules is every line of a doas configuration that permits the account or one of its groups.
|
||||
func DoasRules(conf string, account string, groups []string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(conf, "\n") {
|
||||
if i := strings.IndexByte(line, '#'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || f[0] != "permit" {
|
||||
continue
|
||||
}
|
||||
i := 1
|
||||
for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" ||
|
||||
f[i] == "setenv" || strings.HasPrefix(f[i], "{")) {
|
||||
if strings.HasPrefix(f[i], "{") {
|
||||
for i < len(f) && !strings.HasSuffix(f[i], "}") {
|
||||
i++
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i >= len(f) {
|
||||
continue
|
||||
}
|
||||
who := f[i]
|
||||
if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) {
|
||||
out = append(out, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PolkitNames says whether a polkit rule or authority file names the account or one of its groups.
|
||||
func PolkitNames(text, account string, groups []string) bool {
|
||||
needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`}
|
||||
for _, g := range groups {
|
||||
needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`)
|
||||
}
|
||||
for _, n := range needles {
|
||||
if strings.Contains(text, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contains(xs []string, s string) bool {
|
||||
for _, x := range xs {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetuidCache keeps the search for setuid programs, which walks the root filesystem, for Every: the judge
|
||||
// looks every minute, and a setuid program appears only by root's act.
|
||||
type SetuidCache struct {
|
||||
Every time.Duration
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
found []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *SetuidCache) get(now time.Time, search func() ([]string, error)) ([]string, error) {
|
||||
if c == nil {
|
||||
return search()
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.at.IsZero() || now.Sub(c.at) >= c.Every || c.err != nil {
|
||||
c.found, c.err = search()
|
||||
c.at = now
|
||||
}
|
||||
return c.found, c.err
|
||||
}
|
||||
|
||||
// setuidSearch is every setuid- or setgid-root regular file on the root filesystem that no installed package
|
||||
// owns, found with find and asked of the package manager. Bounded: a search that does not finish is an
|
||||
// unanswered question, never "none".
|
||||
func (e Exec) setuidSearch(ctx context.Context) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
out, err := e.Run(ctx, "find", "/", "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o",
|
||||
"-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o",
|
||||
"-type", "f", "-user", "root", "-perm", "/6000", "-print")
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("the search for setuid programs did not finish within two minutes")
|
||||
}
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
return nil, fmt.Errorf("the search for setuid programs did not finish: %w", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
paths = append(paths, l)
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var unowned []string
|
||||
for _, p := range paths {
|
||||
owned, err := e.packaged(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
unowned = append(unowned, p)
|
||||
}
|
||||
}
|
||||
return unowned, nil
|
||||
}
|
||||
|
||||
// packaged says whether an installed package owns a path: pacman's or apk's answer, never a guess.
|
||||
func (e Exec) packaged(ctx context.Context, path string) (bool, error) {
|
||||
out, err := e.Run(ctx, "pacman", "-Qqo", path)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
if strings.Contains(out+err.Error(), "No package owns") || strings.Contains(err.Error(), "exited 1") {
|
||||
return false, nil
|
||||
}
|
||||
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
|
||||
return false, fmt.Errorf("pacman could not say who owns %s: %w", path, err)
|
||||
}
|
||||
out, err = e.Run(ctx, "apk", "info", "-W", path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("no package manager could say who owns %s: %w", path, err)
|
||||
}
|
||||
return strings.Contains(out, " is owned by "), nil
|
||||
}
|
||||
|
||||
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
|
||||
func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool,
|
||||
secrets []string) ([]string, error) {
|
||||
read := e.ReadFile
|
||||
if read == nil {
|
||||
read = os.ReadFile
|
||||
}
|
||||
readDir := e.ReadDir
|
||||
if readDir == nil {
|
||||
readDir = os.ReadDir
|
||||
}
|
||||
acl := e.ACL
|
||||
if acl == nil {
|
||||
acl = aclOf
|
||||
}
|
||||
stat := e.Stat
|
||||
if stat == nil {
|
||||
stat = statOf
|
||||
}
|
||||
var ways []string
|
||||
|
||||
// doas.
|
||||
for _, conf := range DoasConfigs {
|
||||
raw, err := read(conf)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err)
|
||||
}
|
||||
for _, r := range DoasRules(string(raw), account, groups) {
|
||||
ways = append(ways, "doas permits it: "+r)
|
||||
}
|
||||
}
|
||||
|
||||
// polkit.
|
||||
for _, dir := range PolkitDirs {
|
||||
var named []string
|
||||
err := walkFiles(readDir, dir, func(path string) error {
|
||||
raw, err := read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if PolkitNames(string(raw), account, groups) {
|
||||
named = append(named, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err)
|
||||
}
|
||||
for _, p := range named {
|
||||
ways = append(ways, "a polkit rule names it or a group of it: "+p)
|
||||
}
|
||||
}
|
||||
|
||||
// The container runtimes' sockets.
|
||||
seen := map[string]bool{}
|
||||
for _, s := range RuntimeSockets {
|
||||
// Two names of one socket are one socket. A test that gives its own files names them as they are.
|
||||
real, err := filepath.EvalSymlinks(s)
|
||||
if e.Stat != nil {
|
||||
real, err = s, nil
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the socket %s could not be read: %w", s, err)
|
||||
}
|
||||
if seen[real] {
|
||||
continue
|
||||
}
|
||||
seen[real] = true
|
||||
m, err := stat(real)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err)
|
||||
}
|
||||
a, err := acl(real)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) {
|
||||
ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root")
|
||||
}
|
||||
}
|
||||
|
||||
// The secrets' ACLs: the bits were judged already.
|
||||
for _, path := range secrets {
|
||||
a, err := acl(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err)
|
||||
}
|
||||
if grantsByACL(a, uid, gids, false) {
|
||||
ways = append(ways, "an ACL lets it read the secret "+path)
|
||||
}
|
||||
}
|
||||
|
||||
// setuid programs no package owns.
|
||||
now := time.Now
|
||||
if e.Now != nil {
|
||||
now = e.Now
|
||||
}
|
||||
unowned, err := e.Cache.get(now(), func() ([]string, error) { return e.setuidSearch(ctx) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range unowned {
|
||||
ways = append(ways, "a setuid-root program no package owns: "+p)
|
||||
}
|
||||
return ways, nil
|
||||
}
|
||||
|
||||
// walkFiles calls fn for every regular file below dir, through readDir.
|
||||
func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error {
|
||||
entries, err := readDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, en := range entries {
|
||||
p := filepath.Join(dir, en.Name())
|
||||
if en.IsDir() {
|
||||
if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := fn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one
|
||||
// language whatever the machine's is; stdin closed, output captured.
|
||||
func CLocale(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return string(out), fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The ways beyond uid, groups and sudo (novox/hq ADR 0266, the review of 2026-10-08): doas, polkit, a
|
||||
// runtime's socket, an ACL on a secret, a setuid program no package owns — each said; each unread question
|
||||
// unknown; and the judge's commands in the C locale.
|
||||
|
||||
func TestEachFurtherWayToRootIsSaid(t *testing.T) {
|
||||
const broker = "/var/lib/mesh/node-tools/broker"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
set func(*agentMachine)
|
||||
said string
|
||||
}{
|
||||
{"doas by name", func(m *agentMachine) {
|
||||
m.texts = map[string]string{"/etc/doas.conf": "permit persist operator\npermit nopass agent as root\n"}
|
||||
}, "doas permits it: permit nopass agent as root"},
|
||||
{"doas by group", func(m *agentMachine) {
|
||||
m.groups = "agent builders"
|
||||
m.texts = map[string]string{"/etc/opendoas.conf": "permit :builders # the builders\n"}
|
||||
}, "doas permits it: permit :builders"},
|
||||
{"polkit by name", func(m *agentMachine) {
|
||||
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"10-agent.rules"}}
|
||||
m.texts = map[string]string{"/etc/polkit-1/rules.d/10-agent.rules": `polkit.addRule(function(a, s) { if (s.user == "agent") return polkit.Result.YES; });`}
|
||||
}, "a polkit rule names it or a group of it: /etc/polkit-1/rules.d/10-agent.rules"},
|
||||
{"polkit by group", func(m *agentMachine) {
|
||||
m.groups = "agent network"
|
||||
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-nm.rules"}}
|
||||
m.texts = map[string]string{"/usr/share/polkit-1/rules.d/50-nm.rules": `if (subject.isInGroup("network")) return polkit.Result.YES;`}
|
||||
}, "a polkit rule names it or a group of it: /usr/share/polkit-1/rules.d/50-nm.rules"},
|
||||
{"docker socket by group bits", func(m *agentMachine) {
|
||||
m.gids = "1600 970"
|
||||
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
||||
}, "it can write the container runtime's socket /run/docker.sock"},
|
||||
{"docker socket by ACL", func(m *agentMachine) {
|
||||
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
||||
m.acls = map[string][]ACLEntry{"/run/docker.sock": {{User: true, ID: 1600, Read: true, Write: true}}}
|
||||
}, "it can write the container runtime's socket /run/docker.sock"},
|
||||
{"secret by ACL", func(m *agentMachine) {
|
||||
m.acls = map[string][]ACLEntry{broker: {{User: false, ID: 1600, Read: true}}}
|
||||
}, "an ACL lets it read the secret " + broker},
|
||||
{"setuid no package owns", func(m *agentMachine) {
|
||||
m.setuid = "/usr/bin/sudo\n/usr/local/bin/rootshell\n"
|
||||
m.packaged = map[string]bool{"/usr/bin/sudo": true}
|
||||
}, "a setuid-root program no package owns: /usr/local/bin/rootshell"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
m := clean()
|
||||
c.set(m)
|
||||
v := lookAgent(t, m)
|
||||
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
|
||||
t.Fatalf("want %q said: %+v", c.said, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatGrantsSomebodyElseIsNoWay(t *testing.T) {
|
||||
m := clean()
|
||||
m.texts = map[string]string{"/etc/doas.conf": "permit operator\npermit :wheel\n# permit agent\n",
|
||||
"/usr/share/polkit-1/rules.d/50-default.rules": `polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`}
|
||||
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-default.rules"}}
|
||||
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
||||
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n"
|
||||
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
|
||||
if v := lookAgent(t, m); v.State != Healthy {
|
||||
t.Fatalf("the operator's and wheel's grants, a socket of another group, packaged setuid programs: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoasWithoutSudoIsJudgedFromItsRules(t *testing.T) {
|
||||
m := clean()
|
||||
m.sudo, m.sudoErr = "", fmt.Errorf("sudo: %w", exec.ErrNotFound)
|
||||
m.texts = map[string]string{"/etc/doas.conf": "permit nopass agent\n"}
|
||||
v := lookAgent(t, m)
|
||||
if v.State != Unhealthy || !strings.Contains(v.Reason, "doas permits it") {
|
||||
t.Fatalf("no sudo is no sudo rule, and doas is read for itself: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFurtherQuestionUnansweredIsUnknown(t *testing.T) {
|
||||
m := clean()
|
||||
m.findErr = errors.New("find: interrupted")
|
||||
if v := lookAgent(t, m); v.State != Unknown {
|
||||
t.Fatalf("a search that did not finish: %+v", v)
|
||||
}
|
||||
m = clean()
|
||||
m.setuid = "/usr/local/bin/x\n"
|
||||
j := New(Exec{Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "pacman" || name == "apk" {
|
||||
return "", fmt.Errorf("%s: %w", name, exec.ErrNotFound)
|
||||
}
|
||||
return m.run(ctx, name, args...)
|
||||
}, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
|
||||
j.Set([]Account{agent})
|
||||
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Unknown {
|
||||
t.Fatalf("no package manager to ask: %+v", st.Accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSetuidSearchIsKeptForItsInterval(t *testing.T) {
|
||||
c := &SetuidCache{Every: time.Hour}
|
||||
searched := 0
|
||||
search := func() ([]string, error) { searched++; return nil, nil }
|
||||
at := time.Date(2026, 10, 8, 19, 0, 0, 0, time.UTC)
|
||||
c.get(at, search)
|
||||
c.get(at.Add(30*time.Minute), search)
|
||||
c.get(at.Add(61*time.Minute), search)
|
||||
if searched != 2 {
|
||||
t.Fatalf("searched %d times in 61 minutes, want 2", searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseACL(t *testing.T) {
|
||||
entry := func(tag, perm uint16, id uint32) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint16(b, tag)
|
||||
binary.LittleEndian.PutUint16(b[2:], perm)
|
||||
binary.LittleEndian.PutUint32(b[4:], id)
|
||||
return b
|
||||
}
|
||||
raw := []byte{2, 0, 0, 0}
|
||||
raw = append(raw, entry(0x01, 6, 0xffffffff)...)
|
||||
raw = append(raw, entry(0x02, 6, 1600)...) // user agent rw
|
||||
raw = append(raw, entry(0x04, 4, 0xffffffff)...)
|
||||
raw = append(raw, entry(0x08, 6, 970)...) // group 970 rw
|
||||
raw = append(raw, entry(0x10, 4, 0xffffffff)...) // mask r--
|
||||
raw = append(raw, entry(0x20, 0, 0xffffffff)...)
|
||||
acl, err := ParseACL(raw)
|
||||
if err != nil || len(acl) != 2 {
|
||||
t.Fatalf("%v %v", acl, err)
|
||||
}
|
||||
if !acl[0].User || acl[0].ID != 1600 || !acl[0].Read || acl[0].Write {
|
||||
t.Fatalf("the mask takes write away: %+v", acl[0])
|
||||
}
|
||||
if grantsByACL(acl, 1600, nil, true) || !grantsByACL(acl, 1601, map[int]bool{970: true}, false) {
|
||||
t.Fatal("grants")
|
||||
}
|
||||
if _, err := ParseACL([]byte{2, 0, 0}); err == nil {
|
||||
t.Fatal("a short ACL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheJudgesCommandsRunInTheCLocale(t *testing.T) {
|
||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
||||
t.Setenv("LANG", "de_DE.UTF-8")
|
||||
out, err := CLocale(t.Context(), "sh", "-c", `echo "$LC_ALL $LANG"`)
|
||||
if err != nil || strings.TrimSpace(out) != "C C" {
|
||||
t.Fatalf("%q %v", out, err)
|
||||
}
|
||||
if _, err := CLocale(t.Context(), "sh", "-c", "echo nope >&2; exit 3"); err == nil ||
|
||||
!strings.Contains(err.Error(), "exited 3: nope") {
|
||||
t.Fatalf("an exit is said with its words: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheJudgedListIsWrittenDown(t *testing.T) {
|
||||
if len(Judged) < 8 || len(NotJudged) == 0 {
|
||||
t.Fatal("what is judged and what is not are both written down")
|
||||
}
|
||||
}
|
||||
+32
-8
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user