diff --git a/go/delivery/answer_paths.go b/go/delivery/answer_paths.go index e2d3a72..bee6368 100644 --- a/go/delivery/answer_paths.go +++ b/go/delivery/answer_paths.go @@ -86,8 +86,24 @@ func walkDiffering(a, b any, at string, out *[]string) { case !inA || !inB: *out = append(*out, join(at, k)) case secretKey(k): - if !sameAny(x, y) { - *out = append(*out, join(at, k)) + // A map of secrets (a file's, by name) is named entry by entry, each entry never entered. + sx, isSX := x.(map[string]any) + sy, isSY := y.(map[string]any) + if !isSX || !isSY { + if !sameAny(x, y) { + *out = append(*out, join(at, k)) + } + continue + } + for name := range sx { + if v, in := sy[name]; !in || !sameAny(sx[name], v) { + *out = append(*out, join(join(at, k), name)) + } + } + for name := range sy { + if _, in := sx[name]; !in { + *out = append(*out, join(join(at, k), name)) + } } default: walkDiffering(x, y, join(at, k), out) diff --git a/go/delivery/composed_compare.go b/go/delivery/composed_compare.go index 3d2a609..47c7279 100644 --- a/go/delivery/composed_compare.go +++ b/go/delivery/composed_compare.go @@ -265,12 +265,12 @@ func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { va, inA := ma[k] vb, inB := mb[k] if secretKey(k) { - word := maskWord(va, vb, inA && inB, sameValue) + x, y := maskAll(va, vb, inA && inB, sameValue) if inA { - outA[k] = word + outA[k] = x } if inB { - outB[k] = word + outB[k] = y } continue } @@ -328,6 +328,36 @@ func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { return a, b } +// maskAll is a value under a key a secret could be under, masked: a map entry by entry (a file's secrets, each its own +// sealed value: "another seal of the same value" or "another value" per secret, issue 1126, C1), its names shown and +// every value under it masked; anything else as one word (maskWord). +func maskAll(a, b any, both bool, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if !isA && !isB { + w := maskWord(a, b, both, sameValue) + return w, w + } + outA, outB := map[string]any{}, map[string]any{} + for k, va := range ma { + vb, inB := mb[k] + outA[k], _ = maskAll(va, vb, isB && inB, sameValue) + } + for k, vb := range mb { + va, inA := ma[k] + _, outB[k] = maskAll(va, vb, isA && inA, sameValue) + } + // A map on one side and another value on the other: the other is one word. + var ra, rb any = outA, outB + if !isA { + ra = maskWord(a, b, both, sameValue) + } + if !isB { + rb = maskWord(a, b, both, sameValue) + } + return ra, rb +} + func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { if !both { return "" diff --git a/go/delivery/composed_seals.go b/go/delivery/composed_seals.go index af3ca54..aa43da9 100644 --- a/go/delivery/composed_seals.go +++ b/go/delivery/composed_seals.go @@ -3,13 +3,14 @@ package delivery import ( "bytes" "encoding/json" + "sort" "strconv" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed` -// and a vault box (catalogue.VaultSealedField), as their JSON text (a box is carried as its JSON, a sealed value as +// a vault box (catalogue.VaultSealedField) and each entry of a file's secrets (SecretsField), as their JSON text (a box is carried as its JSON, a sealed value as // the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only // at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its // consumer's secret on one node hold one value). @@ -22,6 +23,44 @@ var SealedFields = []string{"sealed", catalogue.VaultSealedField} // The module is the composition's: a slot is never compared across compositions. func SealSlot(resource, field string) string { return resource + "#" + field } +// SecretsField is the field of a file that carries, by name, each sealed value its content asks for +// (${secret:}, catalogue's intoFile): each entry is a slot of its own (SecretSlotField), so a secret sealed again +// is judged by the value it opens to, entry by entry, like a file's `sealed` (issue 1126, C1; the seed-7 probe: about 45 +// files on ace and novox differed by their secrets' bytes alone). +const SecretsField = "secrets" + +// SecretSlotField is the field part of the slot of one entry of a file's secrets (SealSlot(id, SecretSlotField(name))). +func SecretSlotField(name string) string { return SecretsField + "." + name } + +// sealAt is one sealed value of a resource: its slot's field, its place (a top-level field, or an entry of the +// secrets), and its raw JSON. +type sealAt struct { + field, entry string + raw json.RawMessage +} + +// sealsAt is every sealed value of one resource's fields, in a fixed order: SealedFields, then the secrets by name. +func sealsAt(fields map[string]json.RawMessage) []sealAt { + var out []sealAt + for _, field := range SealedFields { + if f, has := fields[field]; has { + out = append(out, sealAt{field: field, raw: f}) + } + } + var secrets map[string]json.RawMessage + if f, has := fields[SecretsField]; has && json.Unmarshal(f, &secrets) == nil { + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + out = append(out, sealAt{field: SecretSlotField(name), entry: name, raw: secrets[name]}) + } + } + return out +} + // SealsIn is every sealed value c carries, by slot; nil when it carries none. func SealsIn(c Composed) Seals { var out Seals @@ -30,16 +69,12 @@ func SealsIn(c Composed) Seals { if !ok { continue } - for _, field := range SealedFields { - f, has := fields[field] - if !has { - continue - } - if text := sealedText(f); text != "" { + for _, s := range sealsAt(fields) { + if text := sealedText(s.raw); text != "" { if out == nil { out = Seals{} } - out[SealSlot(id, field)] = text + out[SealSlot(id, s.field)] = text } } } @@ -59,25 +94,34 @@ func KeepSeals(c Composed, kept Seals, same func(a, b string) (same, known bool) for i, raw := range c.Resources { id, fields, ok := sealedFieldsOf(raw, i) if ok { - for _, field := range SealedFields { - f, has := fields[field] - if !has { - continue - } - ours := sealedText(f) - theirs, held := kept[SealSlot(id, field)] + for _, s := range sealsAt(fields) { + ours := sealedText(s.raw) + theirs, held := kept[SealSlot(id, s.field)] if !held || ours == "" || theirs == ours { continue } - if s, known := same(ours, theirs); !known || !s { + if opens, known := same(ours, theirs); !known || !opens { continue } value := json.RawMessage(theirs) - if f[0] == '"' { + if s.raw[0] == '"' { value, _ = json.Marshal(theirs) } - if swapped, ok := withField(raw, field, value); ok { - raw = swapped + if s.entry == "" { + if swapped, ok := withField(raw, s.field, value); ok { + raw = swapped + } + continue + } + // An entry of the secrets: swapped inside the secrets as they stand in raw, then the secrets in raw. + var now map[string]json.RawMessage + if json.Unmarshal(raw, &now) != nil { + continue + } + if secrets, ok := withField(now[SecretsField], s.entry, value); ok { + if swapped, ok := withField(raw, SecretsField, secrets); ok { + raw = swapped + } } } } diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 1799d06..5ffda73 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -1,6 +1,7 @@ package delivery import ( + "bytes" "encoding/json" "errors" "fmt" @@ -327,6 +328,43 @@ func TestPlacement(t *testing.T) { t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths) } } + // Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry + // is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets + // (2026-10-28), with the module a composition names each resource of; only the boxes are replaced. + withSecrets := func(db, key string) json.RawMessage { + return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` + + `"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db + + `","secret-key":"` + key + `"},"type":"file"}`) + } + if c := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-1"))}, + []Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { + t.Errorf("a secret sealed again at its own entry is not the same: %+v", c) + } + if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) { + t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0]) + } + if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 { + t.Errorf("the secrets' slots are %v", s) + } + // Another value at one entry differs there alone, each entry masked by what it opens to. + // box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values. + mixed := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-5"))}, + []Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { + return (a == "box-2" && b == "box-4") || a == b, true + }) + if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 { + t.Fatalf("a secret of another value is not shown: %+v", mixed) + } + if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) || + !strings.Contains(body.Composed, `"secret-key":""`) || strings.Contains(body.Composed+body.Running, "box-") || + !strings.Contains(body.Running, `"postgres-database":""`) { + t.Errorf("a file's secrets shown as %+v", body) + } + if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) { + return a == "box-2", true + }); !strings.Contains(c, `"postgres-database":""`) || !strings.Contains(c, `"secret-key":""`) { + t.Errorf("each secret is not masked by what it opens to: %s", c) + } // Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a // key a secret could be under is named and never shown. long := strings.Repeat("x", MaxBody)