From c23833d3e520cc43959fc607c96863872fe87c99 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:34:19 +0200 Subject: [PATCH] Move the source fingerprint into one function the builder and the indexer both call (issue 1203) --- go/delivery/delivery.go | 3 + go/delivery/fingerprint.go | 61 +++++++++++++++++ go/delivery/fingerprint_test.go | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 go/delivery/fingerprint.go create mode 100644 go/delivery/fingerprint_test.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 02c4ed9..fe99528 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -27,6 +27,9 @@ // `merge..` (the router's ask whole), its owed keys `...` that the // forge bridge performs, and the teller's answer under the ask's own key (gate.go). // +// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and +// the indexer both call, so they agree byte for byte (fingerprint.go). +// // Standard library only, and this module's own asks package. package delivery diff --git a/go/delivery/fingerprint.go b/go/delivery/fingerprint.go new file mode 100644 index 0000000..443039e --- /dev/null +++ b/go/delivery/fingerprint.go @@ -0,0 +1,61 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" +) + +// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of +// its directory, its bases (references, of which only the digest counts), the root tree of each repository its +// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build +// when it does not (a build that resolves packages when built). +type SourceInputs struct { + Module string + Tree string + Bases []string + Contexts map[string]string + Toolchains map[string]string + Unpinned string +} + +// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none +// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the +// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are +// "module " and "tree ", then "base " (once each), "context " and "toolchain +// " sorted, all joined by a newline. +func SourceFingerprint(in SourceInputs) (fp, none string) { + if in.Unpinned != "" { + return "", in.Unpinned + } + if in.Tree == "" { + return "", "no tree" + } + lines := []string{"module " + in.Module, "tree " + in.Tree} + bases := map[string]bool{} + for _, b := range in.Bases { + bases[ReferenceDigest(b)] = true + } + for b := range bases { + lines = append(lines, "base "+b) + } + for a, t := range in.Contexts { + lines = append(lines, "context "+a+" "+t) + } + for a, t := range in.Toolchains { + lines = append(lines, "toolchain "+a+" "+t) + } + sort.Strings(lines[2:]) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return SourcePrefix + hex.EncodeToString(sum[:]), "" +} + +// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not +// enter the fingerprint; the reference itself when it carries none. +func ReferenceDigest(reference string) string { + if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" { + return digest + } + return reference +} diff --git a/go/delivery/fingerprint_test.go b/go/delivery/fingerprint_test.go new file mode 100644 index 0000000..a7fd036 --- /dev/null +++ b/go/delivery/fingerprint_test.go @@ -0,0 +1,116 @@ +package delivery + +import ( + "strings" + "testing" +) + +func inputs() SourceInputs { + return SourceInputs{ + Module: "mesh-mail", Tree: "t-module", + Bases: []string{"registry.example/base@sha256:aaa", "registry.example/other@sha256:bbb"}, + Contexts: map[string]string{"mail": "t-root", "tool": "t-root2"}, + Toolchains: map[string]string{"mail": "go sha256:ccc 0123456789abcdef", "tool": "go sha256:ccc fedcba9876543210"}, + } +} + +func mustFP(t *testing.T, in SourceInputs) string { + t.Helper() + fp, none := SourceFingerprint(in) + if fp == "" || none != "" { + t.Fatalf("fingerprint = %q, none = %q", fp, none) + } + return fp +} + +func TestFingerprintForm(t *testing.T) { + fp := mustFP(t, inputs()) + hex := strings.TrimPrefix(fp, SourcePrefix) + if SourcePrefix != "src1:" || hex == fp || len(hex) != 64 || strings.Trim(hex, "0123456789abcdef") != "" { + t.Errorf("fingerprint %q is not src1: and 64 hex digits", fp) + } +} + +func TestFingerprintOrderAndDuplicates(t *testing.T) { // 1203/AC2 + want := mustFP(t, inputs()) + in := inputs() + in.Bases = []string{in.Bases[1], in.Bases[0]} + if got := mustFP(t, in); got != want { + t.Errorf("the order of bases changes it: %s", got) + } + in = inputs() + in.Bases = append(in.Bases, in.Bases[0]) + if got := mustFP(t, in); got != want { + t.Errorf("a base given twice changes it: %s", got) + } + in = inputs() + in.Bases = append(in.Bases, "other.registry.example/base@sha256:aaa") + if got := mustFP(t, in); got != want { + t.Errorf("two references to one digest change it: %s", got) + } + // Maps have no order, but build them in many insertion orders anyway. + for i := 0; i < 20; i++ { + in = inputs() + in.Contexts = map[string]string{} + in.Toolchains = map[string]string{} + for _, k := range []string{"tool", "mail"} { + in.Contexts[k] = inputs().Contexts[k] + in.Toolchains[k] = inputs().Toolchains[k] + } + if got := mustFP(t, in); got != want { + t.Fatalf("map order changes it: %s", got) + } + } +} + +func TestFingerprintChanges(t *testing.T) { // 1203/AC3 + want := mustFP(t, inputs()) + for name, f := range map[string]func(*SourceInputs){ + "module": func(in *SourceInputs) { in.Module = "other" }, + "tree": func(in *SourceInputs) { in.Tree = "t-other" }, + "base": func(in *SourceInputs) { in.Bases[0] = "registry.example/base@sha256:zzz" }, + "a base added": func(in *SourceInputs) { in.Bases = append(in.Bases, "x@sha256:new") }, + "context": func(in *SourceInputs) { in.Contexts["mail"] = "t-changed" }, + "context artifact": func(in *SourceInputs) { in.Contexts["extra"] = "t-root" }, + "toolchain": func(in *SourceInputs) { in.Toolchains["tool"] = "go sha256:ccc changed" }, + } { + in := inputs() + f(&in) + if got := mustFP(t, in); got == want { + t.Errorf("a changed %s keeps the fingerprint", name) + } + } +} + +func TestFingerprintNone(t *testing.T) { // 1203/AC4 + in := inputs() + in.Unpinned = "the build resolves packages when built" + fp, none := SourceFingerprint(in) + if fp != "" || none != in.Unpinned { + t.Errorf("unpinned: fp = %q, none = %q", fp, none) + } + in = inputs() + in.Tree = "" + fp, none = SourceFingerprint(in) + if fp != "" || none != "no tree" { + t.Errorf("no tree: fp = %q, none = %q", fp, none) + } + in = inputs() + in.Tree, in.Unpinned = "", "the build resolves packages when built" + if _, none = SourceFingerprint(in); none != in.Unpinned { + t.Errorf("the reason for unpinned stands over no tree: %q", none) + } +} + +func TestReferenceDigest(t *testing.T) { + for in, want := range map[string]string{ + "registry.example/base@sha256:aaa": "sha256:aaa", + "registry.example/base": "registry.example/base", + "registry.example/base@": "registry.example/base@", + "": "", + } { + if got := ReferenceDigest(in); got != want { + t.Errorf("ReferenceDigest(%q) = %q, want %q", in, got, want) + } + } +}