From 605eca2c0a1deda1bd697c28c7ed98c668646bb5 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:40:35 +0000 Subject: [PATCH] Compute the source fingerprint's new form beside src1, and its key part (issue 1356) --- go/delivery/fingerprint2.go | 105 ++++++++++++++++++++ go/delivery/fingerprint2_test.go | 160 +++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 go/delivery/fingerprint2.go create mode 100644 go/delivery/fingerprint2_test.go diff --git a/go/delivery/fingerprint2.go b/go/delivery/fingerprint2.go new file mode 100644 index 0000000..23a991a --- /dev/null +++ b/go/delivery/fingerprint2.go @@ -0,0 +1,105 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" +) + +// SourcePrefix2 opens every source fingerprint of the new form. +const SourcePrefix2 = "src2:" + +// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the +// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the +// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only. +type Toolchain2 struct { + Language string + Version string + Recipe string +} + +// SourceInputs2 are what a module's source fingerprint of the new form is of (novox/hq ADR 0429 clause 5 as ADR 0437 +// clause 5 changed it; ADR 0453 clause 6). It differs from SourceInputs in exactly four things: a base enters by its +// module's fingerprint (src2:...), not its image digest; the builder enters by its own module's fingerprint, not its +// executable's digest; an artifact that resolves packages when it is built enters by the hash of its lock file; and +// an artifact's toolchain enters as Toolchain2. Module, Tree, Contexts and Unpinned are as in SourceInputs. +// +// Locks names the artifacts that resolve packages when built, each with the hash of its lock file; an entry with an +// empty hash is such an artifact with no lock, which has no fingerprint. An artifact that resolves nothing is not in +// Locks. +type SourceInputs2 struct { + Module string + Tree string + Bases []string + Builder string + Contexts map[string]string + Locks map[string]string + Toolchains map[string]Toolchain2 + Unpinned string +} + +// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` 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, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order +// of maps and a base given twice change nothing. The lines are "module ", "tree " and "builder ", then +// "base " (once each), "context ", "lock " and "toolchain +// ", sorted, all joined by a newline. +func SourceFingerprint2(in SourceInputs2) (fp, none string) { + if in.Unpinned != "" { + return "", in.Unpinned + } + if in.Tree == "" { + return "", "no tree" + } + for _, h := range in.Locks { + if h == "" { + return "", "no lock" + } + } + lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder} + bases := map[string]bool{} + for _, b := range in.Bases { + bases[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, h := range in.Locks { + lines = append(lines, "lock "+a+" "+h) + } + for a, t := range in.Toolchains { + lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe) + } + sort.Strings(lines[3:]) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return SourcePrefix2 + hex.EncodeToString(sum[:]), "" +} + +// FingerprintKeyPart is a new-form fingerprint as a key part: its 64 hex digits without the prefix, since a bus key +// may not hold ':'. It refuses a value that is not `src2:` and 64 lowercase hex digits, a src1 fingerprint included, +// naming it. +func FingerprintKeyPart(fp string) (string, error) { + part, ok := strings.CutPrefix(fp, SourcePrefix2) + if !ok || !isHex64(part) { + return "", fmt.Errorf("delivery: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, SourcePrefix2) + } + return part, nil +} + +// ParseFingerprintKeyPart is the inverse of FingerprintKeyPart. It refuses a part that is not 64 lowercase hex +// digits, a src1 fingerprint or a prefixed value included, naming it. +func ParseFingerprintKeyPart(part string) (string, error) { + if !isHex64(part) { + return "", fmt.Errorf("delivery: %q is not the key part of a fingerprint, 64 lowercase hex digits", part) + } + return SourcePrefix2 + part, nil +} + +func isHex64(s string) bool { + return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" +} diff --git a/go/delivery/fingerprint2_test.go b/go/delivery/fingerprint2_test.go new file mode 100644 index 0000000..053b8dc --- /dev/null +++ b/go/delivery/fingerprint2_test.go @@ -0,0 +1,160 @@ +package delivery + +import ( + "strings" + "testing" +) + +// The new form's inputs (novox/hq ADR 0429 clause 5 as ADR 0437 clause 5 changed it, ADR 0405 clause 7). The base and +// builder values are module fingerprints in the new form; a lock is the caller's hash of a lock file, which this +// function only enters (its form is the builder's to settle; the values here are stand-in tokens); the toolchain is a +// language, the installed toolchain's version and the build module's recipe version. +func inputs2() SourceInputs2 { + return SourceInputs2{ + Module: "mesh-mail", + Tree: "t-module", + Bases: []string{SourcePrefix2 + strings.Repeat("a", 64), SourcePrefix2 + strings.Repeat("b", 64)}, + Builder: SourcePrefix2 + strings.Repeat("c", 64), + Contexts: map[string]string{"mail": "t-root", "tool": "t-root2"}, + Locks: map[string]string{"mail": "sha256:1111", "tool": "sha256:2222"}, + Toolchains: map[string]Toolchain2{ + "mail": {Language: "go", Version: "1.26.0", Recipe: "3"}, + "tool": {Language: "typescript", Version: "5.9.2", Recipe: "1"}, + }, + } +} + +func mustFP2(t *testing.T, in SourceInputs2) string { + t.Helper() + fp, none := SourceFingerprint2(in) + if fp == "" || none != "" { + t.Fatalf("fingerprint = %q, none = %q", fp, none) + } + return fp +} + +// 1356/AC1: a fixed input has the fingerprint the test fixes, with the new prefix. The hex was computed from the function and checked against an independent sha256 of the lines; +// it is pinned here, so a change of the lines is a change of every fingerprint, and shows. +func TestFingerprint2Vector(t *testing.T) { + const want = "src2:56b6f2f6ffc1767225e0b277ab78539521e9a9fbaadfe4ec158d2fddee1d63ef" + if got := mustFP2(t, inputs2()); got != want { + t.Errorf("fingerprint = %s, want %s", got, want) + } + if SourcePrefix2 != "src2:" || SourcePrefix == SourcePrefix2 { + t.Errorf("the new prefix is %q", SourcePrefix2) + } +} + +// 1356/AC2: each input on its own changes the fingerprint. +func TestFingerprint2Changes(t *testing.T) { + want := mustFP2(t, inputs2()) + for name, f := range map[string]func(*SourceInputs2){ + "module": func(in *SourceInputs2) { in.Module = "other" }, + "tree": func(in *SourceInputs2) { in.Tree = "t-other" }, + "a base": func(in *SourceInputs2) { in.Bases[0] = SourcePrefix2 + strings.Repeat("d", 64) }, + "a base added": func(in *SourceInputs2) { in.Bases = append(in.Bases, SourcePrefix2+strings.Repeat("e", 64)) }, + "the builder": func(in *SourceInputs2) { in.Builder = SourcePrefix2 + strings.Repeat("f", 64) }, + "a lock": func(in *SourceInputs2) { in.Locks["mail"] = "sha256:changed" }, + "a lock added": func(in *SourceInputs2) { in.Locks["extra"] = "sha256:3333" }, + "a toolchain": func(in *SourceInputs2) { in.Toolchains["mail"] = Toolchain2{"go", "1.26.1", "3"} }, + "a toolchain lang": func(in *SourceInputs2) { in.Toolchains["mail"] = Toolchain2{"typescript", "1.26.0", "3"} }, + "a recipe": func(in *SourceInputs2) { in.Toolchains["mail"] = Toolchain2{"go", "1.26.0", "4"} }, + "a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" }, + "a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" }, + "a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} }, + } { + in := inputs2() + f(&in) + if got := mustFP2(t, in); got == want { + t.Errorf("a changed %s keeps the fingerprint", name) + } + } +} + +// 1356/AC3: a base given twice, and maps in any order, give the same fingerprint. +func TestFingerprint2OrderAndDuplicates(t *testing.T) { + want := mustFP2(t, inputs2()) + in := inputs2() + in.Bases = []string{in.Bases[1], in.Bases[0]} + if got := mustFP2(t, in); got != want { + t.Errorf("the order of bases changes it: %s", got) + } + in = inputs2() + in.Bases = append(in.Bases, in.Bases[0], in.Bases[1]) + if got := mustFP2(t, in); got != want { + t.Errorf("a base given twice changes it: %s", got) + } + for i := 0; i < 20; i++ { + in = inputs2() + in.Contexts, in.Locks, in.Toolchains = map[string]string{}, map[string]string{}, map[string]Toolchain2{} + for _, k := range []string{"tool", "mail"} { + in.Contexts[k] = inputs2().Contexts[k] + in.Locks[k] = inputs2().Locks[k] + in.Toolchains[k] = inputs2().Toolchains[k] + } + if got := mustFP2(t, in); got != want { + t.Fatalf("map order changes it: %s", got) + } + } +} + +// 1356/AC4: an artifact that resolves packages when built, with no lock, gives no fingerprint and the reason "no lock"; +// a module with no tree gets none with today's reason, and the reason the source is unpinned stands over both. +func TestFingerprint2None(t *testing.T) { + in := inputs2() + in.Locks["mail"] = "" + if fp, none := SourceFingerprint2(in); fp != "" || none != "no lock" { + t.Errorf("no lock: fp = %q, none = %q", fp, none) + } + in = inputs2() + in.Tree = "" + if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" { + t.Errorf("no tree: fp = %q, none = %q", fp, none) + } + in = inputs2() + in.Tree, in.Unpinned = "", "a context is a Go program's build source" + if _, none := SourceFingerprint2(in); none != in.Unpinned { + t.Errorf("the reason for unpinned stands over no tree: %q", none) + } +} + +// 1356/AC5: src1 is unchanged by the new form; its vectors in fingerprint_test.go run unchanged. This pins that the +// two forms do not collide for equal module and tree, and that src1 keeps its prefix. +func TestSource1StaysSource1(t *testing.T) { + if SourcePrefix != "src1:" { + t.Fatalf("src1's prefix is %q", SourcePrefix) + } + fp1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + fp2, _ := SourceFingerprint2(SourceInputs2{Module: "m", Tree: "t"}) + if fp1 == "" || fp2 == "" || strings.TrimPrefix(fp1, SourcePrefix) == strings.TrimPrefix(fp2, SourcePrefix2) { + t.Errorf("src1 %q and src2 %q", fp1, fp2) + } +} + +// 1356/AC6: the key part holds only bus key characters and parsing gives the fingerprint back; a src1 or malformed +// value is refused, naming it. +func TestFingerprintKeyPart(t *testing.T) { + fp := mustFP2(t, inputs2()) + part, err := FingerprintKeyPart(fp) + if err != nil { + t.Fatal(err) + } + if len(part) != 64 || strings.Trim(part, "0123456789abcdef") != "" || !nameOK.MatchString(part) { + t.Errorf("key part %q is not 64 lowercase hex characters a key allows", part) + } + back, err := ParseFingerprintKeyPart(part) + if err != nil || back != fp { + t.Errorf("parse(%q) = %q, %v; want %q", part, back, err, fp) + } + src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + for _, bad := range []string{src1, "src2:", "src2:xyz", "", "src2:" + strings.Repeat("A", 64), "src2:" + strings.Repeat("a", 63)} { + if _, err := FingerprintKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("FingerprintKeyPart(%q) = %v, want a refusal naming it", bad, err) + } + } + for _, bad := range []string{src1, "src2:" + strings.Repeat("a", 64), strings.Repeat("a", 63), strings.Repeat("A", 64), "", "zz"} { + if _, err := ParseFingerprintKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseFingerprintKeyPart(%q) = %v, want a refusal naming it", bad, err) + } + } +}