mesh/delivery merged: source.main moved to novox/mesh-sdk@5a38a1221659
mesh/delivery-group group feat/l3-final merged: every member is merged
mesh/check pass 85ba0a9a70d8 on 52e564845e44 | unit 3760 run, 4992 kept, 56 skipped | integration 3841 run, 5103 kept, 59 skipped | over bound: unit, …
NodeRecord.ManifestReads (`manifest-reads`) and Module.Needs (`needs`) are additive at Version 1, read leniently by every running part. checkRule takes 1 to 10 so a later release may number the verb wait and the capability hold; nothing writes 9 or 10 in this one. kept_until_delivered.go stays: this release still builds beside mesh-host 798d73e, and its head comment names the next mesh-sdk release as the one that removes it. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
216 lines
10 KiB
Go
216 lines
10 KiB
Go
package delivery
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field
|
|
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
|
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
|
|
|
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
|
// neither it nor the placement file names a field like a secret.
|
|
func TestNodeRecord(t *testing.T) {
|
|
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
|
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
|
NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"},
|
|
ManifestReads: []string{"seat:mesh-catalog", "word:tool-runner"}}
|
|
raw, err := EncodeNodeRecord(hub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeNodeRecord(raw)
|
|
if err != nil || !SameNodeRecord(back, hub) || back.V != Version {
|
|
t.Fatalf("round trip: %+v, %v", back, err)
|
|
}
|
|
// It rides in the node's own have key: a have that differs only in its record is another value.
|
|
with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version}
|
|
if raw, err := EncodeHaveNode(with); err != nil {
|
|
t.Fatal(err)
|
|
} else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) {
|
|
t.Fatalf("the have's record: %+v, %v", got, err)
|
|
}
|
|
changed := hub
|
|
changed.Contract = 4
|
|
if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) {
|
|
t.Error("a changed record reads as the same have")
|
|
}
|
|
|
|
for name, r := range map[string]NodeRecord{
|
|
"empty capability": {Node: "ace", Capabilities: []string{""}},
|
|
"empty reader capability": {Node: "ace", ManifestReads: []string{""}},
|
|
"node that is not a name": {Node: "a.b"},
|
|
} {
|
|
if _, err := EncodeNodeRecord(r); err == nil {
|
|
t.Errorf("%s: taken", name)
|
|
}
|
|
}
|
|
|
|
// Every JSON field name of the record and the types it carries, walked: none reads as a secret.
|
|
var walk func(reflect.Type, string)
|
|
walk = func(ty reflect.Type, at string) {
|
|
for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice {
|
|
ty = ty.Elem()
|
|
}
|
|
if ty.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
for i := 0; i < ty.NumField(); i++ {
|
|
f := ty.Field(i)
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) {
|
|
t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name)
|
|
}
|
|
walk(f.Type, at+"."+f.Name)
|
|
}
|
|
}
|
|
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
|
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
|
// grants part reads them (ADR 0587 §2).
|
|
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
|
// The tunnel key given at issue, at every stage.
|
|
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
|
for _, e := range []Enrolment{
|
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
|
} {
|
|
raw, err := EncodeEnrolment(e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeEnrolment(raw)
|
|
e.V = Version
|
|
if err != nil || !reflect.DeepEqual(back, e) {
|
|
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
|
}
|
|
}
|
|
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
|
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
|
t.Fatal(err)
|
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
|
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
|
}
|
|
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
|
t.Fatal(err)
|
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
|
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
|
}
|
|
for name, raw := range map[string]string{
|
|
"no node": `{"v":1}`,
|
|
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
|
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
|
} {
|
|
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
|
t.Errorf("an address with %s: taken", name)
|
|
}
|
|
}
|
|
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
|
t.Errorf("the issued event's id: %q", id)
|
|
}
|
|
for name, e := range map[string]Enrolment{
|
|
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
|
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
|
Expires: issued.Add(time.Hour)},
|
|
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
|
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
|
Expires: issued.Add(time.Hour)},
|
|
} {
|
|
if _, err := EncodeEnrolment(e); err == nil {
|
|
t.Errorf("enrolment with %s: taken", name)
|
|
}
|
|
}
|
|
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
|
t.Errorf("address key: %q %v", key, ok)
|
|
}
|
|
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
|
t.Errorf("node key: %q %v", node, ok)
|
|
}
|
|
for _, life := range []string{"", "1h", "24h"} {
|
|
if _, err := TokenLife(life); err != nil {
|
|
t.Errorf("a life of %q: %v", life, err)
|
|
}
|
|
}
|
|
for _, life := range []string{"25h", "-1h", "a day"} {
|
|
if _, err := TokenLife(life); err == nil {
|
|
t.Errorf("a life of %q: taken", life)
|
|
}
|
|
}
|
|
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
|
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
|
for _, l := range []NodeLogin{
|
|
{Node: "g14", Login: "node.g14", Since: issued},
|
|
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
|
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
|
} {
|
|
raw, err := EncodeNodeLogin(l)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeNodeLogin(raw)
|
|
l.V = Version
|
|
if err != nil || !reflect.DeepEqual(back, l) {
|
|
t.Errorf("node login round trip: %+v, %v", back, err)
|
|
}
|
|
}
|
|
// The hand acts that warranted its rotations, as the vault records them.
|
|
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
|
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
|
t.Errorf("a login with its acts: %s, %v", raw, err)
|
|
}
|
|
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
|
t.Errorf("an empty rotating: %+v, %v", l, err)
|
|
}
|
|
for name, l := range map[string]NodeLogin{
|
|
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
|
"another node's login": {Node: "g14", Login: "node.ace"},
|
|
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
|
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
|
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
|
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
|
} {
|
|
if _, err := EncodeNodeLogin(l); err == nil {
|
|
t.Errorf("node login with %s: taken", name)
|
|
}
|
|
}
|
|
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
|
t.Errorf("the second login's node: %q %v", node, ok)
|
|
}
|
|
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
|
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
|
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
|
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
|
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
|
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
|
t.Errorf("an expiry: %v", err)
|
|
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
|
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
|
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
|
}
|
|
for name, raw := range map[string]string{
|
|
"no issued event": `{"v":1,"node":"lab"}`,
|
|
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
|
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
|
} {
|
|
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
|
t.Errorf("an expiry with %s: taken", name)
|
|
}
|
|
}
|
|
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
|
walk(reflect.TypeOf(Placement{}), "Placement")
|
|
}
|