Two holes the review proved. The ask's subject proved nothing — a responder can reach it through a reply — so the engine now verifies the mesh's signature over a fixed context with the key it verifies declarations with, before reading anything, and refuses an ask for another node, expired, good too long, without a nonce, or replayed. And the apply followed a link the hand-over had refused: a component swapped for a link after the record carried root's chmod and chown to its target. A handed-over directory must now still resolve to itself, and is changed only through one descriptor opened from / with no link followed (fchmod, fchown). Tests for the reply guard, the run loop's hook, the fallback renewal on proof and a non-regular fallback; 27 checks each removed in turn, each failing a test.
136 lines
4.4 KiB
Go
136 lines
4.4 KiB
Go
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
|
|
}
|