Give a composition its value, its canonical digest and its files by digest; key a dotted module escaped (issue 1943, S2; issue 1150)

A node applies one module's composition at a time, so each carries only its own module's resources, checked
resource by resource as the node-engine reads them, and its digest follows its content alone (ADR 0539 clauses 1
and 4). The fixtures are the controller's plan answers for ace and g14, their sealed resources taken out.
This commit is contained in:
2026-10-24 16:26:32 +02:00
parent aee971c198
commit 826fd73cf2
6 changed files with 703 additions and 7 deletions
+47
View File
@@ -0,0 +1,47 @@
package declaration
import (
"encoding/json"
"fmt"
)
// ParseResource reads one resource as Parse reads each of a declaration's resources: by its type, strictly, refusing
// a field its kind does not use and anything its kind refuses, and never an action (the link may not carry one, ADR
// 0005). It judges the resource alone: what Parse judges across resources (an opening on a node not adopted, the
// files the provisioner fills, what replaces what) is for the whole a node applies (novox/hq ADR 0539 clause 1: a
// composition is one module's resources, and a node applies its compositions together with its own key).
func ParseResource(raw json.RawMessage) (Resource, error) {
var head struct {
ID string `json:"id"`
Type Type `json:"type"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil, &RefusalError{Problems: []string{"not a resource: " + err.Error()}}
}
where := "resource"
if head.ID == "" {
return nil, &RefusalError{Problems: []string{where + ": no id. Identity is what lets the host know this is " +
"the same resource it applied last time"}}
}
where = fmt.Sprintf("resource %q", head.ID)
resource := newOf(head.Type)
if resource == nil {
return nil, &RefusalError{Problems: []string{fmt.Sprintf("%s: unknown type %q. This host understands %s",
where, head.Type, vocabulary())}}
}
if unknown := unknownFields(raw, resource); len(unknown) > 0 {
var problems []string
for _, field := range unknown {
problems = append(problems, fmt.Sprintf("%s: a %s does not use %q, and it is set. Refused rather "+
"than ignored", where, head.Type, field))
}
return nil, &RefusalError{Problems: problems}
}
if err := json.Unmarshal(raw, resource); err != nil {
return nil, &RefusalError{Problems: []string{fmt.Sprintf("%s: %s", where, err)}}
}
if problems := resource.validate(where, false); len(problems) > 0 {
return nil, &RefusalError{Problems: problems}
}
return resource, nil
}
+298
View File
@@ -0,0 +1,298 @@
package delivery
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// NodeOwn is what a node's own composition (`<node>.node`) carries beside its resources: what the declaration says of
// the node as a whole (novox/hq ADR 0539 clause 2: adoption, accounts, identities and groups). A module's composition
// carries none of it.
type NodeOwn struct {
// Adoption says the node is adopted and which of its modules are taken (ADR 0100, ADR 0103); nil for a converged node.
Adoption *declaration.Adoption `json:"adoption,omitempty"`
// Identities is the identity of every module assigned to the node (ADR 0328 §4), Groups and Libraries every library
// it is reached by (ADR 0328 §3).
Identities []declaration.Identity `json:"identities,omitempty"`
Groups []declaration.Group `json:"groups,omitempty"`
Libraries []declaration.Library `json:"libraries,omitempty"`
// Provisioner is the account the provisioner's client runs as on the node (ADR 0349 §4).
Provisioner string `json:"provisioner_account,omitempty"`
}
func (o NodeOwn) empty() bool {
return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.Provisioner == ""
}
// Composed is a composition (ADR 0539 clause 1): 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 as the
// node-engine reads it, saying its module; it is kept as JSON and written canonically (its keys sorted, its spacing
// dropped), so its digest (ComposedDigestOf) is a function of its content alone. Data says the module declares data,
// so the node agent backs it up before it moves (ADR 0429 §14). Filled names the files of the module the provisioner
// fills (ADR 0349 §5). A file over 64 KiB travels by digest and size (ByDigest), its content in the node's object
// bucket (ADR 0539 clause 4).
type Composed struct {
V int
Node string
Module string
FP string
Data bool
Resources []json.RawMessage
Filled []string
Own NodeOwn
}
// 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 *NodeOwn `json:"own,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 Composed) check() error {
if _, err := HaveNodeKey(c.Node); err != nil {
return err
}
if c.Module == "" {
if c.FP != "" {
return fmt.Errorf("delivery: the node's own composition names no fingerprint, and this one names %s", c.FP)
}
if c.Data || len(c.Filled) > 0 {
return fmt.Errorf("delivery: the node's own composition holds no module's data or filled files")
}
} else {
if _, err := ComposedKey(c.Node, c.Module, c.FP); err != nil {
return err
}
if !c.Own.empty() {
return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only <node>.node carries", c.Module)
}
}
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("delivery: the composition of %q on %s: %w", c.Module, c.Node, err)
}
if seen[r.Identity()] {
return fmt.Errorf("delivery: 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("delivery: 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("delivery: the file %q is over %d bytes and travels by digest (ByDigest), not whole",
f.ID, declaration.FileReferenceThreshold)
}
files[f.Path] = append(files[f.Path], f)
}
}
for _, path := range c.Filled {
if len(files[path]) == 0 {
return fmt.Errorf("delivery: 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("delivery: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID)
}
}
}
return nil
}
// wire is c checked and made canonical, ready to marshal.
func (c Composed) wire() (composedWire, error) {
if err := c.check(); err != nil {
return composedWire{}, err
}
w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
Resources: make([]json.RawMessage, len(c.Resources))}
for i, r := range c.Resources {
canon, err := canonicalJSON(r)
if err != nil {
return composedWire{}, fmt.Errorf("delivery: a resource of %q is not JSON: %w", c.Module, err)
}
w.Resources[i] = canon
}
if !c.Own.empty() {
own := c.Own
w.Own = &own
}
return w, nil
}
// EncodeComposed is c as JSON at Version, each resource canonical. It refuses a composition whose node or fingerprint
// a key refuses, a resource its kind refuses (an action among them), 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 EncodeComposed(c Composed) ([]byte, error) {
w, err := c.wire()
if err != nil {
return nil, err
}
return json.Marshal(w)
}
// DecodeComposed reads a composition; it refuses an unknown version (ErrUnknownVersion), a field it does not know, and
// what EncodeComposed refuses.
func DecodeComposed(data []byte) (Composed, error) {
if err := checkVersion(data); err != nil {
return Composed{}, err
}
var w composedWire
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(&w); err != nil {
return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err)
}
c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
Filled: w.Filled}
if w.Own != nil {
c.Own = *w.Own
}
if err := c.check(); err != nil {
return Composed{}, err
}
return c, nil
}
// ComposedDigestOf is a composition's digest, "sha256:<hex>", of its encoding. A want names it (ADR 0539 clause 1),
// and the controller writes it beside the composition.
func ComposedDigestOf(c Composed) (string, error) {
raw, err := EncodeComposed(c)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// SameComposed says whether two compositions are the same value, so the controller writes
// only when they differ.
func SameComposed(a, b Composed) bool {
ra, errA := EncodeComposed(a)
rb, errB := EncodeComposed(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
// ByDigest is c with every file over 64 KiB given by its digest and size, and the content of each by digest, for the
// writer to put in the node's object bucket (declaration.FileBucketFor) under declaration.ObjectName before it writes
// the composition (ADR 0539 clause 4, ADR 0488 §3). A file that is sealed, filled with secrets or written into another
// stays as it is (declaration.File.TravelsByDigest).
func (c Composed) ByDigest() (Composed, map[string][]byte, error) {
out := c
out.Resources = make([]json.RawMessage, len(c.Resources))
objects := map[string][]byte{}
for i, raw := range c.Resources {
out.Resources[i] = raw
r, err := declaration.ParseResource(raw)
if err != nil {
return Composed{}, nil, err
}
f, ok := r.(*declaration.File)
if !ok || !f.TravelsByDigest() {
continue
}
ref, body, err := f.AsReference()
if err != nil {
return Composed{}, nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return Composed{}, nil, err
}
delete(fields, "content")
delete(fields, "bytes")
fields["digest"], _ = json.Marshal(ref.Digest)
fields["size"], _ = json.Marshal(ref.Size)
rewritten, err := json.Marshal(fields)
if err != nil {
return Composed{}, nil, err
}
out.Resources[i] = rewritten
objects[ref.Digest] = body
}
return out, objects, nil
}
// SplitComposed splits a node's composed resources into its compositions, by key (ComposedKey, ComposedNodeKey): each
// resource into the composition of the module it says, under that module's fingerprint in fps, and a resource that
// says no module into the node's own, with own. Each filled path goes with the module whose file declares it. The order
// of the resources is kept within each composition. A resource of a module fps names no fingerprint for is refused,
// naming it. Data is the caller's to set, from the module's manifest.
func SplitComposed(node string, resources []json.RawMessage, fps map[string]string, filled []string, own NodeOwn) (map[string]Composed, error) {
nodeKey, err := ComposedNodeKey(node)
if err != nil {
return nil, err
}
out := map[string]Composed{nodeKey: {Node: node, Own: own, Resources: []json.RawMessage{}}}
pathOf := map[string]string{}
for _, raw := range resources {
var head struct {
ID string `json:"id"`
Type string `json:"type"`
Module string `json:"module"`
Path string `json:"path"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil, fmt.Errorf("delivery: a resource of %s is not JSON: %w", node, err)
}
key := nodeKey
if head.Module != "" {
fp, ok := fps[head.Module]
if !ok {
return nil, fmt.Errorf("delivery: the resource %q is of %q, whose fingerprint on %s is not given", head.ID, head.Module, node)
}
if key, err = ComposedKey(node, head.Module, fp); err != nil {
return nil, err
}
if _, ok := out[key]; !ok {
out[key] = Composed{Node: node, Module: head.Module, FP: fp}
}
}
if head.Type == string(declaration.TypeFile) && head.Module != "" {
pathOf[head.Path] = key
}
c := out[key]
c.Resources = append(c.Resources, raw)
out[key] = c
}
for _, path := range filled {
key, ok := pathOf[path]
if !ok {
return nil, fmt.Errorf("delivery: the provisioner fills %q on %s, and no module's file declares it", path, node)
}
c := out[key]
c.Filled = append(c.Filled, path)
out[key] = c
}
return out, nil
}
+317
View File
@@ -0,0 +1,317 @@
package delivery
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// capturedDeclaration is a node's declaration as the controller's `plan` verb answered it on 2026-10-24 at 16:19
// (testdata/declaration-<node>.json.gz; the resources carrying a sealed value or a secret taken out, and the filled
// files that named them), never typed by hand.
type capturedDeclaration struct {
Builds map[string]string `json:"builds"`
Resources []json.RawMessage `json:"resources"`
Filled []string `json:"filled_files"`
Provisioner string `json:"provisioner_account"`
}
func readCaptured(t *testing.T, node string) capturedDeclaration {
t.Helper()
f, err := os.Open("testdata/declaration-" + node + ".json.gz")
if err != nil {
t.Fatal(err)
}
defer f.Close()
z, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
raw, err := io.ReadAll(z)
if err != nil {
t.Fatal(err)
}
var c capturedDeclaration
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
return c
}
// fpOfBuild stands for each module's fingerprint in a captured declaration, which names builds, not fingerprints.
func fpOfBuild(build string) string {
var b strings.Builder
for b.Len() < 64 {
b.WriteString(fmt.Sprintf("%x", []byte(build)))
}
return SourcePrefix2 + b.String()[:64]
}
func splitCaptured(t *testing.T, node string, c capturedDeclaration) map[string]Composed {
t.Helper()
fps := map[string]string{}
for m, b := range c.Builds {
fps[m] = fpOfBuild(b)
}
// mesh-wireguard's resources are composed with no build of it named; its fingerprint is the indexer's, as every
// module's is, and stood in for here by its name.
for _, r := range c.Resources {
var head struct {
Module string `json:"module"`
}
_ = json.Unmarshal(r, &head)
if _, ok := fps[head.Module]; head.Module != "" && !ok {
fps[head.Module] = fpOfBuild(head.Module)
}
}
split, err := SplitComposed(node, c.Resources, fps, c.Filled, NodeOwn{Provisioner: c.Provisioner})
if err != nil {
t.Fatalf("SplitComposed(%s): %v", node, err)
}
return split
}
func canonical(t *testing.T, raw json.RawMessage) string {
t.Helper()
var v any
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
if err := d.Decode(&v); err != nil {
t.Fatal(err)
}
out, _ := json.Marshal(v)
return string(out)
}
// 1943/AC2: each module's slice of a captured declaration of each node, and the node's own resources, round-trip
// through the composition's encoding with the same digest; together they are the declaration's resources, each once,
// each in the composition of the module it says.
func TestCompositionsOfACapturedDeclaration(t *testing.T) {
for _, node := range []string{"ace", "g14"} {
c := readCaptured(t, node)
split := splitCaptured(t, node, c)
modules := map[string]bool{}
for m := range c.Builds {
modules[m] = true
}
var union []string
for key, comp := range split {
n, module, fp, isNode, err := ParseComposedKey(key)
if err != nil || n != node || comp.Node != node || comp.Module != module || comp.FP != fp {
t.Fatalf("%s: key %q holds %s/%s/%s (%v)", node, key, comp.Node, comp.Module, comp.FP, err)
}
if !isNode && !modules[module] && module != "mesh-wireguard" {
t.Errorf("%s: a composition of %s, which the declaration names no build of", node, module)
}
raw, err := EncodeComposed(comp)
if err != nil {
t.Fatalf("%s: EncodeComposed(%s): %v", node, key, err)
}
back, err := DecodeComposed(raw)
if err != nil {
t.Fatalf("%s: DecodeComposed(%s): %v", node, key, err)
}
if !SameComposed(back, comp) {
t.Errorf("%s: %s did not round-trip", node, key)
}
d1, err1 := ComposedDigestOf(comp)
d2, err2 := ComposedDigestOf(back)
if err1 != nil || err2 != nil || d1 != d2 || !digestForm.MatchString(d1) {
t.Errorf("%s: %s digests %q and %q (%v, %v)", node, key, d1, d2, err1, err2)
}
again, _ := EncodeComposed(back)
if !bytes.Equal(raw, again) {
t.Errorf("%s: %s encodes differently once read back", node, key)
}
for _, r := range back.Resources {
var head struct {
Module string `json:"module"`
}
_ = json.Unmarshal(r, &head)
if head.Module != module {
t.Errorf("%s: %s holds a resource of %q", node, key, head.Module)
}
union = append(union, canonical(t, r))
}
}
var want []string
for _, r := range c.Resources {
want = append(want, canonical(t, r))
}
sort.Strings(union)
sort.Strings(want)
if strings.Join(union, "\n") != strings.Join(want, "\n") {
t.Errorf("%s: the compositions hold %d resources, the declaration %d, or they differ", node, len(union), len(want))
}
}
}
// 1943/AC2: one changed byte of a resource is another digest; the same composition written with its keys in another
// order is the same digest.
func TestComposedDigestFollowsTheContent(t *testing.T) {
c := readCaptured(t, "g14")
split := splitCaptured(t, "g14", c)
var comp Composed
for _, v := range split {
if v.Module != "" && len(v.Resources) > 1 {
comp = v
break
}
}
d1, _ := ComposedDigestOf(comp)
var m map[string]json.RawMessage
_ = json.Unmarshal(comp.Resources[0], &m)
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
var parts []string
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%q : %s", k, m[k]))
}
reordered := json.RawMessage("{ " + strings.Join(parts, ",\n ") + " }")
shuffled := comp
shuffled.Resources = append([]json.RawMessage{reordered}, comp.Resources[1:]...)
if d2, _ := ComposedDigestOf(shuffled); d2 != d1 {
t.Errorf("the same resources in another order and spacing digest %s and %s", d1, d2)
}
changed := comp
changed.Resources = append([]json.RawMessage{json.RawMessage(strings.Replace(canonical(t, comp.Resources[0]), `"id":"`, `"id":"x`, 1))}, comp.Resources[1:]...)
if d3, _ := ComposedDigestOf(changed); d3 == d1 {
t.Errorf("a changed resource kept its digest")
}
revised := comp
revised.Data = !comp.Data
if d4, _ := ComposedDigestOf(revised); d4 == d1 {
t.Errorf("a changed data flag kept its digest")
}
}
// 1943/AC2: a file over 64 KiB is carried by digest and size, its content handed back to be put in the node's object
// bucket; a composition that carries such a file whole is refused.
func TestAComposedFileOver64KiBIsCarriedByDigest(t *testing.T) {
c := readCaptured(t, "g14")
split := splitCaptured(t, "g14", c)
var comp Composed
var at int
for _, v := range split {
for i, r := range v.Resources {
var f map[string]any
_ = json.Unmarshal(r, &f)
if f["type"] == "file" && v.Module != "" && f["bytes"] == nil && f["into"] == nil {
comp, at = v, i
break
}
}
if comp.Module != "" {
break
}
}
var f map[string]any
_ = json.Unmarshal(comp.Resources[at], &f)
// Grown past the threshold from a captured file: no captured file of this node is over 64 KiB.
body := strings.Repeat("x", declaration.FileReferenceThreshold+1)
f["content"] = body
big, _ := json.Marshal(f)
comp.Resources = append([]json.RawMessage{}, comp.Resources...)
comp.Resources[at] = big
if _, err := EncodeComposed(comp); err == nil || !strings.Contains(err.Error(), "by digest") {
t.Fatalf("a file over 64 KiB carried whole = %v, want refused", err)
}
byDigest, objects, err := comp.ByDigest()
if err != nil {
t.Fatal(err)
}
var ref map[string]any
_ = json.Unmarshal(byDigest.Resources[at], &ref)
digest := declaration.DigestOf([]byte(body))
if ref["digest"] != digest || ref["size"] != float64(len(body)) || ref["content"] != nil {
t.Errorf("the file became %v, want digest %s and size %d", ref, digest, len(body))
}
if string(objects[digest]) != body || len(objects) != 1 {
t.Errorf("the objects handed back are %d, want the file's content under its digest", len(objects))
}
if _, err := EncodeComposed(byDigest); err != nil {
t.Errorf("the composition by digest is refused: %v", err)
}
}
// 1943/AC2: an unknown version is refused as one; a module's composition carrying the node's own parts, a resource of
// another module, a resource its kind refuses, or no fingerprint, is refused, naming why.
func TestComposedRefusals(t *testing.T) {
fp := fp2of('a')
file := json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x"}`)
ok := Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}}
raw, err := EncodeComposed(ok)
if err != nil {
t.Fatal(err)
}
if _, err := DecodeComposed(bytes.Replace(raw, []byte(`"v":1`), []byte(`"v":2`), 1)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("version 2 = %v, want ErrUnknownVersion", err)
}
refusals := []struct {
c Composed
why string
}{
{Composed{Node: "ace", Module: "m", Resources: []json.RawMessage{file}}, "fingerprint"},
{Composed{Node: "", Module: "m", FP: fp, Resources: []json.RawMessage{file}}, "node name is empty"},
{Composed{Node: "ace", Module: "other", FP: fp, Resources: []json.RawMessage{file}}, `of "m"`},
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}, Own: NodeOwn{Provisioner: "p"}}, "node's own"},
{Composed{Node: "ace", FP: fp, Resources: []json.RawMessage{}}, "names no fingerprint"},
{Composed{Node: "ace", Resources: []json.RawMessage{file}}, `of "m"`},
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x","image":"y"}`)}}, `"image"`},
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.a","type":"action","module":"m"}`)}}, "action"},
}
for _, r := range refusals {
if _, err := EncodeComposed(r.c); err == nil || !strings.Contains(err.Error(), r.why) {
t.Errorf("EncodeComposed(%s/%s) = %v, want a refusal saying %q", r.c.Node, r.c.Module, err, r.why)
}
}
}
// 1943/AC2 (issue 1150): a module whose name holds a dot keys its have, its want and its composition escaped, and the
// parsers give its name back; a name with a wildcard, whitespace or an escape of its own is still refused.
func TestADottedModuleKeysEscaped(t *testing.T) {
fp := fp2of('f')
have, err := HaveKey("ace", "novox.be")
if err != nil || have != "ace.novox=2ebe" {
t.Fatalf("HaveKey = %q, %v", have, err)
}
if n, m, isNode, err := ParseHaveKey(have); err != nil || n != "ace" || m != "novox.be" || isNode {
t.Errorf("ParseHaveKey(%q) = %q, %q, %v, %v", have, n, m, isNode, err)
}
want, err := WantKey("ace", "novox.be")
if err != nil || want != "want.ace.novox=2ebe" {
t.Fatalf("WantKey = %q, %v", want, err)
}
if n, m, err := ParseWantKey(want); err != nil || n != "ace" || m != "novox.be" {
t.Errorf("ParseWantKey(%q) = %q, %q, %v", want, n, m, err)
}
comp, err := ComposedKey("ace", "novox.be", fp)
if err != nil || comp != "ace.novox=2ebe."+strings.Repeat("f", 64) {
t.Fatalf("ComposedKey = %q, %v", comp, err)
}
if n, m, back, _, err := ParseComposedKey(comp); err != nil || n != "ace" || m != "novox.be" || back != fp {
t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v", comp, n, m, back, err)
}
for _, bad := range []string{"m*", "m x", "m=2e", "m>", ".m", "m.", "a..b"} {
if _, err := HaveKey("ace", bad); err == nil {
t.Errorf("HaveKey(ace, %q) accepted", bad)
}
}
for _, bad := range []string{"ace.m=2", "ace.m=2E", "ace.m=41", "ace.=6eode"} {
if _, _, _, err := ParseHaveKey(bad); err == nil {
t.Errorf("ParseHaveKey(%q) accepted", bad)
}
}
}
+41 -7
View File
@@ -11,7 +11,8 @@
// `node`. A presence key is `<node>` for a node and `part.<part>.<instance>` for a part's instance, so a node may
// not be named `part`. A pulse key is `<bucket>.<key>`: the full name of the bucket watched, then that bucket's key,
// which may hold dots, so it splits at the first dot. A name is refused when it is empty or holds a dot, a wildcard,
// whitespace or a character a bus key refuses, and the error names it.
// whitespace or a character a bus key refuses, and the error names it; but a module's name may hold dots between its
// parts (`novox.be`), and keys with each dot escaped as EscapeName writes it (`novox=2ebe`, issue 1150).
//
// **Values** are JSON of under about 500 bytes, each carrying its version in `v`. A reader refuses a value whose
// version is missing or one it does not know, with an error that is ErrUnknownVersion (research 063 section 05
@@ -35,7 +36,7 @@
// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte.
//
// Standard library only, and this module's own asks package.
// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources).
package delivery
import (
@@ -164,13 +165,45 @@ func HaveKey(node, module string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
if err := checkName("module", module); err != nil {
part, err := moduleKeyPart(module)
if err != nil {
return "", err
}
if module == "node" {
return "", fmt.Errorf(`delivery: the module name "node" is refused: %s.node is the node's own key`, node)
}
return node + "." + module, nil
return node + "." + part, nil
}
var moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`)
// moduleKeyPart is a module's name as a key part (issue 1150): a name may hold a dot (`novox.be`), which a key part
// may not, so a dot is escaped as EscapeName writes it (`novox=2ebe`); a name of letters, digits, `-`, `/` and `_` keys
// as it is. A name that is empty, or holds a wildcard, whitespace, `=`, or an empty part between dots, is refused.
func moduleKeyPart(module string) (string, error) {
if module == "" {
return "", fmt.Errorf("delivery: the module name is empty")
}
if !moduleNameOK.MatchString(module) {
return "", fmt.Errorf("delivery: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module)
}
return EscapeName(module), nil
}
// parseModuleKeyPart is the inverse of moduleKeyPart; it refuses a part that is not the one moduleKeyPart writes for
// its name (an escape of a character that keys as it is, an upper-case escape, an escape of a refused character).
func parseModuleKeyPart(part string) (string, error) {
if err := checkName("module", part); err != nil {
return "", err
}
module, err := UnescapeName(part)
if err != nil {
return "", err
}
if back, err := moduleKeyPart(module); err != nil || back != part {
return "", fmt.Errorf("delivery: the module part %q is not a module name as a key writes it", part)
}
return module, nil
}
// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `<node>.node`.
@@ -190,12 +223,13 @@ func ParseHaveKey(key string) (node, module string, isNode bool, err error) {
if err := checkName("node", node); err != nil {
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
}
if err := checkName("module", module); err != nil {
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
}
if module == "node" {
return node, "", true, nil
}
module, err = parseModuleKeyPart(module)
if err != nil {
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
}
return node, module, false, nil
}
Binary file not shown.
Binary file not shown.