From 6f22665c9854b1d05808c74636b55f866ba25d7a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:04:06 +0100 Subject: [PATCH] =?UTF-8?q?mesh-catalog's=20client:=20the=20manifest=20dig?= =?UTF-8?q?est=20and=20key,=20the=20modules=20value's=20rules,=20the=20ver?= =?UTF-8?q?dict's=20rules=20and=20the=20candidate's=20fingerprint=20set,?= =?UTF-8?q?=20over=20the=20seat's=20typed=20states=20(ADR=200625=20=C2=A74?= =?UTF-8?q?,=20ADR=200634=20=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../go/clients/meshcatalogclient/candidate.go | 38 ++++ .../clients/meshcatalogclient/catalog_test.go | 151 ++++++++++++++++ contracts/go/clients/meshcatalogclient/go.mod | 5 +- .../go/clients/meshcatalogclient/manifest.go | 79 ++++++++ .../go/clients/meshcatalogclient/module.go | 133 ++++++++++++++ .../go/clients/meshcatalogclient/verdict.go | 168 ++++++++++++++++++ 6 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 contracts/go/clients/meshcatalogclient/candidate.go create mode 100644 contracts/go/clients/meshcatalogclient/catalog_test.go create mode 100644 contracts/go/clients/meshcatalogclient/manifest.go create mode 100644 contracts/go/clients/meshcatalogclient/module.go create mode 100644 contracts/go/clients/meshcatalogclient/verdict.go diff --git a/contracts/go/clients/meshcatalogclient/candidate.go b/contracts/go/clients/meshcatalogclient/candidate.go new file mode 100644 index 00000000..3c0f181c --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/candidate.go @@ -0,0 +1,38 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// FingerprintSet is the `fpset` of a candidate's pull requests: the first 12 hex digits of the sha256 over the lines +// `=` or `=none:` of every module with an entry in a pull's fps, sorted and joined by a newline, +// so it is the same for every order and changes with any fingerprint or reason. A manifest digest does not enter: the +// fingerprint already covers the manifest. It is the empty string when no pull request carries a fingerprint. The checker +// keys its verdict by it. +func FingerprintSet(pulls []seat.CandidatesByGroupValuePullsItem) string { + lines := map[string]bool{} + for _, p := range pulls { + for module, m := range p.Fps { + if m.Fp2 != "" { + lines[module+"="+m.Fp2] = true + } else { + lines[module+"=none:"+m.None2] = true + } + } + } + if len(lines) == 0 { + return "" + } + all := make([]string, 0, len(lines)) + for l := range lines { + all = append(all, l) + } + sort.Strings(all) + sum := sha256.Sum256([]byte(strings.Join(all, "\n"))) + return hex.EncodeToString(sum[:])[:12] +} diff --git a/contracts/go/clients/meshcatalogclient/catalog_test.go b/contracts/go/clients/meshcatalogclient/catalog_test.go new file mode 100644 index 00000000..1151fc00 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/catalog_test.go @@ -0,0 +1,151 @@ +package meshcatalogclient + +import ( + "errors" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func hex64(c string) string { return strings.Repeat(c, 64) } + +// The digest and the candidate's fingerprint set equal what the trunk's go/delivery computed for the same input (the +// expected strings are its output), so a key or a verdict written before is read after. +func TestDigestAndFingerprintSetKeepTheirForms(t *testing.T) { + if got := ManifestDigest([]byte(`{"module":"nats"}`)); got != "19a7450f557153c318558acac91cd6972321a192f6f95c3698a0e4c57edd4359" { + t.Errorf("ManifestDigest = %s", got) + } + pull := func(fps map[string]seat.CandidatesByGroupValuePullsItemFpsValue) []seat.CandidatesByGroupValuePullsItem { + return []seat.CandidatesByGroupValuePullsItem{{Fps: fps}} + } + fps := map[string]seat.CandidatesByGroupValuePullsItemFpsValue{"nats": {Fp2: SourcePrefix2 + hex64("a")}, "zsh": {None2: "no manifest"}} + set := FingerprintSet(pull(fps)) + if set != "50a3f57cc4e2" { + t.Errorf("FingerprintSet = %s", set) + } + fps["zsh"] = seat.CandidatesByGroupValuePullsItemFpsValue{None2: "another reason"} + if FingerprintSet(pull(fps)) == set { + t.Error("a changed reason leaves the set as it was") + } + if FingerprintSet(pull(nil)) != "" { + t.Error("no fingerprint gives a set") + } +} + +// A manifest and a module are read back as written, and each rule of the old values is kept: a refusal names its reason. +func TestManifestAndModuleValuesKeepTheirRules(t *testing.T) { + if _, err := ManifestsKey("abc"); err == nil || !strings.Contains(err.Error(), `"abc"`) { + t.Errorf("ManifestsKey(abc) = %v", err) + } + if k, err := ParseManifestsKey(hex64("b")); err != nil || k != hex64("b") { + t.Errorf("ParseManifestsKey = %q, %v", k, err) + } + raw, err := EncodeManifest(seat.ManifestsByDigestValue{Module: "nats", Manifest: `{"module":"nats"}`}) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeManifest(raw); err != nil || m.Module != "nats" || m.V != keys.Version { + t.Errorf("DecodeManifest = %+v, %v", m, err) + } + for _, bad := range []seat.ManifestsByDigestValue{{Module: "a.b", Manifest: "{}"}, {Module: "nats"}, {Module: "nats", Manifest: "not json"}, {Module: "nats", Manifest: "{\"a\":\"\xff\"}"}} { + if _, err := EncodeManifest(bad); err == nil { + t.Errorf("EncodeManifest(%+v) is accepted", bad) + } + } + if _, err := DecodeManifest([]byte(`{"module":"nats","manifest":"{}"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("a manifest with no version: %v", err) + } + + good := seat.ModulesByModuleValue{Fp2: SourcePrefix2 + hex64("a"), Repository: "novox/mesh-catalog", Commit: "c", Manifest: hex64("b"), Path: RootPath} + raw, err = EncodeModule(good) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeModule(raw); err != nil || m.Fp2 != good.Fp2 || m.Path != RootPath { + t.Errorf("DecodeModule = %+v, %v", m, err) + } + refused := good + refused.Verdict, refused.Problems = VerdictRefused, []string{"p"} + if _, err := EncodeModule(refused); err != nil { + t.Errorf("a refused module: %v", err) + } + for name, mutate := range map[string]func(m *seat.ModulesByModuleValue){ + "neither a fingerprint nor a reason": func(m *seat.ModulesByModuleValue) { m.Fp2 = "" }, + "both fp2 and none2": func(m *seat.ModulesByModuleValue) { m.None2 = "x" }, + "an old fingerprint of another form": func(m *seat.ModulesByModuleValue) { m.Fp = "src2:" + hex64("a") }, + "a fingerprint of the old key form": func(m *seat.ModulesByModuleValue) { m.Fp2 = "src1:" + hex64("a") }, + "inputs and no manifest": func(m *seat.ModulesByModuleValue) { + m.Manifest = "" + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t"} + }, + "a refusal with no problem": func(m *seat.ModulesByModuleValue) { m.Verdict = VerdictRefused }, + "problems with no refusal": func(m *seat.ModulesByModuleValue) { m.Problems = []string{"p"} }, + "unknown fields with no refusal": func(m *seat.ModulesByModuleValue) { m.Unknown = []string{"series"} }, + "a path outside the directory": func(m *seat.ModulesByModuleValue) { m.Paths = map[string][]string{"tools": {"../x"}} }, + "a toolchain with no recipe": func(m *seat.ModulesByModuleValue) { + m.Toolchains = map[string]seat.ToolchainUsed{"go": {Language: "go", Version: "1.26"}} + }, + "an import form with no imports": func(m *seat.ModulesByModuleValue) { + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t", ImportForm: SourcePrefix2 + hex64("a")} + }, + } { + m := good + mutate(&m) + if _, err := EncodeModule(m); err == nil { + t.Errorf("%s: EncodeModule accepts %+v", name, m) + } + } +} + +// A verdict is taken as the readers have always taken it: a missing one accepted, a refusal held with its problems, a wait +// held with the fields it waits on, and a module's own verdict field gives the same. +func TestVerdictsAreTakenAsTheReadersTookThem(t *testing.T) { + if refused, problems := TakeVerdict(nil); refused || problems != nil { + t.Errorf("no verdict: %v %v", refused, problems) + } + waits := seat.VerdictsByDigestValue{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"series"}, Judge: "j"} + refused, problems := TakeVerdict(&waits) + if !refused || len(problems) != 1 || problems[0] != "it carries series, which the indexer's judge does not know yet" { + t.Errorf("a wait: %v %v", refused, problems) + } + if got := VerdictWhy(waits); got != "the indexer's judge (j) holds the manifest of nats: "+problems[0] { + t.Errorf("VerdictWhy(wait) = %q", got) + } + if as := VerdictAsRefused(waits); as.State != VerdictRefused || len(as.Unknown) != 0 || as.Problems[0] != problems[0] { + t.Errorf("VerdictAsRefused = %+v", as) + } + no := seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "j"} + if got := VerdictWhy(no); got != "the indexer's judge (j) refused the manifest of nats: a; b" { + t.Errorf("VerdictWhy(refused) = %q", got) + } + if !SameVerdict(no, seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "k"}) { + t.Error("another judge is another verdict") + } + fromModule := ModuleVerdict("nats", seat.ModulesByModuleValue{Verdict: VerdictRefused, Unknown: []string{"series"}, Problems: []string{"x"}}) + if fromModule == nil || fromModule.State != VerdictManifestWaits || fromModule.Unknown[0] != "series" { + t.Errorf("a refusal naming unknown fields is the wait it was written as: %+v", fromModule) + } + if ModuleVerdict("nats", seat.ModulesByModuleValue{}) != nil { + t.Error("a module with no verdict carries one") + } + for _, bad := range []seat.VerdictsByDigestValue{{Module: "nats", State: "accepted", Problems: []string{"p"}, Judge: "j"}, {Module: "nats", State: "refused", Judge: "j"}, {Module: "nats", State: "accepted"}, {Module: "nats", State: "odd", Judge: "j"}} { + if _, err := EncodeVerdict(bad); err == nil { + t.Errorf("EncodeVerdict(%+v) is accepted", bad) + } + } + raw, err := EncodeVerdict(no) + if err != nil { + t.Fatal(err) + } + if v, err := DecodeVerdict(raw); err != nil || !SameVerdict(v, no) { + t.Errorf("DecodeVerdict = %+v, %v", v, err) + } + if k, err := VerdictsKey(hex64("c")); err != nil || k != hex64("c") { + t.Errorf("VerdictsKey = %q %v", k, err) + } + if _, err := ModulesKey("a.b"); err == nil { + t.Error("ModulesKey accepts a dotted name") + } +} diff --git a/contracts/go/clients/meshcatalogclient/go.mod b/contracts/go/clients/meshcatalogclient/go.mod index 12ac8e36..3e0ec688 100644 --- a/contracts/go/clients/meshcatalogclient/go.mod +++ b/contracts/go/clients/meshcatalogclient/go.mod @@ -2,7 +2,10 @@ module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef +) require ( github.com/klauspost/compress v1.20.1 // indirect diff --git a/contracts/go/clients/meshcatalogclient/manifest.go b/contracts/go/clients/meshcatalogclient/manifest.go new file mode 100644 index 00000000..38c46616 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/manifest.go @@ -0,0 +1,79 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "unicode/utf8" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ManifestDigest is the hex sha256 of a manifest's bytes exactly as read at the commit: the key of a `manifests` value and +// the digest a `modules` value and a candidate's module carry (novox/hq ADR 0429 clause 16). +func ManifestDigest(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// ManifestsKey is the key of a `manifests` value: the manifest's digest, 64 lowercase hex digits. The indexer writes it +// once, by a create that fails when the key exists. +func ManifestsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.ManifestsByDigestKey(digest), nil +} + +// ParseManifestsKey is the inverse of ManifestsKey. +func ParseManifestsKey(key string) (string, error) { + digest, err := ManifestsKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifests key: %w", key, err) + } + return digest, nil +} + +func checkManifest(m seat.ManifestsByDigestValue) error { + if err := keys.CheckName("module", m.Module); err != nil { + return err + } + if m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: the manifest of %s is empty", m.Module) + } + if !utf8.ValidString(m.Manifest) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not UTF-8 text, which a value cannot hold byte for byte", m.Module) + } + if !json.Valid([]byte(m.Manifest)) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not JSON", m.Module) + } + return nil +} + +// EncodeManifest is m as JSON at the kept values' version; it refuses a module that is not a name and a manifest that is +// empty, not UTF-8 or not JSON. +func EncodeManifest(m seat.ManifestsByDigestValue) ([]byte, error) { + if err := checkManifest(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeManifest reads a `manifests` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeManifest +// refuses. +func DecodeManifest(data []byte) (seat.ManifestsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ManifestsByDigestValue{}, err + } + var m seat.ManifestsByDigestValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ManifestsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkManifest(m); err != nil { + return seat.ManifestsByDigestValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/module.go b/contracts/go/clients/meshcatalogclient/module.go new file mode 100644 index 00000000..d07b3692 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/module.go @@ -0,0 +1,133 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// RootPath is a `modules` value's path when the module's manifest is its repository's root. +const RootPath = "." + +// SourcePrefix1 opens the fingerprint of the old form a `modules` value may still carry in `fp`. +const SourcePrefix1 = "src1:" + +// ModulesKey is the key of a `modules` value: the module's name. +func ModulesKey(module string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + return seat.ModulesByModuleKey(module), nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (string, error) { + module, err := ModulesKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// CheckModule refuses a `modules` value that contradicts itself: both or neither of a fingerprint and the reason for none, +// of either form, an old fingerprint not of the form `src1:`, kept inputs without a manifest digest, a verdict the indexer +// does not write, problems without a refusal (or a refusal without problems), a path that is not clean inside the module's +// directory, and a fingerprint or a digest not of its form. +func CheckModule(m seat.ModulesByModuleValue) error { + switch { + case m.Fp != "" && m.None != "": + return fmt.Errorf("meshcatalogclient: a module has both a fingerprint and a reason for none") + case m.Fp == "" && m.None == "" && m.Fp2 == "" && m.None2 == "": + return fmt.Errorf("meshcatalogclient: a module has neither a fingerprint nor a reason for none, of either form") + case m.Fp != "" && !strings.HasPrefix(m.Fp, SourcePrefix1): + return fmt.Errorf("meshcatalogclient: the module's fingerprint %q does not start with %s", m.Fp, SourcePrefix1) + case m.Fp2 != "" && m.None2 != "": + return fmt.Errorf("meshcatalogclient: a module has both fp2 and none2") + } + for language, t := range m.Toolchains { + if t.Version == "" || t.Recipe == "" { + return fmt.Errorf("meshcatalogclient: the module's toolchain of %q needs its version and its recipe version", language) + } + } + if m.Inputs2 != nil && m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: a module keeps new-form inputs but has no manifest digest") + } + for artifact, paths := range m.Paths { + if artifact == "" || len(paths) == 0 { + return fmt.Errorf("meshcatalogclient: the module's named paths of the artifact %q are empty or name no artifact", artifact) + } + for _, p := range paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the artifact %q names the path %q, not a clean path inside the module's directory", artifact, p) + } + } + } + if in := m.Inputs2; in != nil { + if f := in.ImportForm; f != "" && (len(in.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !keys.IsHex64(f[len(SourcePrefix2):])) { + return fmt.Errorf("meshcatalogclient: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) + } + for p, object := range in.Paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the named path %q is not a clean path inside the module's directory", p) + } + if object == "" { + return fmt.Errorf("meshcatalogclient: the named path %q names no git object", p) + } + } + } + switch { + case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) + case len(m.Unknown) > 0 && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: a module's unknown fields are named only with the refusal a wait is written as") + case m.Verdict != "" && m.Manifest == "": + return fmt.Errorf("meshcatalogclient: a module carries a verdict and no manifest digest") + case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): + return fmt.Errorf("meshcatalogclient: a module's problems are named only with a refusal, and a refusal names them") + case m.Verdict == VerdictRefused && len(m.Warnings) > 0: + return fmt.Errorf("meshcatalogclient: a refused module warns: a refusal names problems") + } + if m.Fp2 != "" { + if _, err := KeyPart(m.Fp2); err != nil { + return err + } + } + if m.Manifest != "" { + if _, err := ManifestsKey(m.Manifest); err != nil { + return err + } + } + return nil +} + +func cleanInside(p string) bool { + return p != "" && p == path.Clean(p) && !strings.HasPrefix(p, "/") && p != ".." && !strings.HasPrefix(p, "../") +} + +// EncodeModule is m as JSON at the kept values' version; it refuses what CheckModule refuses. +func EncodeModule(m seat.ModulesByModuleValue) ([]byte, error) { + if err := CheckModule(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeModule reads a `modules` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (seat.ModulesByModuleValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ModulesByModuleValue{}, err + } + var m seat.ModulesByModuleValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ModulesByModuleValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := CheckModule(m); err != nil { + return seat.ModulesByModuleValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/verdict.go b/contracts/go/clients/meshcatalogclient/verdict.go new file mode 100644 index 00000000..0e398221 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/verdict.go @@ -0,0 +1,168 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The states of a verdict on a manifest (novox/hq ADR 0578 §1): the indexer judges every manifest it takes in once and +// every other part takes the verdict from the seat's state `verdicts`. +const ( + // VerdictAccepted is the state of a manifest the judge found no problem in. + VerdictAccepted = "accepted" + // VerdictRefused is the state of a manifest the judge found a problem in; the verdict's problems say each. + VerdictRefused = "refused" + // VerdictManifestWaits is the state of a manifest that carries a field the judge does not know: it is not refused, + // because a newer judge may know the field; the verdict's unknown names each. A reader holds the module as a refused + // one and keeps what runs. In a `modules` value it is written as a refusal naming the fields. + VerdictManifestWaits = "waits" +) + +// VerdictsKey is the key of a `verdicts` value: the digest of the manifest it judges. +func VerdictsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.VerdictsByDigestKey(digest), nil +} + +// VerdictRefuses says whether v refuses its manifest. +func VerdictRefuses(v seat.VerdictsByDigestValue) bool { return v.State == VerdictRefused } + +// VerdictWaits says whether v says the manifest waits for a judge that knows its fields. +func VerdictWaits(v seat.VerdictsByDigestValue) bool { return v.State == VerdictManifestWaits } + +// VerdictHeld says whether a reader holds the module as refused: the manifest is refused or waits. +func VerdictHeld(v seat.VerdictsByDigestValue) bool { return VerdictRefuses(v) || VerdictWaits(v) } + +// WaitsOn says, in words, what a waiting manifest waits on. +func WaitsOn(v seat.VerdictsByDigestValue) string { + return "it carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet" +} + +// TakeVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): held, with its problems, when the indexer +// wrote a refusal, or, for a manifest that waits, with the words that say which fields it waits on. A missing verdict (nil) +// is taken as accepted: every manifest the indexer holds before it has judged it was taken in under the judges that ran +// until then. A reader holds a refused module and names it with VerdictWhy, and reads the rest. +func TakeVerdict(v *seat.VerdictsByDigestValue) (refused bool, problems []string) { + if v == nil || !VerdictHeld(*v) { + return false, nil + } + if VerdictWaits(*v) { + return true, []string{WaitsOn(*v)} + } + return true, v.Problems +} + +// VerdictWhy is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. +func VerdictWhy(v seat.VerdictsByDigestValue) string { + if !VerdictHeld(v) { + return "" + } + judge := "the indexer's judge" + if v.Judge != "" { + judge += " (" + v.Judge + ")" + } + if VerdictWaits(v) { + return fmt.Sprintf("%s holds the manifest of %s: %s", judge, v.Module, WaitsOn(v)) + } + why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) + for _, p := range v.Problems { + why += " " + p + ";" + } + return why[:len(why)-1] +} + +// VerdictAsRefused is v as a reader that does not know the waiting state takes it: a waiting verdict is a refusal naming what +// it waits on, any other verdict is v. +func VerdictAsRefused(v seat.VerdictsByDigestValue) seat.VerdictsByDigestValue { + if !VerdictWaits(v) { + return v + } + v.State, v.Problems, v.Unknown = VerdictRefused, []string{WaitsOn(v)}, nil + return v +} + +// SameVerdict says whether v and o say the same of a manifest: the module, the state, the problems, the unknown fields and +// the warnings, whoever judged and when. The indexer writes a verdict only when it is not the same as the one it holds. +func SameVerdict(v, o seat.VerdictsByDigestValue) bool { + return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Unknown, o.Unknown) && slices.Equal(v.Warnings, o.Warnings) +} + +func checkVerdict(v seat.VerdictsByDigestValue) error { + if err := keys.CheckName("module", v.Module); err != nil { + return err + } + if v.State != VerdictManifestWaits && len(v.Unknown) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s names unknown fields and does not wait", v.Module) + } + switch v.State { + case VerdictAccepted: + if len(v.Problems) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s accepts its manifest and names problems", v.Module) + } + case VerdictRefused: + if len(v.Problems) == 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and names no problem", v.Module) + } + if len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module) + } + case VerdictManifestWaits: + if len(v.Unknown) == 0 || len(v.Problems) > 0 || len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s waits and names no unknown field, or names problems or warnings: a wait names the fields", v.Module) + } + default: + return fmt.Errorf("meshcatalogclient: the verdict on %s is %q, none of %s, %s and %s", v.Module, v.State, VerdictAccepted, VerdictRefused, VerdictManifestWaits) + } + if v.Judge == "" { + return fmt.Errorf("meshcatalogclient: the verdict on %s names no judge", v.Module) + } + return nil +} + +// EncodeVerdict is v as JSON at the kept values' version; it refuses a verdict without a module or a judge, a state other +// than accepted, refused or waits, a refusal naming no problem and an acceptance naming one. +func EncodeVerdict(v seat.VerdictsByDigestValue) ([]byte, error) { + if err := checkVerdict(v); err != nil { + return nil, err + } + v.V = keys.Version + return json.Marshal(v) +} + +// DecodeVerdict reads a `verdicts` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeVerdict +// refuses. +func DecodeVerdict(data []byte) (seat.VerdictsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.VerdictsByDigestValue{}, err + } + var v seat.VerdictsByDigestValue + if err := json.Unmarshal(data, &v); err != nil { + return seat.VerdictsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkVerdict(v); err != nil { + return seat.VerdictsByDigestValue{}, err + } + return v, nil +} + +// ModuleVerdict is the verdict a `modules` value carries, or nil when it carries none (taken as accepted, TakeVerdict). A +// refusal naming unknown fields is the wait it was written as. +func ModuleVerdict(module string, m seat.ModulesByModuleValue) *seat.VerdictsByDigestValue { + if m.Verdict == "" && len(m.Warnings) == 0 { + return nil + } + if m.Verdict == "" { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictAccepted, Warnings: m.Warnings} + } + if m.Verdict == VerdictRefused && len(m.Unknown) > 0 { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictManifestWaits, Unknown: m.Unknown} + } + return &seat.VerdictsByDigestValue{Module: module, State: m.Verdict, Problems: m.Problems} +}