Merge pull request 'Renew the engine placed by hand to the proved build, and take a hand-over from the controller's terminal (hq issue 356)' (#74) from fix/356-hand-over-by-nox-and-the-engine-on-path-renewed into main

This commit was merged in pull request #74.
This commit is contained in:
2026-10-09 18:07:21 +00:00
19 changed files with 1306 additions and 74 deletions
+20 -13
View File
@@ -4,11 +4,16 @@ Tier 0 of the Novox Mesh. The one thing ever installed by hand, and the only thi
a machine.
```
scp mesh-host root@machine:/usr/local/bin/
mesh-host profile
scp nox-mesh-host root@machine:/usr/bin/
nox-mesh-host profile
```
That is the whole installation. One statically linked binary, nothing else present, no runtime
That is the whole installation. **It is a daemon**: the launcher runs it, and the operator's command is
`nox` (`mesh-cli`), on any node, from their own account (`novox/hq` ADR 0272). The subcommands below are
for installing a machine, joining it, and rescuing one no mesh can reach, run as root at the machine; a
line the mesh addresses to the operator names a `nox` line, never this binary. Once a delivered engine
proves itself, it renews the one placed by hand here to its own build, so the command on the machine is
the engine that runs it (`novox/hq` issue 356). One statically linked binary, nothing else present, no runtime
to install first ([`novox/hq` ADR 0005](https://git.novox.be/novox/hq)).
## What it is for
@@ -23,7 +28,7 @@ the host never queries the mesh database. It receives declarations and applies t
## Joining a mesh
```
mesh-host enrol --token <token> --name <what this machine is called>
nox-mesh-host enrol --token <token> --name <what this machine is called>
```
**The node generates its own identity** — an Ed25519 keypair whose private half never leaves the
@@ -52,20 +57,22 @@ and then stops, having saved nothing — so it can be run again unchanged.
connects to nothing and listens on nothing — what it applies comes from a file.
```
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match a declaration from a file
mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
mesh-host bundle show what this host carries
mesh-host owned what this host has applied and still owns
nox-mesh-host profile what this machine can be asked to do
nox-mesh-host inventory what this machine is, and what it holds
nox-mesh-host apply FILE make this machine match a declaration from a file
nox-mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
nox-mesh-host bundle show what this host carries
nox-mesh-host owned what this host has applied and still owns
nox-mesh-host hand-over DIR hand a directory used as found to the mesh, on a machine no mesh
can reach; the operator's line is `nox node hand-over <node> DIR`
--json machine-readable
--state where this node keeps what it knows
--dry-run say what applying would change, and change nothing
```
```
$ mesh-host profile
$ nox-mesh-host profile
linux/amd64
yes container-runtime 29.7.2
@@ -115,7 +122,7 @@ A host built for a machine carries its declaration **inside the binary**:
make host BUNDLE=path/to/foundation.lock
```
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
`nox-mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
first node's path — no mesh present, nothing fetched, nothing else copied onto the machine. Once
the mesh has spoken, `reconcile` holds the machine to what it last said and never to the bundle,
which genesis consumed; a bundle or a file is refused when it says the other mode than the node
+95 -31
View File
@@ -22,14 +22,20 @@ import (
// directory it never made to whichever account a declaration named. That is safe and, said nowhere, a module
// whose data directory it cannot write would simply fail. So each such directory is said three ways: a WARNING
// line in every apply's log; a resource of its module of kind `directory`, stated unhealthy on every look,
// which the controller raises as that module's condition naming the directory and its owner; and a way out at
// the machine's terminal, `mesh-host hand-over <path>`, after which the next apply gives it the declared owner
// and mode like any directory of the mesh's.
// which the controller raises as that module's condition naming the directory and its owner; and a way out,
// which is the operator's line at the controller's terminal — `nox node hand-over <node> <path>` on the
// control-node (novox/hq ADR 0272, issue 356) — after which the next apply gives it the declared owner and
// mode like any directory of the mesh's. The controller carries that line to this engine over the bus
// (link.AskHandOverSubject); the engine's own `hand-over` subcommand is the same act for a machine with no
// mesh to reach.
// foundState is where the node's state is, for the looks to read the directories used as found from; empty
// until the engine runs.
var foundState atomic.Value
// foundNode is this node's name in the mesh, for the way out to name it; empty until the engine is enrolled.
var foundNode atomic.Value
// handedOverFrom has the apply read the directories handed over from beside the state at statePath.
func handedOverFrom(statePath string) {
apply.HandedOver = func() map[string]bool {
@@ -58,9 +64,21 @@ func foundDirectories(known store.State) []store.Applied {
return out
}
// handOverLine is the operator's line that hands a directory to the mesh, with this node's name when it is
// known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's
// path is the resource's target beside the reason, never in it: the controller says a reason on the
// operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence.
func handOverLine() string {
node, _ := foundNode.Load().(string)
if node == "" {
node = "<node>"
}
return fmt.Sprintf("`nox node hand-over %s <directory>` on the control-node", node)
}
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and how to hand
// it over. The path is the target, which the controller keeps as evidence.
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line
// that hands it over. The path is the target, which the controller keeps as evidence.
func withFoundDirectories(h *link.Health, found []store.Applied) *link.Health {
for _, r := range found {
at := strings.LastIndex(r.ID, ".")
@@ -80,9 +98,8 @@ func withFoundDirectories(h *link.Health, found []store.Applied) *link.Health {
}
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.ID[:at], Resource: r.ID,
Kind: link.KindDirectory, Target: r.Target, State: link.StateUnhealthy,
Reason: fmt.Sprintf("%s owned by %s, mode %s, as found; %s was declared and not given it — "+
"`mesh-host hand-over` at the machine hands it to the mesh", link.ReasonUsedAsFound, owner, mode,
declared),
Reason: fmt.Sprintf("%s owned by %s, mode %s, as found; %s was declared and not given it — %s, with its "+
"path, hands it to the mesh", link.ReasonUsedAsFound, owner, mode, declared, handOverLine()),
Since: foundSince(r).UTC()})
}
return h
@@ -102,34 +119,81 @@ func foundNow() []store.Applied {
return foundDirectories(known)
}
// handOverCommand hands one directory used as found to the mesh: run by a person, as root, at the machine.
// It changes nothing on disk itself; the next apply gives the directory its declared owner and mode, and says so.
func handOverCommand(opts options) error {
path := filepath.Clean(opts.file)
// handOverFound hands one directory used as found to the mesh, on the operator's word carried by the
// controller (link.HandOverFunc) or typed at this machine. It changes nothing on disk itself; the next apply
// gives the directory its declared owner and mode, and says so.
//
// **Every value is judged, and a refusal records nothing.** The path is the directory's absolute path exactly
// as the engine states it — clean, so `..` and a doubled separator never name a directory; one this machine's
// state records as used as found; on disk a directory and not a link, reached through no link. The engine
// never follows a path it did not state: a hand-over is root giving a directory to an account at the next
// apply, and a path that resolves elsewhere would give that elsewhere.
func handOverFound(statePath, path, by string) (string, error) {
if !filepath.IsAbs(path) {
return fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one", opts.file)
return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path)
}
known, err := store.Load(opts.state)
if filepath.Clean(path) != path {
return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+
"separator); nothing was handed over", path)
}
known, err := store.Load(statePath)
if err != nil {
return "", err
}
var found *store.Applied
for _, r := range foundDirectories(known) {
if filepath.Clean(r.Target) == path {
r := r
found = &r
break
}
}
if found == nil {
return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " +
"the ones that are); nothing was handed over")
}
info, err := os.Lstat(path)
switch {
case err != nil:
return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err)
case info.Mode()&os.ModeSymlink != 0:
return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path)
case !info.IsDir():
return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path)
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err)
}
if resolved != path {
return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+
"(novox/hq issue 339); nothing was handed over", path, resolved)
}
if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+
"then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil
}
// handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked.
func handOverFrom(statePath string) link.HandOverFunc {
return func(path, by string) (string, error) { return handOverFound(statePath, path, by) }
}
// handOverCommand is `nox-mesh-host hand-over <dir>` at this machine, as root: the same act, for a machine
// with no mesh to reach. The operator's line is `nox node hand-over <node> <dir>` on the control-node.
func handOverCommand(opts options) error {
by := "a person at the machine"
if u, err := user.Current(); err == nil {
by = u.Username + " at the machine"
}
said, err := handOverFound(opts.state, filepath.Clean(opts.file), by)
if err != nil {
return err
}
for _, r := range foundDirectories(known) {
if filepath.Clean(r.Target) != path {
continue
}
by := "a person at the machine"
if u, err := user.Current(); err == nil {
by = u.Username
}
if err := store.AddHandedOver(opts.state, store.HandOver{ID: r.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
return err
}
fmt.Fprintf(opts.out, "%s (%s) is handed to the mesh: the next apply gives it %s, and it is the mesh's from "+
"then on — removed when empty and no longer declared\n", path, r.ID, orUnsaid(r.Declared))
return nil
}
return errors.New(path + " is not a directory this machine uses as found (`mesh-host owned` lists what the " +
"engine applied); nothing was handed over")
fmt.Fprintln(opts.out, said)
return nil
}
// foundSince is when a directory was first found as it is: kept across applies, so the controller can tell one
+117 -6
View File
@@ -14,13 +14,16 @@ import (
)
// Every directory used as found is its module's resource of kind directory, unhealthy, naming its owner, on
// every statement (novox/hq issue 339): the controller raises that as the module's condition.
// every statement (novox/hq issue 339): the controller raises that as the module's condition. The way out it
// names is the operator's line, with this node's name (novox/hq ADR 0272, issue 356).
func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
dir := t.TempDir()
known := store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: dir, AsFound: true, Declared: "root, mode 755", AppliedAt: time.Now()},
{ID: "notes.cache", Type: "directory", Target: dir},
}}
foundNode.Store("laptop")
t.Cleanup(func() { foundNode.Store("") })
h := withFoundDirectories(&link.Health{}, foundDirectories(known))
if len(h.Resources) != 1 {
t.Fatalf("stated: %+v", h.Resources)
@@ -28,16 +31,45 @@ func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
r := h.Resources[0]
if r.Module != "notes" || r.Kind != link.KindDirectory || r.State != link.StateUnhealthy || r.Target != dir ||
!strings.HasPrefix(r.Reason, link.ReasonUsedAsFound) || !strings.Contains(r.Reason, "owned by ") ||
!strings.Contains(r.Reason, "root, mode 755") || !strings.Contains(r.Reason, "hand-over") {
!strings.Contains(r.Reason, "root, mode 755") {
t.Fatalf("stated as: %+v", r)
}
// The path is the target, kept as evidence, and never in the reason: the operator's channel withholds a path
// (novox/hq ADR 0234 §6), and the controller says the reason there.
if !strings.Contains(r.Reason, "`nox node hand-over laptop <directory>` on the control-node") ||
strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) {
t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason)
}
foundNode.Store("")
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
"`nox node hand-over <node> <directory>`") {
t.Fatalf("before enrolment the line names a placeholder: %q", r.Reason)
}
}
// `mesh-host hand-over` records a directory used as found, and refuses one that is not.
// realDir is a temporary directory with every link in its path resolved, so a judgement of links is about the
// links the test makes.
func realDir(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return dir
}
// A hand-over records a directory used as found, and refuses one that is not — through the engine's own
// subcommand and through the controller's ask alike, which are one judgement.
func TestHandOverRecordsOnlyAFoundDirectory(t *testing.T) {
dir := t.TempDir()
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "mine"), 0o755); err != nil {
t.Fatal(err)
}
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true, Declared: "root, mode 755"},
{ID: "notes.mine", Type: "directory", Target: filepath.Join(dir, "mine")},
@@ -52,7 +84,7 @@ func TestHandOverRecordsOnlyAFoundDirectory(t *testing.T) {
t.Fatal(err)
}
all, err := store.LoadHandedOver(statePath)
if err != nil || len(all) != 1 || all[0].Path != found {
if err != nil || len(all) != 1 || all[0].Path != found || !strings.HasSuffix(all[0].By, " at the machine") {
t.Fatalf("recorded: %+v %v", all, err)
}
handedOverFrom(statePath)
@@ -62,5 +94,84 @@ func TestHandOverRecordsOnlyAFoundDirectory(t *testing.T) {
if !strings.Contains(out.String(), "root, mode 755") {
t.Fatalf("said: %s", out.String())
}
_ = os.Remove(statePath)
// The controller's ask is the same judgement, recording who asked in the controller's words.
_ = os.Remove(store.HandedOverPath(statePath))
said, err := handOverFrom(statePath)(found, "jo through mesh-cli on anchor")
if err != nil || !strings.Contains(said, "by jo through mesh-cli on anchor") || !strings.Contains(said, "root, mode 755") {
t.Fatalf("the ask answered %q, %v", said, err)
}
all, _ = store.LoadHandedOver(statePath)
if len(all) != 1 || all[0].By != "jo through mesh-cli on anchor" || all[0].ID != "notes.data" {
t.Fatalf("recorded: %+v", all)
}
}
// Every value of a hand-over is judged before anything is recorded, and each refusal records nothing: a relative
// path, a path that is not the engine's statement of the directory (`..`, a doubled or trailing separator), one
// not used as found, a link at the path, a link in the path, a file, a directory that is gone.
func TestAHandOverIsRefusedForEveryPathThatIsNotTheFoundDirectory(t *testing.T) {
dir := realDir(t)
statePath := filepath.Join(dir, "state.json")
found := filepath.Join(dir, "found")
if err := os.Mkdir(found, 0o700); err != nil {
t.Fatal(err)
}
// A link at the path of a second found directory, and a found directory below a linked directory.
linked := filepath.Join(dir, "linked")
if err := os.Symlink(found, linked); err != nil {
t.Fatal(err)
}
through := filepath.Join(dir, "via", "data")
if err := os.Symlink(dir, filepath.Join(dir, "via")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "data"), 0o700); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
gone := filepath.Join(dir, "gone")
if err := store.Save(statePath, store.State{Resources: []store.Applied{
{ID: "notes.data", Type: "directory", Target: found, AsFound: true},
{ID: "notes.linked", Type: "directory", Target: linked, AsFound: true},
{ID: "notes.through", Type: "directory", Target: through, AsFound: true},
{ID: "notes.file", Type: "directory", Target: file, AsFound: true},
{ID: "notes.gone", Type: "directory", Target: gone, AsFound: true},
}}); err != nil {
t.Fatal(err)
}
// Each refused by its own check, which the reason names.
refused := map[string][2]string{
"a relative path": {"found", "not one"},
"a path with ..": {dir + "/x/../found", "as the engine states it"},
"a doubled separator": {dir + "//found", "as the engine states it"},
"a trailing separator": {found + "/", "as the engine states it"},
"a directory not used as found": {filepath.Join(dir, "other"), "not a directory this machine uses as found"},
"a link at the path": {linked, "is a link"},
"a link in the path": {through, "reached through a link"},
"a file": {file, "is not a directory on this machine"},
"a directory that is gone": {gone, "cannot be read on this machine"},
}
for name, c := range refused {
said, err := handOverFound(statePath, c[0], "jo")
if err == nil || said != "" {
t.Fatalf("%s (%s) was handed over: %q", name, c[0], said)
}
if !strings.Contains(err.Error(), "nothing was handed over") || !strings.Contains(err.Error(), c[1]) {
t.Fatalf("%s's refusal does not say %q and that nothing was handed over: %v", name, c[1], err)
}
}
if all, err := store.LoadHandedOver(statePath); err != nil || len(all) != 0 {
t.Fatalf("a refusal recorded something: %+v %v", all, err)
}
// The found directory itself, stated exactly, is taken.
if _, err := handOverFound(statePath, found, "jo"); err != nil {
t.Fatal(err)
}
if all, _ := store.LoadHandedOver(statePath); len(all) != 1 || all[0].Path != found {
t.Fatalf("recorded: %+v", all)
}
}
+18 -6
View File
@@ -94,7 +94,12 @@ func versionAt(self, stamped string) string {
return stamped
}
const usage = `mesh-host — the node host
const usage = `nox-mesh-host — the node-engine
A daemon: its launcher runs it, and it applies what the mesh declares for this machine. The
operator's command is nox (mesh-cli), on any node, from their own account (novox/hq ADR 0272);
the subcommands here are for installing a machine, joining it, and rescuing one no mesh can
reach, run as root at the machine.
profile what this machine can be asked to do
inventory what this machine is, and what it holds
@@ -108,7 +113,9 @@ const usage = `mesh-host — the node host
overlay take take over the tunnel found here (novox/hq ADR 0105): its key becomes this
node's overlay key and the mesh is told, signed; --tunnel <iface> when several are up
owned what this host has applied and still owns
hand-over <dir> hand a directory used as found to the mesh: the next apply gives it its declared owner and mode
hand-over <dir> 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 <node> <dir> on the
control-node; this is the same act for a machine with no mesh to reach
version
--json machine-readable output
@@ -221,7 +228,7 @@ func parseArgs(args []string) (string, options, error) {
// Anything left over was neither the command nor a flag. Refused rather than ignored: a
// mistyped argument that changes nothing and reports success is worse than an error.
if len(positionals) > 0 {
return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", positionals[0])
return "", opts, fmt.Errorf("unexpected argument %q — try `nox-mesh-host help`", positionals[0])
}
return command, opts, nil
}
@@ -327,7 +334,7 @@ func run(ctx context.Context, command string, opts options) error {
return nil
default:
return fmt.Errorf("unknown command %q — try `mesh-host help`", command)
return fmt.Errorf("unknown command %q — try `nox-mesh-host help`", command)
}
}
@@ -1077,6 +1084,8 @@ func runLink(ctx context.Context, opts options) error {
// password, say — and restarting the host (design 28, task 5.2). Same file, same check.
say := func(line string) { fmt.Println(line) }
adoptDeliveredMembership(identity.Path(opts.state), &mine, say)
// So every statement about a directory used as found names the operator's line for this node.
foundNode.Store(mine.Node)
fmt.Printf("node %s, linking to %s\n", mine.Node, mine.Membership.Broker)
@@ -1181,8 +1190,11 @@ func runLink(ctx context.Context, opts options) error {
},
Unsaid: unsaidBeside(opts.state),
Numbers: numbersBeside(opts.state),
Say: say,
Timeout: opts.timeout,
// And the operator's hand-over of a directory used as found, carried by the controller from the
// terminal (novox/hq issue 356): judged and recorded here, applied by the next apply.
HandOver: handOverFrom(opts.state),
Say: say,
Timeout: opts.timeout,
}
worker := make(chan struct{})
go func() {
+22
View File
@@ -70,10 +70,32 @@ func (p *proving) heard(r link.Report) {
if len(retired) > 0 {
p.say(fmt.Sprintf("host %s is proved; retired the host version(s) %s", p.version, strings.Join(retired, ", ")))
}
renewFallback(p.version, p.say)
renewLauncher(p.say)
})
}
// renewFallback makes the engine placed by hand — the launcher's fallback, and the `nox-mesh-host` on this
// machine's PATH — a copy of this proved build when it is not one (novox/hq issue 356). On the same
// evidence known-good is written on, and said when it happens or cannot: a stale command that answers
// for a build that is not running would otherwise stay until somebody typed it.
func renewFallback(version string, say func(string)) {
self, err := os.Executable()
if err != nil {
say("the engine placed by hand could not be renewed: this build does not know its own path: " + err.Error())
return
}
fallback := upgrade.FallbackPath()
renewed, err := upgrade.RenewFallback(self, fallback)
switch {
case err != nil:
say(fmt.Sprintf("the engine placed by hand at %s is not this build, and could not be renewed: %v", fallback, err))
case renewed:
say(fmt.Sprintf("renewed the engine placed by hand at %s to this build, %s: the command on this machine is the "+
"engine that runs it", fallback, version))
}
}
// The launcher, as the service manager runs it.
const (
launcherPath = upgrade.DefaultLibexec + "/launch"
+27
View File
@@ -5,6 +5,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -17,6 +18,12 @@ import (
func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
t.Setenv("MESH_HOST_LIBEXEC", t.TempDir())
// Never the machine's own engine placed by hand: proving renews it (issue 356).
fallback := filepath.Join(t.TempDir(), "nox-mesh-host")
if err := os.WriteFile(fallback, []byte("a stale engine placed by hand"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv(upgrade.FallbackVar, fallback)
var said []string
p := &proving{statePath: state, version: "2.0", say: func(s string) { said = append(said, s) }}
@@ -29,6 +36,9 @@ func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
if !unproved(state, "2.0") {
t.Fatalf("%+v proved this build", r)
}
if got, _ := os.ReadFile(fallback); string(got) != "a stale engine placed by hand" {
t.Fatalf("%+v renewed the engine placed by hand", r)
}
}
p.heard(link.Report{Host: "2.0", Declared: "abc"})
if unproved(state, "2.0") {
@@ -37,6 +47,23 @@ func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
if got, _ := upgrade.ReadKnownGood(upgrade.KnownGoodPath(state)); got != "2.0" {
t.Fatalf("known-good is %q", got)
}
// **Proved, the engine placed by hand becomes this build** (issue 356): the command on the machine's PATH is
// the engine that runs it, and that is said.
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
want, _ := os.ReadFile(self)
if got, _ := os.ReadFile(fallback); len(want) == 0 || string(got) != string(want) {
t.Fatalf("the engine placed by hand was not renewed to this build (%d bytes, want %d)", len(got), len(want))
}
renewed := false
for _, s := range said {
renewed = renewed || strings.Contains(s, "renewed the engine placed by hand at "+fallback)
}
if !renewed {
t.Fatalf("the renewal was not said: %q", said)
}
}
// Every verdict that stands is said, and a reconcile that has one says it unasked.
+11 -5
View File
@@ -390,7 +390,7 @@ type SetuidCache struct {
mu sync.Mutex
wg sync.WaitGroup
at time.Time // when the last search that finished finished
at time.Time // when the last search that finished was started, on the caller's clock
found []string
running bool
startedAt time.Time
@@ -431,8 +431,14 @@ const (
ReasonIncomplete = rootsearch.ReasonIncomplete
)
// Look answers the last result and when its search finished, starting a search in the background when one is
// Look answers the last result and when its search was started, starting a search in the background when one is
// due; it never waits for one. Nil c searches in place, for a caller that wants to.
//
// **One clock: the caller's.** A search's time is the `now` it was started at, never the wall clock read when it
// finished: due-ness compares a later `now` with it, and mixing the two clocks made a search never due again
// whenever the caller's clock ran behind the wall clock — the test with a fixed date failed on every branch from
// 2026-10-09 18:00 UTC (found while checking issue 356). Measured from the start, a result reads at most one
// search older than it is, which errs towards searching again.
func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]string, error)) ([]string, time.Time, error) {
if c == nil {
ctx, cancel := context.WithTimeout(context.Background(), SearchBound)
@@ -446,6 +452,7 @@ func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]st
(c.failed == nil || now.Sub(c.failedAt) >= c.backoff)
if due {
c.running, c.startedAt = true, now
started := now
c.wg.Add(1)
go func() {
defer c.wg.Done()
@@ -455,13 +462,12 @@ func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]st
c.mu.Lock()
defer c.mu.Unlock()
c.running = false
done := time.Now()
if err != nil {
c.failed, c.failedAt = err, done
c.failed, c.failedAt = err, started
c.backoff = min(max(2*c.backoff, c.Every), MaxBackoff)
return
}
c.found, c.at, c.failed, c.backoff = found, done, nil, 0
c.found, c.at, c.failed, c.backoff = found, started, nil, 0
}()
}
switch {
+51 -9
View File
@@ -781,7 +781,7 @@ func ApplyMindingWindows(
report.Outcomes = append(report.Outcomes, outcome)
if outcome.asFound {
// Never quiet (novox/hq issue 339): said on every apply, and stated unhealthy on every look until a
// person hands it over (FoundDirectories, `mesh-host hand-over`).
// person hands it over (FoundDirectories, `nox node hand-over`, novox/hq issue 356).
log(fmt.Sprintf(" WARNING %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail))
}
if handed {
@@ -1045,13 +1045,16 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
if existed && !before.IsDir() {
return out, fmt.Errorf("%s exists and is not a directory", r.Path)
}
// Handed to the mesh by the operator (`nox node hand-over`, or `hand-over` at the machine): given the declared
// owner and mode through a descriptor that followed no link (giveHandedOver) — **on every apply, not only the
// first** (review of issue 356). After the first, the record is no longer used as found and would read as the
// mesh's own, whose mode and owner are set by path; a parent swapped for a link after that would carry root's
// chmod and chown to its target.
if existed && HandedOver()[handOverKey(r.ID, r.Path)] {
return applyHandedOver(r, out, mode)
}
if existed && wasBefore {
ours := previous.Target != "" && filepath.Clean(previous.Target) == filepath.Clean(r.Path) && !previous.AsFound
// Or handed to the mesh by a person at the machine (`mesh-host hand-over`): then it is given the
// declared owner and mode below, as any directory of the mesh's.
if !ours && HandedOver()[handOverKey(r.ID, r.Path)] {
ours = true
}
if !ours {
owned, err := ownedBy(r.Path, r.Owner)
if err != nil {
@@ -1069,8 +1072,8 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
out.asFoundAt = previous.FoundAt
}
out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner %s and mode %o are "+
"left, though %s were declared (novox/hq issue 339); `mesh-host hand-over %s` at the machine "+
"hands it to the mesh", ownerText(before), before.Mode().Perm(), out.asFoundDeclared, r.Path)
"left, though %s were declared (novox/hq issue 339); `nox node hand-over <node> %s` on the "+
"control-node hands it to the mesh", ownerText(before), before.Mode().Perm(), out.asFoundDeclared, r.Path)
return out, nil
}
}
@@ -1122,6 +1125,45 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore
return out, nil
}
// applyHandedOver gives a directory the operator handed to the mesh its declared mode and owner (novox/hq issue
// 356). **No link, checked again at the moment of use**: the hand-over refused a link when it was recorded, and a
// component of the path may have become one since — a parent swapped for a link to /etc would carry root's chmod
// and chown there. So the path must still resolve to itself, and the change is made through one descriptor opened
// from / with no link followed on any component (giveHandedOver); a link anywhere is refused and nothing changes.
func applyHandedOver(r *declaration.Directory, out Outcome, mode os.FileMode) (Outcome, error) {
clean := filepath.Clean(r.Path)
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return out, err
}
if resolved != clean {
return out, &PlacementRefusedError{Path: r.Path, Why: fmt.Sprintf("it was handed to the mesh, and is now "+
"reached through a link (it is %s): the node-engine follows no link to a directory it gives an owner", resolved)}
}
uid, gid, chown := 0, 0, r.Owner != ""
if chown {
if uid, gid, err = idsOf(r.Owner); err != nil {
return out, fmt.Errorf("%s should belong to %q: %w", r.Path, r.Owner, err)
}
}
before, beforeUID, beforeGID, err := giveHandedOver(clean, mode, uid, gid, chown)
if err != nil {
var link *LinkUnderHomeError
if errors.As(err, &link) {
return out, &PlacementRefusedError{Path: r.Path, Why: fmt.Sprintf("it was handed to the mesh, and %s is "+
"now a link: the node-engine follows no link to a directory it gives an owner", link.Link)}
}
return out, err
}
out.Action = "unchanged"
if before.Perm() != mode.Perm() || (chown && (beforeUID != uid || beforeGID != gid)) {
out.Action = "updated"
out.Detail = fmt.Sprintf("handed to the mesh: owner %d:%d, mode %o, given what was declared", beforeUID, beforeGID,
before.Perm())
}
return out, nil
}
// applyAccess confirms an operator-owned path is present, and owns nothing about it.
//
// **The mirror image of applyDirectory** (novox/hq ADR 0051). A directory the host makes, chmods,
@@ -3417,7 +3459,7 @@ func removeFile(a store.Applied, now time.Time) (string, string, error) {
"remove it by hand once you know what it is", aside), nil
}
// HandedOver is every directory a person handed to the mesh at the machine (`mesh-host hand-over`), by
// HandedOver is every directory the operator handed to the mesh (`nox node hand-over`; `hand-over` at the machine), by
// handOverKey: a found directory there is the mesh's, given its declared owner and mode at the next apply
// (novox/hq issue 339). Set by the engine to read the file beside its state; a test sets its own.
var HandedOver = func() map[string]bool { return nil }
+119
View File
@@ -136,3 +136,122 @@ func TestAHandOverIsForTheResourceThatAskedForIt(t *testing.T) {
t.Fatalf("a hand-over for notes.data gave other.data the directory: mode %o", info.Mode().Perm())
}
}
// **A handed-over directory is changed through no link, at the moment of use** (review of issue 356). The
// hand-over refused a link when it was recorded; between then and the apply its parent is swapped for a link to
// a victim tree. The apply refuses, and the victim's directory keeps its mode — and so does a directory whose own
// name became a link, and one whose grandparent did.
func TestAHandedOverDirectoryIsChangedThroughNoLink(t *testing.T) {
for _, c := range []struct {
swap string
applyOnce bool
}{{"parent", false}, {"grandparent", false}, {"itself", false}, {"parent", true}, {"grandparent", true}} {
swap, applyOnce := c.swap, c.applyOnce
name := swap
if applyOnce {
name += " after an apply gave it its owner"
}
t.Run(name, func(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
found := filepath.Join(dir, "top", "parent", "data")
if err := os.MkdirAll(found, 0o700); err != nil {
t.Fatal(err)
}
decl := `{"id":"notes.data","type":"directory","path":"` + found + `","mode":"0755"}`
_, state, err := Apply(context.Background(), archHost(t), declare(t, decl), store.State{}, store.OriginDeclared,
noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); !a.AsFound {
t.Fatalf("not used as found: %+v", a)
}
before := HandedOver
HandedOver = func() map[string]bool { return map[string]bool{handOverKey("notes.data", found): true} }
t.Cleanup(func() { HandedOver = before })
if applyOnce {
// The hand-over takes: the directory is given its mode, and the record is no longer as found.
if _, state, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil); err != nil {
t.Fatal(err)
}
if a, _ := state.Find("notes.data"); a.AsFound {
t.Fatalf("still as found after the hand-over took: %+v", a)
}
if info, _ := os.Stat(found); info.Mode().Perm() != 0o755 {
t.Fatalf("the hand-over did not take: %o", info.Mode().Perm())
}
}
// The victim: a tree of the same shape somewhere else, its data directory mode 0700.
victim := filepath.Join(dir, "victim", "top", "parent", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
var swapped, target string
switch swap {
case "parent":
swapped, target = filepath.Join(dir, "top", "parent"), filepath.Join(dir, "victim", "top", "parent")
case "grandparent":
swapped, target = filepath.Join(dir, "top"), filepath.Join(dir, "victim", "top")
case "itself":
swapped, target = found, victim
}
if err := os.Rename(swapped, swapped+".real"); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, swapped); err != nil {
t.Fatal(err)
}
_, _, err = Apply(context.Background(), archHost(t), declare(t, decl), state, store.OriginDeclared,
noServices, nil, nil)
if err == nil {
t.Fatal("a handed-over directory reached through a link was applied")
}
// Refused by the path's re-check before any descriptor is opened (a link at the name itself is refused
// earlier still, by the directory applier's own look at it).
want := "reached through a link"
if swap == "itself" {
want = "symbolic link"
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("refused as %v; want %q", err, want)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim's directory was given the declared mode through the link: %o", info.Mode().Perm())
}
})
}
}
// The check at the moment of use, without the path's re-check: a component swapped for a link is refused by the
// descriptor walk itself, whatever was resolved a moment before.
func TestGiveHandedOverFollowsNoLink(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
victim := filepath.Join(dir, "victim", "data")
if err := os.MkdirAll(victim, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dir, "victim"), filepath.Join(dir, "parent")); err != nil {
t.Fatal(err)
}
if _, _, _, err := giveHandedOver(filepath.Join(dir, "parent", "data"), 0o755, 0, 0, false); err == nil {
t.Fatal("a directory reached through a link was changed")
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o700 {
t.Fatalf("the victim was changed: %o", info.Mode().Perm())
}
mode, _, _, err := giveHandedOver(victim, 0o750, 0, 0, false)
if err != nil || mode.Perm() != 0o700 {
t.Fatalf("the directory itself: before %o, %v", mode, err)
}
if info, _ := os.Stat(victim); info.Mode().Perm() != 0o750 {
t.Fatalf("the directory itself was not given its mode: %o", info.Mode().Perm())
}
}
+28
View File
@@ -239,3 +239,31 @@ func writeUnder(home, path string, content []byte, mode os.FileMode) error {
}
return werr
}
// giveHandedOver gives a directory the operator handed to the mesh its declared mode and owner (novox/hq issue
// 356, issue 339) through one descriptor opened from / with no link followed on any component (openUnderAs with /
// as the home), judged a directory by fstat on that descriptor, and changed with fchmod and fchown on it. The
// hand-over refused a link when it was recorded; this refuses one at the moment of use, so a component swapped for
// a link between the record and the apply cannot carry root's chmod and chown to wherever it points. Returns the
// mode and owner the directory had before.
func giveHandedOver(path string, mode os.FileMode, uid, gid int, chown bool) (before os.FileMode, beforeUID, beforeGID int, err error) {
fd, err := openUnderAs("/", path, kindDir)
if err != nil {
return 0, 0, 0, err
}
defer unix.Close(fd)
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return 0, 0, 0, &os.PathError{Op: "fstat", Path: path, Err: err}
}
before, beforeUID, beforeGID = os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid)
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
return 0, 0, 0, &os.PathError{Op: "chmod", Path: path, Err: err}
}
if chown && (beforeUID != uid || beforeGID != gid) {
if err := unix.Fchown(fd, uid, gid); err != nil {
return 0, 0, 0, &os.PathError{Op: "chown", Path: path, Err: err}
}
}
return before, beforeUID, beforeGID, nil
}
+7
View File
@@ -57,3 +57,10 @@ func writeUnder(home, path string, content []byte, mode os.FileMode) error {
}
return writeAtomicallyByPath(path, content, mode)
}
// giveHandedOver is refused where there is no openat with O_NOFOLLOW: a handed-over directory is never changed by
// path (novox/hq issue 356). The node-engine runs on Linux.
func giveHandedOver(path string, _ os.FileMode, _, _ int, _ bool) (os.FileMode, int, int, error) {
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"}
}
+190
View File
@@ -0,0 +1,190 @@
package link
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// A hand-over asked over the bus (novox/hq issue 356, issue 339).
//
// A directory used as found is handed to the mesh by its operator, at the controller's terminal:
// `nox node hand-over <node> <path>` on the control-node (novox/hq ADR 0272). The controller asks this
// node's engine on AskHandOverSubject, a request on core NATS, and the engine answers once, on the reply
// the request carried.
//
// **The subject proves nothing; the signature does** (review of issue 356). Only the controller is granted
// a publish on the subject, but the bus lets any principal allowed to answer reply to a message it received,
// on whatever reply subject that message named — a tool server asked on its own subject with this reply
// would deliver a body of its choosing here. So the controller signs the ask with the mesh's key, over
// HandOverContext and the ask's bytes, and the engine verifies it with the key it verifies declarations with
// before it reads anything out of it; then it holds the ask to this node, to its expiry, and to a nonce it
// takes once. Then it judges every value and records the hand-over, or refuses and records nothing; the next
// apply gives the directory its declared owner and mode. The controller holds the same shapes in its own link
// code, with a test on each side holding the field names.
// AskHandOverSubject is where the controller asks this node's engine to take a directory used as found as
// the mesh's.
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
// HandOverAsk is one hand-over asked: the node it is for, the directory's absolute path as the engine states
// it, who asked in the controller's words, when it stops being good, and a nonce taken once.
type HandOverAsk struct {
Node string `json:"node"`
Path string `json:"path"`
By string `json:"by"`
Expires time.Time `json:"expires"`
Nonce string `json:"nonce"`
}
// SignedHandOver is the ask as it travels: its bytes exactly as signed, and the mesh's signature.
type SignedHandOver struct {
Ask []byte `json:"ask"`
Signature []byte `json:"signature"`
}
// HandOverContext is prefixed to an ask's bytes before signing: a hand-over's signature is never a
// declaration's, which the same key makes over a declaration's bytes alone.
const HandOverContext = "novox-mesh hand-over v1\n"
// HandOverGood is how long the controller makes an ask good for; HandOverSkew is how far the machines' clocks
// may differ. An ask whose expiry is further ahead than both together is refused: it would be good too long.
const (
HandOverGood = time.Minute
HandOverSkew = time.Minute
)
// HandOverAnswer is the engine's answer: what it recorded, or why it refused — never both, never neither.
type HandOverAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
}
// HandOverFunc judges and records one hand-over: the words to answer with, or the refusal.
type HandOverFunc func(path, by string) (string, error)
// MaxHandOverAsk bounds a request: a path, a name, a time, a nonce and a signature are hundreds of bytes.
const MaxHandOverAsk = 8 << 10
// MaxHandOverBy bounds who asked.
const MaxHandOverBy = 200
// HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken.
type HandOverCheck struct {
Signer ed25519.PublicKey
Node string
Now func() time.Time
Nonces *Nonces
}
// Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory —
// an ask outlives no restart by more than its minute, and taking one again records the same hand-over again.
type Nonces struct {
mu sync.Mutex
seen map[string]time.Time
}
// take records a nonce until its expiry, and says false when it was already taken.
func (n *Nonces) take(nonce string, expires, now time.Time) bool {
n.mu.Lock()
defer n.mu.Unlock()
if n.seen == nil {
n.seen = map[string]time.Time{}
}
for k, until := range n.seen {
if now.After(until) {
delete(n.seen, k)
}
}
if _, taken := n.seen[nonce]; taken {
return false
}
n.seen[nonce] = expires
return true
}
// HandOverNonces are this process's: every link it opens takes from the same set, so a reconnect is no replay.
var HandOverNonces = &Nonces{}
func refusedAsk(why string) HandOverAnswer {
return HandOverAnswer{Refused: why + "; nothing was handed over"}
}
// AnswerHandOver is the engine's judgement of one ask's bytes: the signature first, then this node, the expiry
// and the nonce, then every value — and only then the handler, given only what passed. A refusal records nothing.
func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandOverAnswer {
if handle == nil {
return refusedAsk("this engine takes no hand-over")
}
if len(body) > MaxHandOverAsk {
return refusedAsk(fmt.Sprintf("the ask is %d bytes, more than a hand-over", len(body)))
}
var signed SignedHandOver
if err := json.Unmarshal(body, &signed); err != nil {
return refusedAsk("the ask is not a signed hand-over: " + err.Error())
}
if len(check.Signer) != ed25519.PublicKeySize ||
!ed25519.Verify(check.Signer, append([]byte(HandOverContext), signed.Ask...), signed.Signature) {
return refusedAsk("the ask is not signed by the mesh")
}
var ask HandOverAsk
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
return refusedAsk("the signed ask is not a hand-over: " + err.Error())
}
now := time.Now()
if check.Now != nil {
now = check.Now()
}
nonces := check.Nonces
if nonces == nil {
nonces = HandOverNonces
}
by := strings.TrimSpace(ask.By)
nonce, nonceErr := hex.DecodeString(ask.Nonce)
switch {
case check.Node == "" || ask.Node != check.Node:
return refusedAsk(fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node))
case ask.Expires.IsZero() || !now.Before(ask.Expires):
return refusedAsk("the ask has expired")
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
return refusedAsk("the ask is good for longer than an ask may be")
case nonceErr != nil || len(nonce) < 16:
return refusedAsk("the ask carries no nonce of its own")
case by == "":
return refusedAsk("the ask names nobody as asking")
case len(by) > MaxHandOverBy || strings.ContainsAny(by, "\r\n\x00"):
return refusedAsk("who asked is not one short line")
case ask.Path == "":
return refusedAsk("the ask names no directory")
case !nonces.take(ask.Nonce, ask.Expires, now):
return refusedAsk("the ask was taken already")
}
said, err := handle(ask.Path, by)
if err != nil {
return HandOverAnswer{Refused: err.Error()}
}
return HandOverAnswer{Said: said}
}
// HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply
// subject is nobody's question, so nothing is judged, recorded or said.
func HandOverReply(check HandOverCheck, handle HandOverFunc, reply string, body []byte) ([]byte, bool) {
if reply == "" {
return nil, false
}
answer, err := json.Marshal(AnswerHandOver(check, handle, body))
if err != nil {
return nil, false
}
return answer, true
}
// HandOvers is a link that hears the controller asking this node to hand a directory over. Optional, as
// Asked is.
type HandOvers interface {
HearHandOvers(HandOverFunc)
}
+240
View File
@@ -0,0 +1,240 @@
package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"sort"
"strings"
"testing"
"time"
)
// The hand-over's ask and answer hold these field names; the controller's side holds the same list
// (mesh-controller internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo", Expires: time.Now(), Nonce: "n"})
if got := keysOf(t, body); got != "by expires node nonce path" {
t.Fatalf("the ask's fields are %q", got)
}
body, _ = json.Marshal(SignedHandOver{Ask: []byte("{}"), Signature: []byte("s")})
if got := keysOf(t, body); got != "ask signature" {
t.Fatalf("the signed ask's fields are %q", got)
}
body, _ = json.Marshal(HandOverAnswer{Said: "s", Refused: "r"})
if got := keysOf(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", AskHandOverSubject("laptop"))
}
if HandOverContext != "novox-mesh hand-over v1\n" {
t.Fatalf("the signing context is %q; the controller holds the same words", HandOverContext)
}
}
func keysOf(t *testing.T, body []byte) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, " ")
}
const aNonce = "00112233445566778899aabbccddeeff"
// signedAsk is an ask as the controller makes it, signed with key over the context and its bytes.
func signedAsk(t *testing.T, key ed25519.PrivateKey, ask HandOverAsk) []byte {
t.Helper()
raw, err := json.Marshal(ask)
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(HandOverContext), raw...))})
if err != nil {
t.Fatal(err)
}
return body
}
// anAsk is a good ask at now.
func anAsk(now time.Time) HandOverAsk {
return HandOverAsk{Node: "n1", Path: "/srv/notes", By: " jo through mesh-cli on anchor ", Expires: now.Add(HandOverGood),
Nonce: aNonce}
}
// **An ask is believed only signed by the mesh, for this node, unexpired, once** (review of issue 356): a
// responder can reach the subject through a reply, so the subject proves nothing. Each refusal below hands
// the handler nothing; the good ask reaches it with its path and who asked, and the same ask again is refused.
func TestAHandOverAskIsBelievedOnlySignedForThisNodeUnexpiredAndOnce(t *testing.T) {
m, key := aMember(t)
_, stranger, _ := ed25519.GenerateKey(nil)
now := time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC)
var given []string
handle := func(path, by string) (string, error) {
given = append(given, path+" by "+by)
if path == "/refused" {
return "", errors.New("not a directory this machine uses as found; nothing was handed over")
}
return path + " is handed to the mesh", nil
}
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Now: func() time.Time { return now }, Nonces: &Nonces{}}
with := func(change func(*HandOverAsk)) HandOverAsk {
a := anAsk(now)
change(&a)
return a
}
good, _ := json.Marshal(anAsk(now))
unsignedBody, _ := json.Marshal(SignedHandOver{Ask: good})
// Signed as a declaration is, over the bytes alone: not a hand-over's signature.
asDeclaration, _ := json.Marshal(SignedHandOver{Ask: good, Signature: ed25519.Sign(key, good)})
refusals := map[string][]byte{
"too large": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/" + strings.Repeat("a", MaxHandOverAsk) })),
"not a signed hand-over": []byte(`not json`),
"unsigned": unsignedBody,
"signed by another key": signedAsk(t, stranger, anAsk(now)),
"signed as a declaration": asDeclaration,
"the bare ask": good,
"for another node": signedAsk(t, key, with(func(a *HandOverAsk) { a.Node = "n2" })),
"expired": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now })),
"no expiry": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = time.Time{} })),
"good for too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now.Add(HandOverGood + HandOverSkew + time.Second) })),
"no nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "" })),
"a short nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "0011" })),
"nobody": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = " " })),
"two lines": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\nroot" })),
"a name too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = strings.Repeat("x", MaxHandOverBy+1) })),
"a NUL in the name": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\x00" })),
"no directory": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "" })),
}
for name, body := range refusals {
a := AnswerHandOver(check, handle, body)
if a.Refused == "" || a.Said != "" || !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("%s was answered %+v; want a refusal saying nothing was handed over", name, a)
}
}
if len(given) != 0 {
t.Fatalf("the handler was given %v although every ask was refused", given)
}
if a := AnswerHandOver(HandOverCheck{Node: "n1", Nonces: &Nonces{}}, handle, signedAsk(t, key, anAsk(time.Now()))); a.Refused == "" {
t.Fatal("an engine with no key believed an ask")
}
if a := AnswerHandOver(check, nil, signedAsk(t, key, anAsk(now))); a.Refused == "" {
t.Fatal("an engine with no handler took a hand-over")
}
body := signedAsk(t, key, anAsk(now))
a := AnswerHandOver(check, handle, body)
if a.Said != "/srv/notes is handed to the mesh" || a.Refused != "" {
t.Fatalf("the good ask was answered %+v", a)
}
if again := AnswerHandOver(check, handle, body); !strings.Contains(again.Refused, "taken already") {
t.Fatalf("the same ask again was answered %+v", again)
}
refused := signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/refused"; a.Nonce = strings.Repeat("ab", 16) }))
if a := AnswerHandOver(check, handle, refused); !strings.HasPrefix(a.Refused, "not a directory") {
t.Fatalf("the handler's refusal was answered %+v", a)
}
if strings.Join(given, "; ") != "/srv/notes by jo through mesh-cli on anchor; /refused by jo through mesh-cli on anchor" {
t.Fatalf("the handler was given %v", given)
}
// A nonce is forgotten once its ask has expired, so the set does not grow for ever.
now = now.Add(HandOverGood + time.Second)
check.Nonces.take("ff", now.Add(time.Second), now)
if len(check.Nonces.seen) != 1 {
t.Fatalf("expired nonces are kept: %v", check.Nonces.seen)
}
}
// **A request with no reply is nobody's question**: nothing is judged, recorded or said. One with a reply is
// answered with the judgement.
func TestAHandOverIsAnsweredOnlyWhenItCarriesAReply(t *testing.T) {
m, key := aMember(t)
now := time.Now()
called := 0
handle := func(string, string) (string, error) { called++; return "handed", nil }
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}}
if body, answer := HandOverReply(check, handle, "", signedAsk(t, key, anAsk(now))); answer || body != nil || called != 0 {
t.Fatalf("a request with no reply was answered %q %v, handled %d", body, answer, called)
}
body, answer := HandOverReply(check, handle, "_INBOX.x", signedAsk(t, key, anAsk(now)))
var said HandOverAnswer
if !answer || json.Unmarshal(body, &said) != nil || said.Said != "handed" || called != 1 {
t.Fatalf("answered %q %v, handled %d", body, answer, called)
}
}
// handingLink is a quiet link that hears hand-overs, keeping the handler it was given.
type handingLink struct {
*quietLink
given chan HandOverFunc
}
func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h }
// **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's
// judgement, and nothing else.
func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
m, _ := aMember(t)
l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1)}
q := aQueue(m, (&applies{}).apply, &keptInMemory{})
q.HandOver = func(path, by string) (string, error) { return "the queue's: " + path + " by " + by, nil }
ctx, stop := context.WithCancel(context.Background())
served := make(chan error, 1)
go func() { served <- serve(ctx, l, m, q, nil, time.Second) }()
select {
case h := <-l.given:
if said, err := h("/srv/notes", "jo"); err != nil || said != "the queue's: /srv/notes by jo" {
t.Fatalf("the link was given another handler: %q %v", said, err)
}
case <-time.After(5 * time.Second):
t.Fatal("the link was never given the queue's hand-over")
}
stop()
<-served
}
// **Against a real server**: a signed request on the node's own subject is answered; one on another node's is
// not heard; a message with no reply is answered by nobody.
func TestNatsAHandOverIsAskedOfTheNode(t *testing.T) {
conn, js := aBus(t)
m, key := aMember(t)
l := &natsLink{conn: conn, js: js, node: m.Node, signer: m.Signer}
handled := make(chan string, 4)
l.HearHandOvers(func(path, by string) (string, error) { handled <- path; return "handed " + path, nil })
defer l.Close()
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
ask := anAsk(time.Now())
ask.Nonce = strings.Repeat("cd", 16)
reply, err := conn.Request(AskHandOverSubject(m.Node), signedAsk(t, key, ask), 5*time.Second)
if err != nil {
t.Fatal(err)
}
var said HandOverAnswer
if err := json.Unmarshal(reply.Data, &said); err != nil || said.Said != "handed /srv/notes" {
t.Fatalf("answered %s, %v", reply.Data, err)
}
ask.Nonce = strings.Repeat("ef", 16)
if err := conn.Publish(AskHandOverSubject(m.Node), signedAsk(t, key, ask)); err != nil {
t.Fatal(err)
}
if _, err := conn.Request(AskHandOverSubject("another"), signedAsk(t, key, ask), 300*time.Millisecond); err == nil {
t.Fatal("another node's ask was answered here")
}
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
if len(handled) != 1 {
t.Fatalf("handled %d asks; want only the one with a reply", len(handled))
}
}
+29 -1
View File
@@ -2,6 +2,7 @@ package link
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"strings"
@@ -60,7 +61,10 @@ type natsLink struct {
sub *nats.Subscription
asking *nats.Subscription
asked chan struct{}
handing *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
lost chan error
}
@@ -110,7 +114,7 @@ func dialNats(ctx context.Context, m Membership, timeout time.Duration) (Link, e
}
l := &natsLink{
conn: conn, js: js, node: m.Node,
conn: conn, js: js, node: m.Node, signer: m.Signer,
arrived: make(chan Declaration, drainDepth),
lost: make(chan error, 1),
asked: make(chan struct{}, 1),
@@ -192,6 +196,27 @@ func (l *natsLink) hearAsks() {
}
}
// HearHandOvers listens for the controller asking this node to hand a directory used as found to the mesh
// (novox/hq issue 356): a request on this machine's own subject, answered once on the reply it carried. The
// ask is verified with the mesh's key before anything is read out of it (AnswerHandOver), because a responder
// can reach this subject through a reply; the engine runs nothing it is sent. A bus whose user list is older
// than the grant refuses the subscription asynchronously, said by the client; the operator's line then says
// that nothing on this node answers.
func (l *natsLink) HearHandOvers(handle HandOverFunc) {
if handle == nil {
return
}
check := HandOverCheck{Signer: l.signer, Node: l.node}
handing, err := l.conn.Subscribe(AskHandOverSubject(l.node), func(msg *nats.Msg) {
if body, answer := HandOverReply(check, handle, msg.Reply, msg.Data); answer {
_ = msg.Respond(body)
}
})
if err == nil {
l.handing = handing
}
}
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 }
@@ -203,6 +228,9 @@ func (l *natsLink) Close() {
if l.asking != nil {
_ = l.asking.Unsubscribe()
}
if l.handing != nil {
_ = l.handing.Unsubscribe()
}
if l.conn != nil {
l.conn.Close()
}
+1 -1
View File
@@ -268,7 +268,7 @@ const KindAccount = "account"
// KindDirectory is a directory a module declares that the engine uses as found (novox/hq issue 339): there before
// the mesh, with another owner or mode than declared, and left so. Said unhealthy on every look, its reason
// starting ReasonUsedAsFound, until a person hands it to the mesh at the machine (`mesh-host hand-over`).
// starting ReasonUsedAsFound, until the operator hands it to the mesh (`nox node hand-over <node> <path>` on the control-node, novox/hq issue 356).
const KindDirectory = "directory"
// ReasonUsedAsFound starts the reason of a directory used as found.
+5 -2
View File
@@ -51,8 +51,11 @@ type Queue struct {
// Numbers keeps the report sequence and the refusals counted, across restarts and self-updates.
// Nil counts in memory, which is only right where nothing outlives the process — a test.
Numbers Numbers
Say Announce
Timeout time.Duration
// HandOver judges and records a directory used as found handed to the mesh by the operator, asked by
// the controller over the link (novox/hq issue 356). Nil answers every such ask with a refusal.
HandOver HandOverFunc
Say Announce
Timeout time.Duration
once sync.Once
wake chan struct{}
+5
View File
@@ -239,6 +239,11 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou
if a, ok := link.(Asked); ok {
asks = a.AskedToReport()
}
// And the controller asking this node to hand a directory used as found to the mesh (novox/hq issue
// 356), answered on the link the question came over.
if h, ok := link.(HandOvers); ok {
h.HearHandOvers(queue.HandOver)
}
for {
// Asked to stop — by standing aside, say — nothing further is taken in.
+135
View File
@@ -0,0 +1,135 @@
package upgrade
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// The engine placed by hand, kept current (novox/hq issue 356).
//
// The first engine on a machine is copied there by a person (novox/hq ADR 0141): the launcher runs it
// while nothing is delivered, and it is the one `nox-mesh-host` on any PATH. Every engine after it is
// delivered under VersionsDirName and run from there, so the file on PATH fell behind: on 2026-10-09 the
// operator typed its `hand-over` and was answered "unexpected argument" by a build months older than the
// one running the machine. A command that answers for a build that is not running is the lie this
// repository exists to refuse.
//
// So a proved engine (Proved) writes itself over the one placed by hand when the two differ: a plain
// copy, renamed into place — never a link, which the mesh creates none of (novox/hq ADR 0012), and never
// a write through one. The launcher's fallback is then the newest proved build, which is also the best
// one to fall back on.
// DefaultFallback is where the engine placed by hand lives: the launcher's fallback, and the command.
const DefaultFallback = "/usr/bin/nox-mesh-host"
// FallbackVar is the environment the launcher reads the fallback's path from; read here too, so the two
// never name different files.
const FallbackVar = "MESH_HOST_BIN"
// FallbackPath is the engine placed by hand: FallbackVar, or DefaultFallback.
func FallbackPath() string {
if p := os.Getenv(FallbackVar); p != "" {
return p
}
return DefaultFallback
}
// RenewFallback makes the file at fallback a copy of the executable at self, when it is not one already.
//
// Nothing is written when the two hold the same bytes — which an engine running from the fallback itself, while
// nothing is delivered, always does. A fallback that is a link is refused: the mesh writes
// through no link, and a link there would be somebody else's arrangement. Reports whether it was renewed.
func RenewFallback(self, fallback string) (bool, error) {
if self == "" || fallback == "" {
return false, errors.New("the running engine or the fallback has no path")
}
mine, err := os.Stat(self)
if err != nil {
return false, fmt.Errorf("cannot read the running engine at %s: %w", self, err)
}
if !mine.Mode().IsRegular() {
return false, fmt.Errorf("the running engine at %s is not a regular file", self)
}
placed, err := os.Lstat(fallback)
switch {
case errors.Is(err, os.ErrNotExist):
// Nothing placed by hand: the launcher would have nothing to fall back on. Placed now.
case err != nil:
return false, fmt.Errorf("cannot read the engine placed by hand at %s: %w", fallback, err)
case placed.Mode()&os.ModeSymlink != 0:
return false, fmt.Errorf("%s is a link, and the mesh writes through none (novox/hq ADR 0012): "+
"it is left as it is", fallback)
case !placed.Mode().IsRegular():
return false, fmt.Errorf("%s is not a regular file: it is left as it is", fallback)
default:
same, err := sameBytes(self, fallback)
if err != nil {
return false, err
}
if same {
return false, nil
}
}
src, err := os.Open(self)
if err != nil {
return false, err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(fallback), "."+filepath.Base(fallback)+"-*")
if err != nil {
return false, fmt.Errorf("cannot write beside %s: %w", fallback, err)
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Close(); err != nil {
return false, err
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return false, err
}
// Renamed, never written in place: the kernel refuses to truncate an executable that runs, and a
// rename leaves whatever runs from the old file untouched.
if err := os.Rename(tmp.Name(), fallback); err != nil {
return false, err
}
return true, nil
}
// sameBytes says whether two files hold the same bytes, by digest.
func sameBytes(a, b string) (bool, error) {
da, err := digestOf(a)
if err != nil {
return false, err
}
db, err := digestOf(b)
if err != nil {
return false, err
}
return bytes.Equal(da, db), nil
}
func digestOf(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return h.Sum(nil), nil
}
+186
View File
@@ -0,0 +1,186 @@
package upgrade
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The engine placed by hand becomes a copy of the proved one when the two differ (novox/hq issue 356): the
// command on PATH is the build that runs the machine.
func TestTheFallbackBecomesACopyOfTheProvedEngine(t *testing.T) {
dir := t.TempDir()
self := filepath.Join(dir, VersionsDirName, "v2", BinaryName)
if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(self, []byte("the proved engine"), 0o755); err != nil {
t.Fatal(err)
}
fallback := filepath.Join(dir, "bin", BinaryName)
if err := os.MkdirAll(filepath.Dir(fallback), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fallback, []byte("the stale engine placed by hand"), 0o644); err != nil {
t.Fatal(err)
}
renewed, err := RenewFallback(self, fallback)
if err != nil || !renewed {
t.Fatalf("renewed %v, %v", renewed, err)
}
got, err := os.ReadFile(fallback)
if err != nil || string(got) != "the proved engine" {
t.Fatalf("the fallback holds %q, %v", got, err)
}
info, err := os.Stat(fallback)
if err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("the fallback's mode is %v, %v; want 0755 so it can be run", info.Mode(), err)
}
if _, err := os.Stat(self); err != nil {
t.Fatalf("the running engine was touched: %v", err)
}
// Already a copy: left alone, so a proof every start does not rewrite it every start.
before, _ := os.Stat(fallback)
renewed, err = RenewFallback(self, fallback)
if err != nil || renewed {
t.Fatalf("a fallback that is already a copy was renewed again: %v, %v", renewed, err)
}
after, _ := os.Stat(fallback)
if !os.SameFile(before, after) {
t.Fatal("a fallback that is already a copy was replaced")
}
if left, _ := filepath.Glob(filepath.Join(filepath.Dir(fallback), "."+BinaryName+"-*")); len(left) > 0 {
t.Fatalf("temporary files left beside the fallback: %v", left)
}
}
// An engine running from the fallback itself — nothing delivered yet — renews nothing: it would copy a file
// over itself.
func TestAnEngineRunningFromTheFallbackRenewsNothing(t *testing.T) {
dir := t.TempDir()
fallback := filepath.Join(dir, BinaryName)
if err := os.WriteFile(fallback, []byte("placed by hand and running"), 0o755); err != nil {
t.Fatal(err)
}
renewed, err := RenewFallback(fallback, fallback)
if err != nil || renewed {
t.Fatalf("renewed %v, %v", renewed, err)
}
}
// Nothing placed by hand: the proved engine is placed, so the launcher has a fallback and the machine a command.
func TestAMissingFallbackIsPlaced(t *testing.T) {
dir := t.TempDir()
self := filepath.Join(dir, "v1", BinaryName)
if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(self, []byte("proved"), 0o755); err != nil {
t.Fatal(err)
}
fallback := filepath.Join(dir, BinaryName)
renewed, err := RenewFallback(self, fallback)
if err != nil || !renewed {
t.Fatalf("renewed %v, %v", renewed, err)
}
if got, _ := os.ReadFile(fallback); string(got) != "proved" {
t.Fatalf("placed %q", got)
}
}
// A link at the fallback's path is refused and left as it is: the mesh creates no link and writes through none
// (novox/hq ADR 0012). The link's target is untouched too.
func TestALinkAtTheFallbackIsRefusedAndLeft(t *testing.T) {
dir := t.TempDir()
self := filepath.Join(dir, "v1", BinaryName)
if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(self, []byte("proved"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "somebody-elses")
if err := os.WriteFile(target, []byte("theirs"), 0o755); err != nil {
t.Fatal(err)
}
fallback := filepath.Join(dir, BinaryName)
if err := os.Symlink(target, fallback); err != nil {
t.Fatal(err)
}
renewed, err := RenewFallback(self, fallback)
if err == nil || renewed || !strings.Contains(err.Error(), "link") {
t.Fatalf("a link was taken: %v, %v", renewed, err)
}
if got, _ := os.ReadFile(target); string(got) != "theirs" {
t.Fatalf("the link's target was written: %q", got)
}
if info, err := os.Lstat(fallback); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("the link was replaced: %v %v", info, err)
}
}
// A running engine that cannot be read is said, not copied as nothing.
func TestAnUnreadableRunningEngineIsSaid(t *testing.T) {
dir := t.TempDir()
fallback := filepath.Join(dir, BinaryName)
if err := os.WriteFile(fallback, []byte("placed"), 0o755); err != nil {
t.Fatal(err)
}
renewed, err := RenewFallback(filepath.Join(dir, "gone"), fallback)
if err == nil || renewed {
t.Fatalf("renewed %v, %v", renewed, err)
}
if got, _ := os.ReadFile(fallback); string(got) != "placed" {
t.Fatalf("the fallback was written from nothing: %q", got)
}
if _, err := RenewFallback("", fallback); err == nil {
t.Fatal("an empty path was taken")
}
}
// The fallback's path is the launcher's: its environment variable, or the default.
func TestTheFallbackPathIsTheLaunchers(t *testing.T) {
t.Setenv(FallbackVar, "")
if FallbackPath() != DefaultFallback {
t.Fatalf("the default is %q", FallbackPath())
}
t.Setenv(FallbackVar, "/elsewhere/nox-mesh-host")
if FallbackPath() != "/elsewhere/nox-mesh-host" {
t.Fatalf("the override is %q", FallbackPath())
}
}
// A fallback that is not a regular file — a directory where the engine should be — is refused and left: the mesh
// replaces a file, never something else at its name.
func TestAFallbackThatIsNotARegularFileIsRefused(t *testing.T) {
dir := t.TempDir()
self := filepath.Join(dir, "v1", BinaryName)
if err := os.MkdirAll(filepath.Dir(self), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(self, []byte("proved"), 0o755); err != nil {
t.Fatal(err)
}
fallback := filepath.Join(dir, BinaryName)
if err := os.Mkdir(fallback, 0o755); err != nil {
t.Fatal(err)
}
renewed, err := RenewFallback(self, fallback)
if err == nil || renewed || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("a directory at the fallback was taken: %v, %v", renewed, err)
}
if info, err := os.Lstat(fallback); err != nil || !info.IsDir() {
t.Fatalf("the directory was replaced: %v %v", info, err)
}
// And a running engine that is not a regular file is refused before anything is written.
if _, err := RenewFallback(dir, filepath.Join(dir, "elsewhere")); err == nil || !strings.Contains(err.Error(),
"not a regular file") {
t.Fatalf("a directory was taken as the running engine: %v", err)
}
if _, err := os.Lstat(filepath.Join(dir, "elsewhere")); err == nil {
t.Fatal("something was placed from a directory")
}
}