package apply // A mesh directory outside a home is given its mode and owner only through a descriptor (novox/hq issue 358). // // Below a home every component is opened without following a link (ADR 0266, home_links.go), and a directory the // operator handed to the mesh is changed through one descriptor opened from / with no link followed (issue 356). // Every other directory the mesh owns — a module's state under /var/lib, a drop-in directory under /etc — was // given its mode and owner by path, with chmod and chown, which follow a link anywhere in the path: whoever could // turn a parent into a link between two applies directed root's chmod and chown at the link's target. // // The danger is a link another account can make or replace, not a link as such: /var/run is a link to /run on // every node, and a disk is sometimes reached through one. So (the decision recorded on issue 358, 2026-10-11): // // - **the directory is opened component by component from /**, each with O_DIRECTORY|O_NOFOLLOW, and its mode // and owner are changed with fchmod and fchown on that descriptor; a directory the walk makes is made in its // parent's descriptor. A link put in place after any check is refused by the kernel at the moment of use; // - **a link in a parent is followed only when root alone could have put it there**: the link is root's, and // every directory from / down to the one holding it is root's and writable by no other account (no write for // others, no write for a group other than root's). Its target is then walked the same way, from /; // - **any other link refuses that directory**, naming the link and the account that could replace it; nothing // is changed there, and the rest of the declaration applies; // - **the directory itself is never a link** (issue 339, applyDirectory). // // A directory below a home keeps the walk of ADR 0266, which follows no link below the home at all. import ( "fmt" osuser "os/user" "path/filepath" "strconv" "strings" "github.com/novox/mesh-host/internal/declaration" ) // LinkInPathError is a mesh directory refused because a link in its path is one another account could replace. type LinkInPathError struct { // Path is the directory declared, Link the link in its path, Who the account that could replace it. Path, Link, Who string } func (e *LinkInPathError) Error() string { return e.Path + " " + e.why() } // why is the refusal without the path it refuses, as a PlacementRefusedError says it. func (e *LinkInPathError) why() string { return fmt.Sprintf("is reached through the link %s, which %s could replace: the node-engine runs as root and "+ "follows a link to a directory it gives a mode and owner only when root alone could have made it (novox/hq "+ "issue 358). Make %s a directory, or make it and every directory above it root's and writable by no other "+ "account", e.Link, e.Who, e.Link) } // couldReplace says who besides root could replace an entry of a directory with this owner, group and mode: "" // when nobody can. A variable so a test, which runs below a directory every account may write (/tmp), can name // which of its own directories count as root's alone. var couldReplace = func(path string, uid, gid, mode uint32) string { if uid != 0 { return "the account " + accountNamed(uid) + " (it owns " + path + ")" } if mode&0o002 != 0 { return "every account (" + path + " is writable by all)" } if mode&0o020 != 0 && gid != 0 { return "the group " + groupNamed(gid) + " (it may write " + path + ")" } return "" } // linkReplacer says who besides root owns a link: "" when root does. A variable for the same reason. var linkReplacer = func(link string, uid uint32) string { if uid != 0 { return "the account " + accountNamed(uid) + " (it owns the link)" } return "" } func accountNamed(uid uint32) string { if u, err := osuser.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil { return u.Username } return "with uid " + strconv.FormatUint(uint64(uid), 10) } func groupNamed(gid uint32) string { if g, err := osuser.LookupGroupId(strconv.FormatUint(uint64(gid), 10)); err == nil { return g.Name } return "with gid " + strconv.FormatUint(uint64(gid), 10) } // JudgeDirectories is the walk's judgement over every directory a declaration names, read-only (novox/hq issue // 358): nothing is made, chmodded or chowned. One line per directory, in the declaration's order, after one line // that counts them: placed, would be refused and why, refused already by the placement guard (issue 339), or // below a home, where ADR 0266's walk judges it as before. func JudgeDirectories(d *declaration.Declaration) string { var lines []string var placed, refused, guarded, homed int for _, r := range d.Resources { dir, ok := r.(*declaration.Directory) if !ok { continue } path := filepath.Clean(dir.Path) if err := refusePath(path, placingDirectory, moduleOfID(dir.ID), dir.Owner); err != nil { guarded++ lines = append(lines, path+": refused already by the placement guard: "+err.Error()) continue } if home := homeAbove(path); home != "" { homed++ lines = append(lines, path+": below the home "+home+", walked without following any link as before (ADR 0266)") continue } if err := judgeMeshDir(path); err != nil { refused++ lines = append(lines, path+": would be refused: "+err.Error()) continue } placed++ lines = append(lines, path+": placed") } head := fmt.Sprintf("%d directories declared: %d placed, %d would be refused, %d refused already by the "+ "placement guard, %d below a home (novox/hq issue 358; judged read-only, nothing changed)", placed+refused+guarded+homed, placed, refused, guarded, homed) return strings.Join(append([]string{head}, lines...), "\n") }