package delivery import ( "bytes" "encoding/json" "strconv" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run // now) and removed by it (run now, not composed). Each list is sorted by module. type Comparison struct { Same []string Differs []ModuleDiffers Added []ModuleAdded Removed []string } // CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones // it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its // composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing // of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and // not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out // on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A // difference names the first resource, by its id, whose content differs or that one side alone holds; when every // resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file // carried by digest). func CompareComposed(composed map[string]Composed, running []Composed) Comparison { return CompareComposedWith(composed, running, nil) } // CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both // bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node // opens them with its own key), so a value sealed again reads as such. func CompareComposedWith(composed map[string]Composed, running []Composed, sameValue func(a, b string) (same, known bool)) Comparison { name := func(c Composed) string { if c.Module == "" { return HaveNodeWord } return c.Module } ours := map[string]Composed{} for _, c := range composed { ours[name(c)] = c } theirs := map[string]Composed{} for _, c := range running { theirs[name(c)] = c } modules := map[string]bool{} for m := range ours { modules[m] = true } for m := range theirs { modules[m] = true } var out Comparison for _, m := range sortedKeys(modules) { a, composedHere := ours[m] b, runs := theirs[m] switch { case !runs: dig, _ := ComposedDigestOf(withoutBusUsers(a)) out.Added = append(out.Added, ModuleAdded{Module: m, Composed: dig}) case !composedHere: out.Removed = append(out.Removed, m) default: if d, ok := compareOne(m, a, b, sameValue); ok { out.Same = append(out.Same, m) } else { out.Differs = append(out.Differs, d) } } } return out } // compareOne is one module's comparison: true when the digests agree, else what differs first. func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (ModuleDiffers, bool) { composed, running = withoutBusUsers(composed), withoutBusUsers(running) dc, errC := ComposedDigestOf(composed) dr, errR := ComposedDigestOf(running) if errC != nil { return ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false } if errR == nil && dc == dr { return ModuleDiffers{}, true } differs := func(resource, why string) (ModuleDiffers, bool) { return ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false } // Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole // and the same file carried by digest read as one. a, _, errA := composed.ByDigest() b, _, errB := running.ByDigest() if errA != nil || errB != nil { return differs("", "the compositions differ and cannot be read resource by resource") } ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources) ids := map[string]bool{} for id := range ra { ids[id] = true } for id := range rb { ids[id] = true } var first ModuleDiffers found := false var bodies []ResourceBodies for _, id := range sortedKeys(ids) { x, inA := ra[id] y, inB := rb[id] why := "" switch { case !inB: why = "composed, and not in what runs" case !inA: why = "in what runs, and not composed" case !bytes.Equal(x, y): why = "the resource differs" default: continue } if !found { first, _ = differs(id, why) found = true } if len(bodies) < MaxBodies { c, r := MaskedPair(x, y, sameValue) bodies = append(bodies, ResourceBodies{ID: id, Why: why, Composed: cut(c), Running: cut(r)}) } } if found { first.Bodies = bodies return first, false } switch { case !jsonEqual(a.Filled, b.Filled): return differs("", "the files the provisioner fills differ") case a.Data != b.Data: return differs("", "whether it declares data differs") case !jsonEqual(a.Own, b.Own): return differs("", "the node's own parts differ") case a.FP != b.FP: return differs("", "the fingerprint differs") } if dA, err := ComposedDigestOf(a); err == nil { if dB, err := ComposedDigestOf(b); err == nil && dA == dB { return differs("", "the same content, a large file carried whole on one side and by digest on the other") } } return differs("", "the same resources in another order") } // withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `.bus-users`). func withoutBusUsers(c Composed) Composed { out := c out.Resources = nil for _, raw := range c.Resources { var head struct { ID string `json:"id"` } if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) { continue } out.Resources = append(out.Resources, raw) } return out } // resourcesByID is each resource canonical, by its id (a resource without one by its place). func resourcesByID(resources []json.RawMessage) map[string][]byte { out := make(map[string][]byte, len(resources)) for i, raw := range resources { var head struct { ID string `json:"id"` } _ = json.Unmarshal(raw, &head) id := head.ID if id == "" { id = "#" + strconv.Itoa(i) } canon, err := canonicalJSON(raw) if err != nil { canon = raw } out[id] = canon } return out } // cut is s cut at MaxBody bytes, saying so. func cut(s string) string { if len(s) <= MaxBody { return s } return s[:MaxBody] + "… (cut at " + strconv.Itoa(MaxBody) + " bytes)" } // secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"} func secretKey(k string) bool { k = strings.ToLower(k) for _, w := range secretWords { if strings.Contains(k, w) { return true } } return false } // MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could // be in (secretWords, at any depth) masked: "", "" or "" when sameValue says so, "" otherwise, "" on one side // alone. A body that is not JSON is shown as its length only. func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) { var a, b any okA := composed != nil && json.Unmarshal(composed, &a) == nil okB := running != nil && json.Unmarshal(running, &b) == nil if okA && okB { a, b = maskBoth(a, b, sameValue) } else if okA { a, _ = maskBoth(a, nil, nil) } else if okB { _, b = maskBoth(nil, b, nil) } show := func(v any, raw json.RawMessage, ok bool) string { if raw == nil { return "" } if !ok { return "" } var out bytes.Buffer enc := json.NewEncoder(&out) enc.SetEscapeHTML(false) _ = enc.Encode(v) return strings.TrimSuffix(out.String(), "\n") } return show(a, composed, okA), show(b, running, okB) } func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { ma, isA := a.(map[string]any) mb, isB := b.(map[string]any) if isA || isB { outA, outB := map[string]any{}, map[string]any{} keys := map[string]bool{} for k := range ma { keys[k] = true } for k := range mb { keys[k] = true } for k := range keys { va, inA := ma[k] vb, inB := mb[k] if secretKey(k) { word := maskWord(va, vb, inA && inB, sameValue) if inA { outA[k] = word } if inB { outB[k] = word } continue } x, y := maskBoth(va, vb, sameValue) if inA { outA[k] = x } if inB { outB[k] = y } } var ra, rb any if isA { ra = outA } else { ra = a } if isB { rb = outB } else { rb = b } return ra, rb } la, isLA := a.([]any) lb, isLB := b.([]any) if isLA || isLB { outA := make([]any, len(la)) outB := make([]any, len(lb)) for i := range max(len(la), len(lb)) { var x, y any if i < len(la) { x = la[i] } if i < len(lb) { y = lb[i] } x, y = maskBoth(x, y, sameValue) if i < len(la) { outA[i] = x } if i < len(lb) { outB[i] = y } } var ra, rb any = a, b if isLA { ra = outA } if isLB { rb = outB } return ra, rb } return a, b } func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { if !both { return "" } x, _ := json.Marshal(a) y, _ := json.Marshal(b) if bytes.Equal(x, y) { return "" } if sameValue != nil { sa, okA := a.(string) sb, okB := b.(string) if !okA { sa = string(x) } if !okB { sb = string(y) } if same, known := sameValue(sa, sb); known { if same { return "" } return "" } } return "" }