diff --git a/cmd/mesh-host/judge_directories.go b/cmd/mesh-host/judge_directories.go
new file mode 100644
index 0000000..c8a9070
--- /dev/null
+++ b/cmd/mesh-host/judge_directories.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/novox/mesh-host/internal/apply"
+ "github.com/novox/mesh-host/internal/declaration"
+ "github.com/novox/mesh-host/internal/link"
+ "github.com/novox/mesh-host/internal/store"
+)
+
+// judgeDirectories judges every directory of the declaration this node keeps, read-only (novox/hq issue 358): which
+// the walk that follows no link another account could make would refuse. Nothing is made, chmodded or chowned.
+func judgeDirectories(statePath string) (string, error) {
+ kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
+ if errors.Is(err, store.ErrNothingDeclared) {
+ return "", errors.New("this node keeps no declaration, so it is declared no directory")
+ }
+ if err != nil {
+ return "", err
+ }
+ d, err := declaration.Parse(kept.Declaration)
+ if err != nil {
+ return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
+ }
+ return apply.JudgeDirectories(d), nil
+}
+
+// judgeDirectoriesFrom is judgeDirectories as the controller asks it over the link.
+func judgeDirectoriesFrom(statePath string) link.JudgeDirectoriesFunc {
+ return func(string) (string, error) { return judgeDirectories(statePath) }
+}
+
+// judgeDirectoriesCommand is `nox-mesh-host judge-directories` at this machine, as root: the same judgement, for a
+// machine the controller cannot ask.
+func judgeDirectoriesCommand(opts options) error {
+ said, err := judgeDirectories(opts.state)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(opts.out, said)
+ return nil
+}
diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go
index c5e2fb0..71e835d 100644
--- a/cmd/mesh-host/main.go
+++ b/cmd/mesh-host/main.go
@@ -116,6 +116,8 @@ reach, run as root at the machine.
hand-over
hand a directory used as found to the mesh: the next apply gives it its declared
owner and mode. The operator's line is nox node hand-over on the
control-node; this is the same act for a machine with no mesh to reach
+ judge-directories which directories this node is declared the walk that follows no link another
+ account could make would refuse, read-only (novox/hq issue 358); run as root
version
--json machine-readable output
@@ -244,6 +246,8 @@ func run(ctx context.Context, command string, opts options) error {
switch command {
case "hand-over":
return handOverCommand(opts)
+ case "judge-directories":
+ return judgeDirectoriesCommand(opts)
case "key":
return keyCommand(opts)
@@ -1212,8 +1216,11 @@ func runLink(ctx context.Context, opts options) error {
}
return setuidSearch.SearchNow(by), nil
},
- Say: say,
- Timeout: opts.timeout,
+ // A read-only judgement of the directories this node is declared, asked by the controller (novox/hq
+ // issue 358): which the walk that follows no link another account could make would refuse.
+ JudgeDirectories: judgeDirectoriesFrom(opts.state),
+ Say: say,
+ Timeout: opts.timeout,
}
worker := make(chan struct{})
go func() {
diff --git a/internal/apply/homes_other.go b/internal/apply/homes_other.go
index 9874386..cf5c92e 100644
--- a/internal/apply/homes_other.go
+++ b/internal/apply/homes_other.go
@@ -64,3 +64,10 @@ func giveHandedOver(path string, _ os.FileMode, _, _ int, _ bool) (os.FileMode,
return 0, 0, 0, &PlacementRefusedError{Path: path, Why: "a handed-over directory is changed only through a " +
"descriptor that followed no link, which this system cannot open"}
}
+
+// judgeMeshDir refuses where there is no openat with O_NOFOLLOW (novox/hq issue 358). The node-engine runs on
+// Linux.
+func judgeMeshDir(path string) error {
+ return &PlacementRefusedError{Path: path, Why: "a mesh directory is judged only by a walk that follows no link " +
+ "another account could make, which this system cannot do"}
+}
diff --git a/internal/apply/judge_directories_test.go b/internal/apply/judge_directories_test.go
new file mode 100644
index 0000000..950d50c
--- /dev/null
+++ b/internal/apply/judge_directories_test.go
@@ -0,0 +1,72 @@
+//go:build linux
+
+package apply
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// The read-only judgement of a node's directories (novox/hq issue 358): one line each — placed, would be refused
+// naming the link and who could replace it, or not there yet (placed: an apply makes it) — and nothing made or
+// changed.
+func TestJudgeDirectoriesSaysWhatTheWalkWouldRefuseAndChangesNothing(t *testing.T) {
+ base := t.TempDir()
+ plain := filepath.Join(base, "plain")
+ if err := os.Mkdir(plain, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ elsewhere := filepath.Join(base, "elsewhere", "state")
+ if err := os.MkdirAll(elsewhere, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "linked")); err != nil {
+ t.Fatal(err)
+ }
+ missing := filepath.Join(base, "missing", "deeper")
+ d := declare(t, `{"id":"a.plain","type":"directory","path":"`+plain+`","mode":"0700"},`+
+ `{"id":"a.linked","type":"directory","path":"`+filepath.Join(base, "linked", "state")+`","mode":"0700"},`+
+ `{"id":"a.missing","type":"directory","path":"`+missing+`","mode":"0700"}`)
+
+ said := JudgeDirectories(d)
+ lines := strings.Split(said, "\n")
+ if len(lines) != 4 || !strings.HasPrefix(lines[0], "3 directories declared: 2 placed, 1 would be refused") {
+ t.Fatalf("the judgement: %q", said)
+ }
+ if lines[1] != plain+": placed" || lines[3] != missing+": placed" {
+ t.Errorf("a directory the walk reaches is not said placed: %q", said)
+ }
+ if !strings.Contains(lines[2], "would be refused") || !strings.Contains(lines[2], filepath.Join(base, "linked")) ||
+ !strings.Contains(lines[2], "every account") {
+ t.Errorf("the linked directory is not said refused naming the link and who: %q", lines[2])
+ }
+ if _, err := os.Stat(filepath.Join(base, "missing")); !os.IsNotExist(err) {
+ t.Error("the judgement made a directory")
+ }
+ for _, p := range []string{plain, elsewhere} {
+ if info, _ := os.Stat(p); info.Mode().Perm() != 0o755 {
+ t.Errorf("the judgement changed %s to %o", p, info.Mode().Perm())
+ }
+ }
+}
+
+// A link only root could have made is followed by the judgement as by the apply.
+func TestJudgeDirectoriesFollowsARootOnlyLink(t *testing.T) {
+ wasDirs, wasLinks := couldReplace, linkReplacer
+ couldReplace = func(string, uint32, uint32, uint32) string { return "" }
+ linkReplacer = func(string, uint32) string { return "" }
+ t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks })
+ base := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(base, "real", "state"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("real", filepath.Join(base, "run")); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(base, "run", "state")
+ if said := JudgeDirectories(declare(t, `{"id":"a.s","type":"directory","path":"`+path+`"}`)); !strings.Contains(said, path+": placed") {
+ t.Fatalf("a directory behind a root-only link is not said placed: %q", said)
+ }
+}
diff --git a/internal/apply/mesh_dirs.go b/internal/apply/mesh_dirs.go
new file mode 100644
index 0000000..adb8ffa
--- /dev/null
+++ b/internal/apply/mesh_dirs.go
@@ -0,0 +1,125 @@
+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")
+}
diff --git a/internal/apply/mesh_dirs_linux.go b/internal/apply/mesh_dirs_linux.go
new file mode 100644
index 0000000..497ed8e
--- /dev/null
+++ b/internal/apply/mesh_dirs_linux.go
@@ -0,0 +1,186 @@
+//go:build linux
+
+package apply
+
+// The descriptor half of mesh_dirs.go (novox/hq issue 358).
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+// maxLinksFollowed bounds the root-only links one walk follows, as the kernel bounds a lookup's (MAXSYMLINKS).
+const maxLinksFollowed = 40
+
+// errNotThereYet is a read-only walk meeting a component that does not exist: an apply would make it there.
+var errNotThereYet = errors.New("not there yet")
+
+// meshWalk is one walk of a mesh directory's path from /.
+type meshWalk struct {
+ // want is the directory as declared.
+ want string
+ // create makes a missing component, with mode, in its parent's descriptor; without it the walk stops there
+ // with errNotThereYet and makes nothing.
+ create bool
+ mode os.FileMode
+ // made is called with each directory the walk made and its descriptor.
+ made func(dir string, fd int) error
+ // Made is every directory the walk made, outermost first, and At the path the directory was reached at:
+ // want, or where the root-only links in its path led.
+ Made []string
+ At string
+}
+
+// judgeMeshDir is openMeshDir's judgement and nothing else (novox/hq issue 358): the same walk, making nothing
+// and changing nothing; nil when an apply would give the directory its mode and owner, also when a part of its
+// path is not there yet (an apply makes it, as root, in a directory it reached).
+func judgeMeshDir(path string) error {
+ w := &meshWalk{want: filepath.Clean(path)}
+ fd, err := w.open()
+ if errors.Is(err, errNotThereYet) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ unix.Close(fd)
+ return nil
+}
+
+func (w *meshWalk) open() (int, error) {
+ if !filepath.IsAbs(w.want) {
+ return -1, fmt.Errorf("%s is not an absolute path", w.want)
+ }
+ p := w.want
+ for followed := 0; ; followed++ {
+ if followed > maxLinksFollowed {
+ return -1, &os.PathError{Op: "open", Path: w.want, Err: unix.ELOOP}
+ }
+ fd, next, err := w.fromRoot(p)
+ if err != nil {
+ return -1, err
+ }
+ if next == "" {
+ w.At = p
+ return fd, nil
+ }
+ p = next
+ }
+}
+
+// fromRoot is one walk down p: the directory's descriptor, or the path to walk next when a root-only link was
+// met (with -1), or the refusal.
+func (w *meshWalk) fromRoot(p string) (int, string, error) {
+ fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return -1, "", &os.PathError{Op: "open", Path: "/", Err: err}
+ }
+ // Who could replace an entry of the directories walked so far: once one could, every link below it is
+ // theirs to make, whoever owns the link.
+ who, err := replacerOf(fd, "/")
+ if err != nil {
+ unix.Close(fd)
+ return -1, "", err
+ }
+ at := "/"
+ var parts []string
+ if p != "/" {
+ parts = strings.Split(strings.TrimPrefix(p, "/"), "/")
+ }
+ const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
+ for i, part := range parts {
+ here := filepath.Join(at, part)
+ last := i == len(parts)-1
+ next, err := unix.Openat(fd, part, dirFlags, 0)
+ if errors.Is(err, unix.ENOENT) {
+ if !w.create {
+ unix.Close(fd)
+ return -1, "", errNotThereYet
+ }
+ mkErr := unix.Mkdirat(fd, part, uint32(w.mode.Perm()))
+ if mkErr != nil && !errors.Is(mkErr, unix.EEXIST) {
+ unix.Close(fd)
+ return -1, "", &os.PathError{Op: "mkdir", Path: here, Err: mkErr}
+ }
+ next, err = unix.Openat(fd, part, dirFlags, 0)
+ if mkErr == nil && err == nil {
+ w.Made = append(w.Made, here)
+ if w.made != nil {
+ if gErr := w.made(here, next); gErr != nil {
+ unix.Close(next)
+ unix.Close(fd)
+ return -1, "", gErr
+ }
+ }
+ }
+ }
+ if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
+ var st unix.Stat_t
+ if lerr := unix.Fstatat(fd, part, &st, unix.AT_SYMLINK_NOFOLLOW); lerr == nil && st.Mode&unix.S_IFMT == unix.S_IFLNK {
+ to, rerr := w.followable(fd, part, here, st, who, last)
+ unix.Close(fd)
+ if rerr != nil {
+ return -1, "", rerr
+ }
+ if !filepath.IsAbs(to) {
+ to = filepath.Join(at, to)
+ }
+ return -1, filepath.Join("/", to, filepath.Join(parts[i+1:]...)), nil
+ }
+ }
+ unix.Close(fd)
+ if err != nil {
+ switch {
+ case errors.Is(err, unix.ENOTDIR) && last:
+ return -1, "", fmt.Errorf("%s exists and is not a directory", here)
+ case errors.Is(err, unix.ENOTDIR):
+ return -1, "", fmt.Errorf("%s is not a directory, and %s lies below it", here, w.want)
+ }
+ return -1, "", &os.PathError{Op: "open", Path: here, Err: err}
+ }
+ fd = next
+ at = here
+ if who == "" {
+ if who, err = replacerOf(fd, here); err != nil {
+ unix.Close(fd)
+ return -1, "", err
+ }
+ }
+ }
+ return fd, "", nil
+}
+
+// replacerOf is couldReplace for the directory open on fd.
+func replacerOf(fd int, path string) (string, error) {
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return "", &os.PathError{Op: "fstat", Path: path, Err: err}
+ }
+ return couldReplace(path, st.Uid, st.Gid, st.Mode&0o7777), nil
+}
+
+// followable judges the link at here, held by the directory open on fd: its target when root alone could have
+// made it, else the refusal. who is who could replace an entry of a directory above it ("" when nobody).
+func (w *meshWalk) followable(fd int, name, here string, st unix.Stat_t, who string, last bool) (string, error) {
+ if last {
+ return "", &PlacementRefusedError{Path: w.want, Why: "it is a symbolic link, and the node-engine follows no " +
+ "link to a directory it owns"}
+ }
+ if who == "" {
+ who = linkReplacer(here, st.Uid)
+ }
+ if who != "" {
+ return "", &LinkInPathError{Path: w.want, Link: here, Who: who}
+ }
+ buf := make([]byte, unix.PathMax)
+ n, err := unix.Readlinkat(fd, name, buf)
+ if err != nil {
+ return "", &os.PathError{Op: "readlink", Path: here, Err: err}
+ }
+ return string(buf[:n]), nil
+}
diff --git a/internal/link/handover.go b/internal/link/handover.go
index 1fc7f60..1d0dd2f 100644
--- a/internal/link/handover.go
+++ b/internal/link/handover.go
@@ -262,3 +262,61 @@ func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply strin
type SetuidSearches interface {
HearSetuidSearches(SetuidSearchFunc)
}
+
+// A judgement of this node's directories asked over the bus (novox/hq issue 358).
+//
+// Before the node-engine gives a mesh directory its mode and owner only through a walk that follows no link
+// another account could make, the operator is told which directories that walk would refuse on each node. The
+// controller asks this node's engine on AskJudgeDirectoriesSubject, signed as a hand-over is and held to the
+// same checks (believed), under a signing context of its own. The engine judges every directory of the
+// declaration it keeps, read-only — nothing is made, chmodded or chowned — and answers one line per directory.
+
+// AskJudgeDirectoriesSubject is where the controller asks this node's engine to judge its directories.
+func AskJudgeDirectoriesSubject(node string) string {
+ return "mesh.node." + node + ".ask.judge-directories"
+}
+
+// JudgeDirectoriesContext is prefixed to a judgement ask's bytes before signing: never another ask's, nor a
+// declaration's.
+const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n"
+
+// JudgeDirectoriesFunc judges this node's declared directories, read-only, asked by by: the words to answer with.
+type JudgeDirectoriesFunc func(by string) (string, error)
+
+func refusedJudgement(why string) HandOverAnswer {
+ return HandOverAnswer{Refused: why + "; nothing was judged"}
+}
+
+// AnswerJudgeDirectories is the engine's judgement of one ask's bytes: believed as a hand-over is, a path refused,
+// and only then the handler.
+func AnswerJudgeDirectories(check HandOverCheck, handle JudgeDirectoriesFunc, body []byte) HandOverAnswer {
+ if handle == nil {
+ return refusedJudgement("this engine judges no directory on an ask")
+ }
+ ask, refused := believed(check, JudgeDirectoriesContext, false, body)
+ if refused != "" {
+ return refusedJudgement(refused)
+ }
+ said, err := handle(ask.By)
+ if err != nil {
+ return refusedJudgement(err.Error())
+ }
+ return HandOverAnswer{Said: said}
+}
+
+// JudgeDirectoriesReply is what a judgement request is answered with, and whether to answer, as HandOverReply.
+func JudgeDirectoriesReply(check HandOverCheck, handle JudgeDirectoriesFunc, reply string, body []byte) ([]byte, bool) {
+ if reply == "" {
+ return nil, false
+ }
+ answer, err := json.Marshal(AnswerJudgeDirectories(check, handle, body))
+ if err != nil {
+ return nil, false
+ }
+ return answer, true
+}
+
+// DirectoryJudgements is a link that hears the controller asking this node to judge its directories. Optional.
+type DirectoryJudgements interface {
+ HearDirectoryJudgements(JudgeDirectoriesFunc)
+}
diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go
index 72d16f9..fd27d0f 100644
--- a/internal/link/hearing_nats.go
+++ b/internal/link/hearing_nats.go
@@ -64,7 +64,9 @@ type natsLink struct {
handing *nats.Subscription
// searching hears the controller's terminal asking for a fresh setuid search (novox/hq issue 361).
searching *nats.Subscription
- node string
+ // judging hears the controller asking for a read-only judgement of this node's directories (issue 358).
+ judging *nats.Subscription
+ node string
// signer is the mesh's key, which a hand-over ask is verified with as a declaration is (issue 356).
signer ed25519.PublicKey
arrived chan Declaration
@@ -238,6 +240,25 @@ func (l *natsLink) HearSetuidSearches(handle SetuidSearchFunc) {
}
}
+// HearDirectoryJudgements listens for the controller asking this node to judge its declared directories,
+// read-only (novox/hq issue 358), on this machine's own subject, verified as a hand-over is
+// (AnswerJudgeDirectories). A bus whose user list is older than the grant refuses the subscription, said by the
+// client; the operator's line then says that nothing on this node answers.
+func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) {
+ if handle == nil {
+ return
+ }
+ check := HandOverCheck{Signer: l.signer, Node: l.node}
+ judging, err := l.conn.Subscribe(AskJudgeDirectoriesSubject(l.node), func(msg *nats.Msg) {
+ if body, answer := JudgeDirectoriesReply(check, handle, msg.Reply, msg.Data); answer {
+ _ = msg.Respond(body)
+ }
+ })
+ if err == nil {
+ l.judging = judging
+ }
+}
+
func (l *natsLink) Declarations() <-chan Declaration { return l.arrived }
func (l *natsLink) AskedToReport() <-chan struct{} { return l.asked }
func (l *natsLink) Lost() <-chan error { return l.lost }
@@ -255,6 +276,9 @@ func (l *natsLink) Close() {
if l.searching != nil {
_ = l.searching.Unsubscribe()
}
+ if l.judging != nil {
+ _ = l.judging.Unsubscribe()
+ }
if l.conn != nil {
l.conn.Close()
}
diff --git a/internal/link/judge_directories_test.go b/internal/link/judge_directories_test.go
new file mode 100644
index 0000000..6439266
--- /dev/null
+++ b/internal/link/judge_directories_test.go
@@ -0,0 +1,52 @@
+package link
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// The subject and the signing context the controller holds the same words for (mesh-controller internal/link).
+func TestTheDirectoryJudgementAskKeepsItsSubjectAndContext(t *testing.T) {
+ if AskJudgeDirectoriesSubject("laptop") != "mesh.node.laptop.ask.judge-directories" {
+ t.Fatalf("the subject is %q", AskJudgeDirectoriesSubject("laptop"))
+ }
+ if JudgeDirectoriesContext != "novox-mesh judge-directories v1\n" || JudgeDirectoriesContext == HandOverContext ||
+ JudgeDirectoriesContext == SetuidSearchContext {
+ t.Fatalf("the signing context is %q", JudgeDirectoriesContext)
+ }
+}
+
+// A judgement ask is believed as a hand-over is: signed by the mesh under its own context, for this node,
+// unexpired, once, and naming no path.
+func TestADirectoryJudgementAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) {
+ m, key := aMember(t)
+ now := time.Date(2026, 10, 12, 14, 0, 0, 0, time.UTC)
+ var asked []string
+ handle := func(by string) (string, error) { asked = append(asked, by); return "3 directories declared", nil }
+ check := HandOverCheck{Signer: m.Signer, Node: "n1", Now: func() time.Time { return now }, Nonces: &Nonces{}}
+ good := HandOverAsk{Node: "n1", By: "jo", Expires: now.Add(HandOverGood), Nonce: aNonce}
+ for name, body := range map[string][]byte{
+ "a setuid search's signature": signedSearch(t, key, SetuidSearchContext, good),
+ "another node": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n2", By: "jo", Expires: good.Expires, Nonce: aNonce}),
+ "a path": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n1", Path: "/x", By: "jo", Expires: good.Expires, Nonce: aNonce}),
+ "expired": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n1", By: "jo", Expires: now, Nonce: aNonce}),
+ } {
+ if a := AnswerJudgeDirectories(check, handle, body); !strings.HasSuffix(a.Refused, "nothing was judged") || a.Said != "" {
+ t.Errorf("%s: %+v", name, a)
+ }
+ }
+ if len(asked) != 0 {
+ t.Fatalf("a refused ask reached the handler: %v", asked)
+ }
+ body := signedSearch(t, key, JudgeDirectoriesContext, good)
+ if a := AnswerJudgeDirectories(check, handle, body); a.Said != "3 directories declared" || len(asked) != 1 {
+ t.Fatalf("the good ask: %+v, %v", a, asked)
+ }
+ if a := AnswerJudgeDirectories(check, handle, body); !strings.Contains(a.Refused, "taken already") {
+ t.Fatalf("the same ask again: %+v", a)
+ }
+ if _, answer := JudgeDirectoriesReply(check, handle, "", body); answer {
+ t.Fatal("a request with no reply subject was answered")
+ }
+}
diff --git a/internal/link/queue.go b/internal/link/queue.go
index c2a66dd..227dc1d 100644
--- a/internal/link/queue.go
+++ b/internal/link/queue.go
@@ -59,8 +59,11 @@ type Queue struct {
// SetuidSearch throws away the last search for setuid programs and starts a full one, asked by the
// controller's terminal over the link (novox/hq issue 361). Nil answers every such ask with a refusal.
SetuidSearch SetuidSearchFunc
- Say Announce
- Timeout time.Duration
+ // JudgeDirectories judges this node's declared directories read-only, asked by the controller over the
+ // link (novox/hq issue 358). Nil answers every such ask with a refusal.
+ JudgeDirectories JudgeDirectoriesFunc
+ Say Announce
+ Timeout time.Duration
once sync.Once
wake chan struct{}
diff --git a/internal/link/run.go b/internal/link/run.go
index e13135e..b61782e 100644
--- a/internal/link/run.go
+++ b/internal/link/run.go
@@ -248,6 +248,10 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou
if s, ok := link.(SetuidSearches); ok {
s.HearSetuidSearches(queue.SetuidSearch)
}
+ // And asking for a read-only judgement of its directories (novox/hq issue 358).
+ if j, ok := link.(DirectoryJudgements); ok {
+ j.HearDirectoryJudgements(queue.JudgeDirectories)
+ }
for {
// Asked to stop — by standing aside, say — nothing further is taken in.