From eb2978b3ce360fed4824aefe84a2553fd6a0b259 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:58:09 +0100 Subject: [PATCH] R1-F-sdk 5: catalogue.Decode refuses a manifest giving one field twice, naming its path A decoder keeps the last of two equal keys without a word, so the indexer's judge read a manifest a merge of two branches had given one field twice as if one side never was. mesh-catalog's checks/manifests refuses it already; Decode now refuses it the same way, at any depth. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/decode.go | 54 +++++++++++++++++++++- go/catalogue/optional_requirements_test.go | 17 +++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/go/catalogue/decode.go b/go/catalogue/decode.go index b362814..78bc229 100644 --- a/go/catalogue/decode.go +++ b/go/catalogue/decode.go @@ -1,7 +1,9 @@ package catalogue import ( + "bytes" "encoding/json" + "fmt" "strings" ) @@ -9,15 +11,65 @@ import ( // contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It // judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its // verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it -// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape. +// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, a field of the wrong shape, or one field +// given twice in one object at any depth, named by its path: a decoder keeps the last of two equal keys without a word, +// so a merge of two branches that each added the field would lose one side silently (as mesh-catalog's checks/manifests +// refuses it). func Decode(raw []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return Manifest{}, err } + if twice := givenTwice(raw); twice != "" { + return Manifest{}, fmt.Errorf("the field %s is given twice; a manifest gives each field once", twice) + } return m, nil } +// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty. +func givenTwice(raw []byte) string { + d := json.NewDecoder(bytes.NewReader(raw)) + var walk func(path string) string + walk = func(path string) string { + t, err := d.Token() + if err != nil { + return "" + } + switch t { + case json.Delim('{'): + seen := map[string]bool{} + for d.More() { + k, err := d.Token() + if err != nil { + return "" + } + key, _ := k.(string) + at := key + if path != "" { + at = path + "." + key + } + if seen[key] { + return at + } + seen[key] = true + if twice := walk(at); twice != "" { + return twice + } + } + _, _ = d.Token() + case json.Delim('['): + for i := 0; d.More(); i++ { + if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" { + return twice + } + } + _, _ = d.Token() + } + return "" + } + return walk("") +} + // SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first // and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words // (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 ยง6). diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index d6d9cb3..2f6bcb8 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -64,21 +64,32 @@ func TestARequirementsLongForm(t *testing.T) { names string busState []BusStateEntry aside []string + refused string }{ // 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions, // and BusState holds the entry whole. {"a plain name and a bus-state entry", `["postgres-database", {"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`, - "postgres-database", []BusStateEntry{records}, nil}, + "postgres-database", []BusStateEntry{records}, nil, ""}, // 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes. {"a bus-state entry with a key it does not know", `[ {"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`, - "", []BusStateEntry{records}, []string{"requires[0].colour"}}, + "", []BusStateEntry{records}, []string{"requires[0].colour"}, ""}, // 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged. {"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}, - "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil}, + "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""}, + // A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word. + {"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"}, + {"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`, + "", nil, nil, "the field requires[0].name is given twice"}, } { got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`)) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: read as %+v, %v; want refused: %s", c.name, got, err, c.refused) + } + continue + } if err != nil { t.Errorf("%s: refused: %v", c.name, err) continue