mesh/merge-gate head 499a687149be merged into trunk 6b54fed706f0 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 499a687149be merged into trunk 6b54fed706f0 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A second unix socket beside mesh-cli's: the kernel's word on the asking account (SO_PEERCRED, same namespaces), that account judged against the declaration before its request is read, the path judged clean, absolute and filled, and the write done under the apply's lock. The journal has the path and digest, never the content. Issue 668. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
8.1 KiB
Go
264 lines
8.1 KiB
Go
package filling
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// Defends novox/hq ADR 0349 §4 (issue 668): the node-engine writes a filled file on request only for the provisioner
|
|
// client's account, only for a clean path the current declaration names as filled, and one at a time with the apply.
|
|
|
|
const provisionerUID = 4242
|
|
|
|
func declared(t *testing.T, raw string) *declaration.Declaration {
|
|
t.Helper()
|
|
d, err := declaration.Parse([]byte(raw))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
const withAccount = `{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/m/m.conf"],
|
|
"resources":[{"id":"m.conf","type":"file","path":"/etc/m/m.conf","content":"x","owner-at-runtime":"owned"},
|
|
{"id":"m.other","type":"file","path":"/etc/m/other.conf","content":"y"}]}`
|
|
|
|
const withoutAccount = `{"declaration":1,"filled_files":["/etc/m/m.conf"],
|
|
"resources":[{"id":"m.conf","type":"file","path":"/etc/m/m.conf","content":"x","owner-at-runtime":"owned"}]}`
|
|
|
|
// engine is an Engine over a fixed declaration, recording what it was asked to place.
|
|
type engine struct {
|
|
Engine
|
|
mu sync.Mutex
|
|
placed []string
|
|
}
|
|
|
|
func newEngine(t *testing.T, raw string, lock sync.Locker) *engine {
|
|
d := declared(t, raw)
|
|
e := &engine{}
|
|
e.Engine = Engine{
|
|
Declared: func() (*declaration.Declaration, error) { return d, nil },
|
|
UIDOf: func(name string) (uint32, error) {
|
|
if name == "mesh-node-provisioner" {
|
|
return provisionerUID, nil
|
|
}
|
|
return 0, errors.New("no such account")
|
|
},
|
|
Lock: func() (func(), error) { lock.Lock(); return lock.Unlock, nil },
|
|
Place: func(_ context.Context, _ *declaration.Declaration, path string, content []byte) (string, string, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.placed = append(e.placed, path)
|
|
return "updated", "digest", nil
|
|
},
|
|
}
|
|
return e
|
|
}
|
|
|
|
func TestAWriteFromTheProvisionersAccountOfAFilledPathIsPlaced(t *testing.T) {
|
|
e := newEngine(t, withAccount, &sync.Mutex{})
|
|
if err := e.Accepts(provisionerUID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := e.Write(context.Background(), provisionerUID, Request{Path: "/etc/m/m.conf", Content: []byte("x")})
|
|
if a.Refused != "" || a.Failed != "" || a.Action != "updated" || len(e.placed) != 1 {
|
|
t.Fatalf("answer %+v, placed %v", a, e.placed)
|
|
}
|
|
}
|
|
|
|
func TestAnUndeclaredPathIsRefused(t *testing.T) {
|
|
e := newEngine(t, withAccount, &sync.Mutex{})
|
|
for _, path := range []string{"/etc/m/other.conf", "/etc/m/none.conf", "/etc/passwd"} {
|
|
a := e.Write(context.Background(), provisionerUID, Request{Path: path, Content: []byte("evil")})
|
|
if a.Refused == "" || !strings.Contains(a.Refused, "not a file this node's declaration names as filled") {
|
|
t.Errorf("%s: %+v", path, a)
|
|
}
|
|
}
|
|
if len(e.placed) != 0 {
|
|
t.Errorf("placed %v", e.placed)
|
|
}
|
|
}
|
|
|
|
func TestAnotherAccountIsRefused(t *testing.T) {
|
|
e := newEngine(t, withAccount, &sync.Mutex{})
|
|
for _, uid := range []uint32{0, 1000, provisionerUID + 1} {
|
|
if err := e.Accepts(uid); err == nil {
|
|
t.Errorf("user id %d was accepted", uid)
|
|
}
|
|
a := e.Write(context.Background(), uid, Request{Path: "/etc/m/m.conf", Content: []byte("evil")})
|
|
if a.Refused == "" || !strings.Contains(a.Refused, "is not the provisioner client's account") {
|
|
t.Errorf("user id %d: %+v", uid, a)
|
|
}
|
|
}
|
|
if len(e.placed) != 0 {
|
|
t.Errorf("placed %v", e.placed)
|
|
}
|
|
}
|
|
|
|
func TestEveryWriteIsRefusedWhileTheDeclarationNamesNoAccount(t *testing.T) {
|
|
e := newEngine(t, withoutAccount, &sync.Mutex{})
|
|
if err := e.Accepts(provisionerUID); err == nil || !strings.Contains(err.Error(), "names no provisioner") {
|
|
t.Errorf("accepted with no account named: %v", err)
|
|
}
|
|
a := e.Write(context.Background(), provisionerUID, Request{Path: "/etc/m/m.conf", Content: []byte("x")})
|
|
if a.Refused == "" || len(e.placed) != 0 {
|
|
t.Errorf("written with no account named: %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestAnAccountThatIsRootHereIsRefused(t *testing.T) {
|
|
e := newEngine(t, withAccount, &sync.Mutex{})
|
|
e.UIDOf = func(string) (uint32, error) { return 0, nil }
|
|
if err := e.Accepts(0); err == nil || !strings.Contains(err.Error(), "root") {
|
|
t.Errorf("an account that is root here was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnUncleanPathIsRefused(t *testing.T) {
|
|
e := newEngine(t, withAccount, &sync.Mutex{})
|
|
for _, path := range []string{"", "etc/m/m.conf", "m.conf", "/etc//m/m.conf", "/etc/m/../m/m.conf",
|
|
"/etc/m/./m.conf", "/etc/m/m.conf/"} {
|
|
a := e.Write(context.Background(), provisionerUID, Request{Path: path, Content: []byte("x")})
|
|
if a.Refused == "" {
|
|
t.Errorf("%q was not refused: %+v", path, a)
|
|
}
|
|
}
|
|
if len(e.placed) != 0 {
|
|
t.Errorf("placed %v", e.placed)
|
|
}
|
|
}
|
|
|
|
// A write waits for an apply holding the apply's lock, and writes only once it is let go.
|
|
func TestAWriteWaitsForTheApply(t *testing.T) {
|
|
var applying sync.Mutex
|
|
e := newEngine(t, withAccount, &applying)
|
|
applying.Lock()
|
|
done := make(chan Answer)
|
|
go func() {
|
|
done <- e.Write(context.Background(), provisionerUID, Request{Path: "/etc/m/m.conf", Content: []byte("x")})
|
|
}()
|
|
select {
|
|
case a := <-done:
|
|
t.Fatalf("written while the apply held the lock: %+v", a)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
e.mu.Lock()
|
|
n := len(e.placed)
|
|
e.mu.Unlock()
|
|
if n != 0 {
|
|
t.Fatalf("placed while the apply held the lock")
|
|
}
|
|
applying.Unlock()
|
|
if a := <-done; a.Action != "updated" {
|
|
t.Fatalf("after the apply: %+v", a)
|
|
}
|
|
}
|
|
|
|
// fixed is a Writer that accepts one user id and answers every write.
|
|
type fixed struct {
|
|
uid uint32
|
|
mu sync.Mutex
|
|
writes []Request
|
|
}
|
|
|
|
func (f *fixed) Accepts(uid uint32) error {
|
|
if uid != f.uid {
|
|
return errors.New("not the provisioner client's account")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fixed) Write(_ context.Context, _ uint32, req Request) Answer {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.writes = append(f.writes, req)
|
|
return Answer{Path: req.Path, Action: "updated", Digest: "d"}
|
|
}
|
|
|
|
// serve serves w on a socket in a fresh directory and returns its path, and what was said.
|
|
func serve(t *testing.T, w Writer) (string, func() []string) {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "fill")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
socket := filepath.Join(dir, "fill.sock")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var mu sync.Mutex
|
|
var said []string
|
|
stopped := make(chan error)
|
|
go func() {
|
|
stopped <- Serve(ctx, socket, w, func(s string) { mu.Lock(); said = append(said, s); mu.Unlock() })
|
|
}()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
if err := <-stopped; err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
for i := 0; i < 100; i++ {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return socket, func() []string { mu.Lock(); defer mu.Unlock(); return append([]string(nil), said...) }
|
|
}
|
|
|
|
func ask(t *testing.T, socket string, req Request) Answer {
|
|
t.Helper()
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
if err := json.NewEncoder(conn).Encode(req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a Answer
|
|
if err := json.NewDecoder(conn).Decode(&a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// Over a real socket, the kernel says who is asking: this test's own account is the provisioner's here.
|
|
func TestTheSocketWritesForTheAccountTheKernelSays(t *testing.T) {
|
|
w := &fixed{uid: uint32(os.Getuid())}
|
|
socket, said := serve(t, w)
|
|
a := ask(t, socket, Request{Path: "/etc/m/m.conf", Content: []byte("secret value")})
|
|
if a.Refused != "" || a.Action != "updated" || len(w.writes) != 1 || string(w.writes[0].Content) != "secret value" {
|
|
t.Fatalf("answer %+v, writes %+v", a, w.writes)
|
|
}
|
|
for _, line := range said() {
|
|
if strings.Contains(line, "secret value") {
|
|
t.Errorf("the content was said: %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheSocketRefusesAnotherAccountBeforeReadingItsRequest(t *testing.T) {
|
|
w := &fixed{uid: uint32(os.Getuid()) + 1}
|
|
socket, said := serve(t, w)
|
|
a := ask(t, socket, Request{Path: "/etc/m/m.conf", Content: []byte("secret value")})
|
|
if a.Refused == "" || len(w.writes) != 0 {
|
|
t.Fatalf("another account was written for: %+v", a)
|
|
}
|
|
for _, line := range said() {
|
|
if strings.Contains(line, "secret value") {
|
|
t.Errorf("the content was said: %q", line)
|
|
}
|
|
}
|
|
}
|