From ce9d39d3f0d631a5ae3eb2359fdcb62fd2ad92d4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:51:46 +0100 Subject: [PATCH] =?UTF-8?q?node-engine=20client:=20the=20codec,=20the=20di?= =?UTF-8?q?gest=20and=20the=20comparison=20of=20a=20composition,=20in=20th?= =?UTF-8?q?e=20field=20order=20its=20first=20writer=20used=20(ADR=200634?= =?UTF-8?q?=20=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/clients/nodeengineclient/composed.go | 323 ++++++++++++++++++ go/clients/nodeengineclient/composed_test.go | 130 +++++++ .../testdata/captured-ace.json | 63 ++++ 3 files changed, 516 insertions(+) create mode 100644 go/clients/nodeengineclient/composed.go create mode 100644 go/clients/nodeengineclient/composed_test.go create mode 100644 go/clients/nodeengineclient/testdata/captured-ace.json diff --git a/go/clients/nodeengineclient/composed.go b/go/clients/nodeengineclient/composed.go new file mode 100644 index 0000000..b8f5356 --- /dev/null +++ b/go/clients/nodeengineclient/composed.go @@ -0,0 +1,323 @@ +package nodeengineclient + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The compositions (novox/hq ADR 0539 clauses 1 to 4), the node-engine's state composed: its layout is the seat's contract +// (seat.ComposedByNodeModuleFingerprintValue, seat.ComposedByNodeNodeValue); this file is the codec and the digest the +// engine, the assigner, the grants part and the provisioner share. A composition's digest is a function of its content +// alone, so the encoding keeps the field order its first writer used, which the contract's generated type (fields sorted) +// does not: the wire structs below hold it. + +// SourcePrefix is the start of a fingerprint of the new form, `src2:` and 64 lowercase hex digits. +const SourcePrefix = "src2:" + +// FingerprintKeyPart is a new-form fingerprint as the key part of ComposedKey: its 64 hex digits without the prefix. +func FingerprintKeyPart(fp string) (string, error) { + part, ok := strings.CutPrefix(fp, SourcePrefix) + if !ok || !keys.IsHex64(part) { + return "", fmt.Errorf("node-engine: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, SourcePrefix) + } + return part, nil +} + +// Composition is one module's resources composed for one node at one fingerprint, or, with Module and FP empty, the node's +// own resources and parts (Own). Each resource is a declaration resource, kept as JSON and written canonically (keys +// sorted, spacing dropped). Plan is the module's whole provisioning plan, kept as JSON too: part of the encoding, not of +// the digest. Refused says the module cannot be composed at this fingerprint; such a composition holds nothing else. +type Composition struct { + V int + Node string + Module string + FP string + Data bool + Resources []json.RawMessage + Filled []string + Own seat.NodeOwn + Plan json.RawMessage + Refused string +} + +type adoptionWire struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +type identityWire struct { + Module string `json:"module"` + Name string `json:"name"` + UID int64 `json:"uid"` + GID int64 `json:"gid"` + Groups []int64 `json:"groups,omitempty"` + Runner string `json:"runner,omitempty"` +} + +type groupWire struct { + Name string `json:"name"` + GID int64 `json:"gid"` +} + +type libraryWire struct { + Library string `json:"library"` + Root string `json:"root"` + Group string `json:"group"` + GID int64 `json:"gid"` + Home bool `json:"home"` + Taken bool `json:"taken,omitempty"` + Paths []string `json:"paths,omitempty"` + Runner string `json:"runner,omitempty"` +} + +type ownWire struct { + Adoption *adoptionWire `json:"adoption,omitempty"` + Identities []identityWire `json:"identities,omitempty"` + Groups []groupWire `json:"groups,omitempty"` + Libraries []libraryWire `json:"libraries,omitempty"` + Provisioner string `json:"provisioner_account,omitempty"` +} + +func ownIsEmpty(o seat.NodeOwn) bool { + return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.ProvisionerAccount == "" +} + +func ownToWire(o seat.NodeOwn) *ownWire { + w := ownWire{Provisioner: o.ProvisionerAccount} + if o.Adoption != nil { + w.Adoption = &adoptionWire{Taken: o.Adoption.Taken, Untaken: o.Adoption.Untaken} + } + for _, i := range o.Identities { + w.Identities = append(w.Identities, identityWire{Module: i.Module, Name: i.Name, UID: i.Uid, GID: i.Gid, Groups: i.Groups, Runner: i.Runner}) + } + for _, g := range o.Groups { + w.Groups = append(w.Groups, groupWire{Name: g.Name, GID: g.Gid}) + } + for _, l := range o.Libraries { + w.Libraries = append(w.Libraries, libraryWire{Library: l.Library, Root: l.Root, Group: l.Group, GID: l.Gid, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner}) + } + return &w +} + +func ownFromWire(w ownWire) seat.NodeOwn { + o := seat.NodeOwn{ProvisionerAccount: w.Provisioner} + if w.Adoption != nil { + o.Adoption = &seat.Adoption{Taken: w.Adoption.Taken, Untaken: w.Adoption.Untaken} + } + for _, i := range w.Identities { + o.Identities = append(o.Identities, seat.DeclaredIdentity{Module: i.Module, Name: i.Name, Uid: i.UID, Gid: i.GID, Groups: i.Groups, Runner: i.Runner}) + } + for _, g := range w.Groups { + o.Groups = append(o.Groups, seat.DeclaredGroup{Name: g.Name, Gid: g.GID}) + } + for _, l := range w.Libraries { + o.Libraries = append(o.Libraries, seat.DeclaredLibrary{Library: l.Library, Root: l.Root, Group: l.Group, Gid: l.GID, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner}) + } + return o +} + +// composedWire is a composition as it is written. +type composedWire struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module,omitempty"` + FP string `json:"fp,omitempty"` + Data bool `json:"data,omitempty"` + Resources []json.RawMessage `json:"resources"` + Filled []string `json:"filled_files,omitempty"` + Own *ownWire `json:"own,omitempty"` + Plan json.RawMessage `json:"plan,omitempty"` + Refused string `json:"refused,omitempty"` + // Signature is read and dropped: a composition is unsigned (ADR 0593 ยง6), and a value the ended controller signed + // still decodes. Nothing writes it. + Signature json.RawMessage `json:"signature,omitempty"` +} + +// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written. +func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + return nil, fmt.Errorf("more after the value") + } + return json.Marshal(v) +} + +func (c Composition) check() error { + if err := keys.CheckName("node", c.Node); err != nil { + return err + } + if c.Module == "" { + if c.FP != "" { + return fmt.Errorf("node-engine: the node's own composition names no fingerprint, and this one names %s", c.FP) + } + if c.Data { + return fmt.Errorf("node-engine: the node's own composition holds no module's data") + } + if len(c.Plan) > 0 || c.Refused != "" { + return fmt.Errorf("node-engine: the node's own composition carries no plan and no refusal: only a module's does") + } + } else { + part, err := FingerprintKeyPart(c.FP) + if err != nil { + return err + } + if _, err := ComposedKey(c.Node, c.Module, part); err != nil { + return err + } + if !ownIsEmpty(c.Own) { + return fmt.Errorf("node-engine: the composition of %s carries the node's own parts, which only .node carries", c.Module) + } + } + if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || len(c.Plan) > 0) { + return fmt.Errorf("node-engine: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node) + } + seen := map[string]bool{} + files := map[string][]*declaration.File{} + for _, raw := range c.Resources { + r, err := declaration.ParseResource(raw) + if err != nil { + return fmt.Errorf("node-engine: the composition of %q on %s: %w", c.Module, c.Node, err) + } + if seen[r.Identity()] { + return fmt.Errorf("node-engine: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity()) + } + seen[r.Identity()] = true + if r.DeclaredModule() != c.Module { + return fmt.Errorf("node-engine: the resource %q is of %q, and this is the composition of %q on %s", + r.Identity(), r.DeclaredModule(), c.Module, c.Node) + } + if f, ok := r.(*declaration.File); ok { + if f.TravelsByDigest() { + return fmt.Errorf("node-engine: the file %q is over %d bytes and travels by digest, not whole", + f.ID, declaration.FileReferenceThreshold) + } + files[f.Path] = append(files[f.Path], f) + } + } + // A file the provisioner fills says no content, so a composition that does not say it is filled would have the + // engine write it empty (issue 1945): refused, naming it. + filledHere := make(map[string]bool, len(c.Filled)) + for _, path := range c.Filled { + filledHere[path] = true + } + for path, fs := range files { + for _, f := range fs { + if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) { + return fmt.Errorf("node-engine: the file %q says no content and the composition of %q on %s does not say "+ + "the provisioner fills %s: the engine would write it empty", f.ID, c.Module, c.Node, path) + } + } + } + for _, path := range c.Filled { + if len(files[path]) == 0 { + return fmt.Errorf("node-engine: the composition of %q fills %q, and declares no file there", c.Module, path) + } + for _, f := range files[path] { + if f.OwnerAtRuntime == "" { + return fmt.Errorf("node-engine: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID) + } + } + } + return nil +} + +func saysContent(f *declaration.File) bool { + return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil +} + +func (c Composition) wire() (composedWire, error) { + if err := c.check(); err != nil { + return composedWire{}, err + } + w := composedWire{V: keys.Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, + Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))} + for i, r := range c.Resources { + canon, err := canonicalJSON(r) + if err != nil { + return composedWire{}, fmt.Errorf("node-engine: a resource of %q is not JSON: %w", c.Module, err) + } + w.Resources[i] = canon + } + if len(c.Plan) > 0 { + canon, err := canonicalJSON(c.Plan) + if err != nil { + return composedWire{}, fmt.Errorf("node-engine: the plan of %q is not JSON: %w", c.Module, err) + } + w.Plan = canon + } + if !ownIsEmpty(c.Own) { + w.Own = ownToWire(c.Own) + } + return w, nil +} + +// EncodeComposition is c as JSON at the version, each resource canonical. It refuses a composition whose node or +// fingerprint a key refuses, a resource its kind refuses, a resource of another module, a module's composition carrying +// the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares. +func EncodeComposition(c Composition) ([]byte, error) { + w, err := c.wire() + if err != nil { + return nil, err + } + return json.Marshal(w) +} + +// DecodeComposition reads a composition; it refuses an unknown version (keys.ErrUnknownVersion), a field it does not +// know, and what EncodeComposition refuses. +func DecodeComposition(data []byte) (Composition, error) { + if err := keys.CheckVersion(data); err != nil { + return Composition{}, err + } + var w composedWire + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&w); err != nil { + return Composition{}, fmt.Errorf("node-engine: a composition does not fit its shape: %w", err) + } + c := Composition{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, + Filled: w.Filled, Plan: w.Plan, Refused: w.Refused} + if w.Own != nil { + c.Own = ownFromWire(*w.Own) + } + if err := c.check(); err != nil { + return Composition{}, err + } + return c, nil +} + +// ComposedDigest is a composition's digest, "sha256:", of its encoding without its plan. A want names it (ADR 0539 +// clause 1), and the node's engine writes it beside the composition. A refused composition has no digest. +func ComposedDigest(c Composition) (string, error) { + if c.Refused != "" { + return "", fmt.Errorf("node-engine: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) + } + c.Plan = nil + raw, err := EncodeComposition(c) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// SameComposition says whether two compositions are the same value, their plans included, so the engine writes only +// when they differ. +func SameComposition(a, b Composition) bool { + ra, errA := EncodeComposition(a) + rb, errB := EncodeComposition(b) + return errA == nil && errB == nil && bytes.Equal(ra, rb) +} diff --git a/go/clients/nodeengineclient/composed_test.go b/go/clients/nodeengineclient/composed_test.go new file mode 100644 index 0000000..725b824 --- /dev/null +++ b/go/clients/nodeengineclient/composed_test.go @@ -0,0 +1,130 @@ +package nodeengineclient + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" +) + +// captured is resources of ace's declaration of 2026-10-24 (the controller's `plan` verb answered it; testdata/captured-ace.json +// keeps three modules of it, never typed by hand), composed here by module under a stand-in fingerprint. +func captured(t *testing.T) (account string, byModule map[string][]json.RawMessage) { + t.Helper() + raw, err := os.ReadFile("testdata/captured-ace.json") + if err != nil { + t.Fatal(err) + } + var c struct { + Account string `json:"provisioner_account"` + Resources []json.RawMessage `json:"resources"` + } + if err := json.Unmarshal(raw, &c); err != nil { + t.Fatal(err) + } + byModule = map[string][]json.RawMessage{} + for _, r := range c.Resources { + var h struct { + Module string `json:"module"` + } + _ = json.Unmarshal(r, &h) + byModule[h.Module] = append(byModule[h.Module], r) + } + return c.Account, byModule +} + +func fp(n int) string { return SourcePrefix + fmt.Sprintf("%064x", n) } + +// TestCompositionDigests: a composition's digest is the one the first writer computed (the values are the digests +// mesh-sdk's delivery package gave for the same compositions when it moved here), whatever order the contract's generated +// types sort their fields in, and the plan is no part of it. +func TestCompositionDigests(t *testing.T) { + account, by := captured(t) + own := seat.NodeOwn{ + Adoption: &seat.Adoption{Taken: []string{"avahi"}, Untaken: map[string][]string{"plex": {"plex.bundle-code"}}}, + Identities: []seat.DeclaredIdentity{{Module: "plex", Name: "plex", Uid: 1001, Gid: 1001, Groups: []int64{1002}, Runner: "plex"}}, + Groups: []seat.DeclaredGroup{{Name: "mesh-media", Gid: 1002}}, + Libraries: []seat.DeclaredLibrary{{Library: "movies", Root: "/srv/media/movies", Group: "mesh-media", Gid: 1002, Taken: true, Paths: []string{"/srv/media/movies"}}}, + ProvisionerAccount: "mesh-provisioner-client", + } + cases := []struct { + name string + c Composition + digest string + }{ + {"a node's own, the account only", Composition{Node: "ace", Own: seat.NodeOwn{ProvisionerAccount: account}}, "sha256:17fde3a215d522619c18bc80b8c236975e25b2d2980a984aebd7c18376b095a2"}, + {"a node's own, every part", Composition{Node: "ace", Own: own}, "sha256:b8ac7ace490a3c483c3ae18a9cda58e607aa6b69167fa274c062949c7b34fdbf"}, + {"one package", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}, "sha256:84e480a7a62333960b3e4be21a7548bbaa09572ef73ca8fb9dc6dfd1228aeb6b"}, + {"a service and a file", Composition{Node: "ace", Module: "mesh-wireguard", FP: fp(14), Resources: by["mesh-wireguard"]}, "sha256:d6edec2474f606503e55d4ee2e0b4f5d1ffc25d016e4bc595e86ea67fe205b8c"}, + {"a plan beside the resources", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"files":[{"path":"/etc/hostname"}]}`)}, "sha256:49e17d6c471ee65cdef745ca3ac5cea4d37b6a100e11d8434dc64a6e2949e57e"}, + } + for _, c := range cases { + got, err := ComposedDigest(c.c) + if err != nil || got != c.digest { + t.Errorf("%s: digest %s, %v; want %s", c.name, got, err, c.digest) + } + raw, err := EncodeComposition(c.c) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + back, err := DecodeComposition(raw) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if !SameComposition(c.c, back) { + t.Errorf("%s: reads back as another composition", c.name) + } + again, _ := ComposedDigest(back) + if again != c.digest { + t.Errorf("%s: read back, its digest is %s", c.name, again) + } + } + a := Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"a":1}`)} + b := a + b.Plan = json.RawMessage(`{"a":2}`) + da, _ := ComposedDigest(a) + db, _ := ComposedDigest(b) + if da != db || SameComposition(a, b) { + t.Error("a plan changes the composition's encoding and not its digest") + } +} + +// TestCompositionRefusals: what the codec refuses. +func TestCompositionRefusals(t *testing.T) { + _, by := captured(t) + ok := Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]} + cases := []struct { + name string + c Composition + want string + }{ + {"an old-form fingerprint", Composition{Node: "ace", Module: "buildah", FP: "src1:abc", Resources: ok.Resources}, "new form"}, + {"a resource of another module", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: ok.Resources}, "is of"}, + {"the node's own parts on a module", Composition{Node: "ace", Module: "buildah", FP: fp(7), Own: seat.NodeOwn{ProvisionerAccount: "x"}}, "node's own parts"}, + {"a refusal that holds resources", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: ok.Resources, Refused: "no secret"}, "refused and holds"}, + {"the node's own with a fingerprint", Composition{Node: "ace", FP: fp(7)}, "names no fingerprint"}, + {"a node name with a dot", Composition{Node: "a.ce"}, "node"}, + } + for _, c := range cases { + if _, err := EncodeComposition(c.c); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want an error saying %q", c.name, err, c.want) + } + } + if _, err := ComposedDigest(Composition{Node: "ace", Module: "buildah", FP: fp(7), Refused: "no secret"}); err == nil { + t.Error("a refused composition has a digest") + } + if _, err := DecodeComposition([]byte(`{"v":2,"node":"ace","resources":[]}`)); err == nil { + t.Error("a composition of version 2 is read") + } + if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"extra":1}`)); err == nil { + t.Error("a composition with a field it does not know is read") + } + if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"signature":"x"}`)); err != nil { + t.Errorf("a signed composition of the ended controller is refused: %v", err) + } +} diff --git a/go/clients/nodeengineclient/testdata/captured-ace.json b/go/clients/nodeengineclient/testdata/captured-ace.json new file mode 100644 index 0000000..d727d90 --- /dev/null +++ b/go/clients/nodeengineclient/testdata/captured-ace.json @@ -0,0 +1,63 @@ +{ + "provisioner_account": "mesh-provisioner-client", + "resources": [ + { + "id": "buildah.buildah", + "module": "buildah", + "package": "buildah", + "type": "package" + }, + { + "id": "mesh-wireguard.overlay-tools", + "module": "mesh-wireguard", + "package": "wireguard-tools", + "type": "package" + }, + { + "content": "# Generated by the mesh. Do not edit \u2014 this file is replaced whenever the\n# peer graph changes, and an edit would survive until the next change and\n# then vanish, which is worse than not being applied at all.\n#\n# node ace, at home, not dialable \u2014 it opens every path itself\n\n[Interface]\nAddress = 10.10.0.2/32\nPostUp = wg set %i private-key /var/lib/mesh-host/overlay.key\n\n# novox \u2014 the hub \u2014 everything not at this site\n[Peer]\nPublicKey = xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY=\nAllowedIPs = 10.10.0.0/24\nEndpoint = 142.132.152.141:51820\nPersistentKeepalive = 25\n", + "id": "mesh-wireguard.overlay-config", + "mode": "0600", + "module": "mesh-wireguard", + "path": "/etc/wireguard/mesh0.conf", + "type": "file" + }, + { + "boot": "enabled", + "id": "mesh-wireguard.overlay-up", + "module": "mesh-wireguard", + "restart-on": [ + "mesh-wireguard.overlay-config" + ], + "state": "running", + "type": "service", + "unit": "wg-quick@mesh0" + }, + { + "digest": "sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda", + "id": "hostname.bundle-tools", + "module": "hostname", + "path": "/var/lib/mesh/bundles/hostname/tools", + "source": "http://novox.internal:5100/v2/hostname/tools/blobs/sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda", + "type": "archive" + }, + { + "at": "start", + "content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\tace\n", + "id": "hostname.own", + "into": "block", + "mode": "0644", + "module": "hostname", + "path": "/etc/hosts", + "type": "file" + }, + { + "content": "Ace\n", + "id": "hostname.name", + "mode": "0644", + "module": "hostname", + "path": "/etc/hostname", + "type": "file" + } + ], + "filled_files": [] +} \ No newline at end of file