diff --git a/go/declaration/resource.go b/go/declaration/resource.go new file mode 100644 index 0000000..8733768 --- /dev/null +++ b/go/declaration/resource.go @@ -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 +} diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go new file mode 100644 index 0000000..8ec92bc --- /dev/null +++ b/go/delivery/composed_value.go @@ -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`) 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 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:", 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 +} diff --git a/go/delivery/composed_value_test.go b/go/delivery/composed_value_test.go new file mode 100644 index 0000000..238c495 --- /dev/null +++ b/go/delivery/composed_value_test.go @@ -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-.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) + } + } +} diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index e4e541d..71ccf75 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -11,7 +11,8 @@ // `node`. A presence key is `` for a node and `part..` for a part's instance, so a node may // not be named `part`. A pulse key is `.`: 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`. @@ -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 } diff --git a/go/delivery/testdata/declaration-ace.json.gz b/go/delivery/testdata/declaration-ace.json.gz new file mode 100644 index 0000000..11f4a41 Binary files /dev/null and b/go/delivery/testdata/declaration-ace.json.gz differ diff --git a/go/delivery/testdata/declaration-g14.json.gz b/go/delivery/testdata/declaration-g14.json.gz new file mode 100644 index 0000000..1629385 Binary files /dev/null and b/go/delivery/testdata/declaration-g14.json.gz differ