Files
mesh-host/internal/apply/walk_linux.go
T
jschoubben c7551162d6 Give no account a home, keep old data's sub-paths for the warrant, and never hand out setgid (hq ADR 0328 review)
usermod --gid regroups every file under an account's home, and accounts were made
with / as home: they now have /nonexistent, and an older account's home is moved
there (without its files) before its group changes. A sub-path asked for later that
holds data not in the group waits for the operator as the root would. The take act
gives the group to hardlinked files an account owns (the *arr apps' downloads) and
leaves root's; a file's setuid or setgid bit the regroup takes is never put back, so
no setgid file hands out the library's group; each is counted. An apply no longer
waits for a running take walk.
2026-10-12 22:18:16 +02:00

536 lines
18 KiB
Go

//go:build linux
package apply
// The descriptor half of walk.go and library.go (novox/hq ADR 0328 §6, §7, §9).
import (
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"golang.org/x/sys/unix"
)
// walkStats is how a walk reads an entry and an open descriptor: the machine's own calls, or a test's stand-in
// for a device it cannot mount.
type walkStats struct {
at func(dirfd int, name string, st *unix.Stat_t) error
fd func(fd int, st *unix.Stat_t) error
}
var machineStats = walkStats{
at: func(dirfd int, name string, st *unix.Stat_t) error {
return unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW)
},
fd: unix.Fstat,
}
// What a visit did to one entry, as flags: nothing (it had what it needs), or changed, and beside it whether it was a
// file with another name (linked, changed; shared, left), and whether its setuid or setgid bit went.
const (
visitKept = 0
visitChanged = 1 << iota
visitLinked // a file with another name, owned by an account: given the group
visitShared // a file with another name, owned by root: left as it is
visitStripped // a file whose setuid or setgid bit the group change took, and which stays taken
)
// visitFunc is what a walk does to one folder or file, open on fd, whose stat is st: what it did, or the error
// counted on that entry alone (errStopWalk stops the walk).
type visitFunc func(fd int, st unix.Stat_t, folder bool) (int, error)
// errStopWalk wraps an error that stops the whole walk, never one entry's alone.
type errStopWalk struct{ err error }
func (e errStopWalk) Error() string { return e.err.Error() }
func (e errStopWalk) Unwrap() error { return e.err }
// treeWalk is one walk under a library's root, from the descriptor of the root itself.
type treeWalk struct {
dev uint64
deadline time.Time
limit int
stats walkStats
visit visitFunc
counts WalkCounts
// at is the names of the levels below the root down to the entry being walked; Cursor, once the walk met its
// bound, where the next goes on.
at []string
Cursor []string
// Failed is the first failure on one entry, in words with no path.
Failed string
}
// openRoot opens a library's root as a mesh directory is opened (no link another account could make, the root
// itself no link) and starts a walk from it on the root's own device.
func openRoot(path string, limits WalkLimits, stats walkStats, visit visitFunc) (*treeWalk, int, unix.Stat_t, error) {
var st unix.Stat_t
if err := keptPath(path); err != nil {
return nil, -1, st, err
}
mw := &meshWalk{want: path}
fd, err := mw.open()
if errors.Is(err, errNotThereYet) {
return nil, -1, st, errors.New("the root is not there; nothing was changed")
}
if err != nil {
return nil, -1, st, errors.New("the root is not walked: " + pathless(err))
}
if mw.At != path {
unix.Close(fd)
return nil, -1, st, errors.New("the root is reached through a link, and the engine walks no root through " +
"one; nothing was changed")
}
if err := stats.fd(fd, &st); err != nil {
unix.Close(fd)
return nil, -1, st, fmt.Errorf("the root cannot be read: %v; nothing was changed", err)
}
return &treeWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries,
stats: stats, visit: visit}, fd, st, nil
}
// walkRoot walks path whole, or from resume, with visit: the walk, with what it did and the cursor where it stopped
// when it met its bound (with ErrWalkBound), and the walk's own error otherwise.
func walkRoot(path string, limits WalkLimits, stats walkStats, resume []string, visit visitFunc) (*treeWalk, error) {
w, fd, st, err := openRoot(path, limits, stats, visit)
if err != nil {
return &treeWalk{}, err
}
return w, w.dir(fd, st, resume)
}
// read counts one entry, and says the bound when it is met, keeping where the walk stood as the cursor.
func (w *treeWalk) read() error {
if w.counts.Read >= w.limit || time.Now().After(w.deadline) {
w.Cursor = append([]string(nil), w.at...)
return ErrWalkBound
}
w.counts.Read++
return nil
}
// dir is one folder, open on fd, whose stat is st: the folder itself (unless the walk resumes below it, which an
// earlier walk visited), then every entry in it in the order of their names. It closes fd.
func (w *treeWalk) dir(fd int, st unix.Stat_t, resume []string) error {
f := os.NewFile(uintptr(fd), "")
defer f.Close()
if len(resume) == 0 {
if err := w.read(); err != nil {
return err
}
if err := w.visited(fd, st, true); err != nil {
return err
}
}
names, err := f.Readdirnames(-1)
if err != nil {
return fmt.Errorf("a folder could not be read: %v", err)
}
sort.Strings(names)
for _, name := range names {
var below []string
if len(resume) > 0 {
if name < resume[0] {
continue
}
if name == resume[0] {
below = resume[1:]
}
resume = nil
}
w.at = append(w.at, name)
err := w.entry(fd, name, below)
w.at = w.at[:len(w.at)-1]
if err != nil {
return err
}
}
return nil
}
// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file visited
// through its own descriptor, anything else left.
func (w *treeWalk) entry(dirfd int, name string, resume []string) error {
var st unix.Stat_t
if err := w.stats.at(dirfd, name, &st); err != nil {
if errors.Is(err, unix.ENOENT) {
return nil
}
return fmt.Errorf("an entry could not be read: %v", err)
}
switch st.Mode & unix.S_IFMT {
case unix.S_IFLNK:
w.counts.Links++
return nil
case unix.S_IFDIR, unix.S_IFREG:
default:
w.counts.Other++
return nil
}
if uint64(st.Dev) != w.dev {
w.counts.Mounts++
return nil
}
isDir := st.Mode&unix.S_IFMT == unix.S_IFDIR
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NOCTTY | unix.O_NONBLOCK
if isDir {
flags |= unix.O_DIRECTORY
}
fd, err := unix.Openat(dirfd, name, flags, 0)
switch {
case errors.Is(err, unix.ENOENT):
return nil
case errors.Is(err, unix.ELOOP), errors.Is(err, unix.ENOTDIR):
// Swapped for a link, or for something else, after it was looked at: passed by.
w.counts.Links++
return nil
case err != nil:
return fmt.Errorf("an entry could not be opened: %v", err)
}
var opened unix.Stat_t
if err := w.stats.fd(fd, &opened); err != nil {
unix.Close(fd)
return fmt.Errorf("an entry could not be read: %v", err)
}
if opened.Dev != st.Dev || opened.Ino != st.Ino || opened.Mode&unix.S_IFMT != st.Mode&unix.S_IFMT {
unix.Close(fd)
return errors.New("an entry changed while it was walked, and the walk stopped there")
}
if isDir {
return w.dir(fd, opened, resume)
}
defer unix.Close(fd)
if err := w.read(); err != nil {
return err
}
return w.visited(fd, opened, false)
}
// visited visits one entry and counts what it did. An error that stops the walk stops it; any other failure on
// one entry (an immutable file, say) is counted failed, its words kept once, and the walk goes on.
func (w *treeWalk) visited(fd int, st unix.Stat_t, folder bool) error {
did, err := w.visit(fd, st, folder)
var stop errStopWalk
if errors.As(err, &stop) {
return stop.err
}
if err != nil {
w.counts.Failed++
if w.Failed == "" {
w.Failed = err.Error()
}
return nil
}
switch {
case did&visitShared != 0:
w.counts.Shared++
case did&visitChanged != 0 && folder:
w.counts.Folders++
case did&visitChanged != 0:
w.counts.Files++
default:
w.counts.Kept++
}
if did&visitLinked != 0 {
w.counts.Linked++
}
if did&visitStripped != 0 {
w.counts.Stripped++
}
return nil
}
// groupVisit gives each entry the library's group gid, group read and write, and on a folder group enter and the
// setgid bit, so what is made beneath keeps the group (ADR 0328 §6, §7). The owner, the owner's bits and others'
// bits are never changed — but for what the group change itself takes:
//
// - **A file's setuid and setgid bits stay taken.** The kernel drops them when a file's group changes; a setgid
// file in the library's group would hand anyone who runs it the group, and a setuid one is no library data. They
// are never put back, setgid is taken off a file the group change left it on, and each such file is counted.
// - **A file with another name** (the *arr apps hardlink downloads into the library) is given the group when an
// account owns it: the kernel's protected_hardlinks already keeps an account from linking a file it does not
// own. One root owns is left as it is: its other name may be anywhere, and the group would reach it. Both are
// counted.
func groupVisit(gid int) visitFunc {
return func(fd int, st unix.Stat_t, folder bool) (int, error) {
did := visitKept
if !folder && st.Nlink > 1 {
if st.Uid == 0 {
return visitShared, nil
}
did |= visitLinked
}
mode := uint32(st.Mode) & 0o7777
want := mode | 0o060
if folder {
want = mode | 0o070 | unix.S_ISGID
}
regroup := int(st.Gid) != gid
if !folder && regroup {
want &^= unix.S_ISUID | unix.S_ISGID
}
if !regroup && want == mode {
return did &^ visitLinked, nil
}
if regroup {
if err := unix.Fchown(fd, -1, gid); err != nil {
return 0, fmt.Errorf("an entry's group could not be set: %v", err)
}
}
if err := unix.Fchmod(fd, want); err != nil {
return 0, fmt.Errorf("an entry's mode could not be set: %v", err)
}
if !folder && mode&(unix.S_ISUID|unix.S_ISGID) != 0 && want&(unix.S_ISUID|unix.S_ISGID) == 0 {
did |= visitStripped
}
return did | visitChanged, nil
}
}
// folderEmpty is whether the folder open on fd holds nothing yet: a root the mesh may keep without asking.
func folderEmpty(fd int) (bool, error) {
dup, err := unix.Dup(fd)
if err != nil {
return false, err
}
f := os.NewFile(uintptr(dup), "")
defer f.Close()
names, err := f.Readdirnames(1)
switch {
case len(names) > 0:
return false, nil
case err == nil, errors.Is(err, io.EOF):
return true, nil
}
return false, fmt.Errorf("the folder could not be read: %v", err)
}
// FolderFacts is what the engine reads of a library's root before keeping it: its owner, group, mode and whether
// it holds data.
type FolderFacts struct {
UID, GID uint32
Mode uint32
Empty bool
}
// readFolder reads a folder's facts through the descriptor a mesh directory is opened by.
func readFolder(path string) (FolderFacts, error) {
if err := keptPath(path); err != nil {
return FolderFacts{}, err
}
mw := &meshWalk{want: path}
fd, err := mw.open()
if err != nil {
return FolderFacts{}, err
}
defer unix.Close(fd)
if mw.At != path {
return FolderFacts{}, errors.New("the folder is reached through a link, and the engine keeps no folder " +
"through one")
}
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return FolderFacts{}, err
}
empty, err := folderEmpty(fd)
if err != nil {
return FolderFacts{}, err
}
return FolderFacts{UID: st.Uid, GID: st.Gid, Mode: uint32(st.Mode) & 0o7777, Empty: empty}, nil
}
// keepFolders keeps a library's root and each of its requested sub-paths — the folders themselves, never what is
// in them — in the group gid with group read, write and enter and the setgid bit (ADR 0328 §6). A sub-path that is
// missing is made, each level with the root's owner, the group, and the root's owner's and others' bits beside
// group rwx and setgid (§9). No link is followed (the root is reached as a mesh directory is, and every level below
// it by O_NOFOLLOW), nothing is made or changed on another filesystem, and the owner and the owner's and others'
// bits of a folder that was there are never changed. How many folders it changed and made.
func keepFolders(root string, paths []string, gid int) (changed, made int, err error) {
_, fd, rst, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return 0, 0, err
}
defer unix.Close(fd)
keep := func(fd int, st unix.Stat_t) error {
mode := uint32(st.Mode) & 0o7777
want := mode | 0o070 | unix.S_ISGID
if int(st.Gid) == gid && want == mode {
return nil
}
if int(st.Gid) != gid {
if err := unix.Fchown(fd, -1, gid); err != nil {
return fmt.Errorf("a folder's group could not be set: %v", err)
}
}
if err := unix.Fchmod(fd, want); err != nil {
return fmt.Errorf("a folder's mode could not be set: %v", err)
}
changed++
return nil
}
if err := keep(fd, rst); err != nil {
return changed, made, err
}
madeMode := uint32(rst.Mode)&0o0707 | 0o070 | unix.S_ISGID
for _, p := range paths {
n, err := keepPath(fd, rst, p, gid, madeMode, keep)
made += n
if err != nil {
return changed, made, err
}
}
return changed, made, nil
}
// keepPath walks one sub-path down from the root open on rootFd, making each missing level, and keeps the last.
func keepPath(rootFd int, rst unix.Stat_t, p string, gid int, madeMode uint32,
keep func(int, unix.Stat_t) error) (int, error) {
made := 0
cur, err := unix.Dup(rootFd)
if err != nil {
return made, err
}
defer func() { unix.Close(cur) }()
const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
levels := strings.Split(p, "/")
for i, name := range levels {
next, err := unix.Openat(cur, name, dirFlags, 0)
if errors.Is(err, unix.ENOENT) {
// Made for its maker alone (the engine, as root), so it is nobody else's until it is the root's.
if mkErr := unix.Mkdirat(cur, name, 0o700); mkErr != nil && !errors.Is(mkErr, unix.EEXIST) {
return made, fmt.Errorf("a level of the path %s could not be made: %v", p, mkErr)
}
if next, err = unix.Openat(cur, name, dirFlags, 0); err == nil {
if err = unix.Fchown(next, int(rst.Uid), gid); err == nil {
err = unix.Fchmod(next, madeMode)
}
if err != nil {
unix.Close(next)
return made, fmt.Errorf("a level of the path %s made could not be given the root's owner "+
"and the library's group: %v", p, err)
}
made++
}
}
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
return made, fmt.Errorf("the path %s is not a folder below the root (a link, or a file), and the "+
"engine keeps no path through one", p)
}
if err != nil {
return made, fmt.Errorf("the path %s could not be opened: %v", p, err)
}
unix.Close(cur)
cur = next
var st unix.Stat_t
if err := unix.Fstat(cur, &st); err != nil {
return made, err
}
if st.Dev != rst.Dev {
return made, fmt.Errorf("the path %s lies on another filesystem than the root, and the engine keeps "+
"nothing there", p)
}
if i == len(levels)-1 {
if err := keep(cur, st); err != nil {
return made, err
}
}
}
return made, nil
}
// MountFsid is the kernel's id of the filesystem at point (statfs f_fsid), in hex; empty when it cannot be read.
// A variable so a test can stand in for a filesystem it cannot mount.
var MountFsid = func(point string) string {
var st unix.Statfs_t
if err := unix.Statfs(point, &st); err != nil {
return ""
}
return fmt.Sprintf("%08x%08x", uint32(st.Fsid.Val[0]), uint32(st.Fsid.Val[1]))
}
// MountUUID is the UUID of the filesystem at point when it is on a block device: the name in /dev/disk/by-uuid of
// the device the mount's own device number is. Empty for one on no block device (NFS, FUSE, ZFS, a btrfs
// subvolume's own device). A variable so a test can stand in for disks it cannot make.
var MountUUID = func(point string) string {
var st unix.Stat_t
if err := unix.Stat(point, &st); err != nil {
return ""
}
const dir = "/dev/disk/by-uuid"
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
var dev unix.Stat_t
if unix.Stat(dir+"/"+e.Name(), &dev) == nil && dev.Mode&unix.S_IFMT == unix.S_IFBLK && dev.Rdev == st.Dev {
return e.Name()
}
}
return ""
}
// SampleLimits bound the sample that judges whether a root's data is already the library's: so many entries a
// folder, so many levels down, for so long. A sample that runs out of time is not in shape: it asks.
var SampleLimits = struct {
PerFolder, Levels int
Time time.Duration
}{PerFolder: 20, Levels: 3, Time: 2 * time.Second}
// sampleInShape is whether the first entries of each folder under root, a few levels down, are all in the group gid
// with group read and write, and folders with group enter and setgid (ADR 0328 §7). Reads only, by descriptor: no
// link followed, no other filesystem entered. A file with more than one name is not judged (the take act leaves it).
func sampleInShape(root string, gid int) bool {
_, fd, st, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
if err != nil {
return false
}
deadline := time.Now().Add(SampleLimits.Time)
return sampleFolder(fd, uint64(st.Dev), gid, SampleLimits.Levels, deadline)
}
// sampleFolder judges the folder open on fd's first entries, and the folders among them levels further. It closes fd.
func sampleFolder(fd int, dev uint64, gid, levels int, deadline time.Time) bool {
f := os.NewFile(uintptr(fd), "")
defer f.Close()
names, err := f.Readdirnames(SampleLimits.PerFolder)
if err != nil && !errors.Is(err, io.EOF) {
return false
}
for _, name := range names {
if time.Now().After(deadline) {
return false
}
var st unix.Stat_t
if err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return false
}
if uint64(st.Dev) != dev {
continue
}
mode := uint32(st.Mode)
switch mode & unix.S_IFMT {
case unix.S_IFREG:
if st.Nlink == 1 && (int(st.Gid) != gid || mode&0o060 != 0o060) {
return false
}
case unix.S_IFDIR:
if int(st.Gid) != gid || mode&0o2070 != 0o2070 {
return false
}
if levels > 1 {
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil || !sampleFolder(next, dev, gid, levels-1, deadline) {
return false
}
}
}
}
return true
}