From 326e3a4717673a167fc55df734ee31483a82f4cc Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Tue, 16 Jun 2026 15:49:41 -0700 Subject: [PATCH 1/6] feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner Phase 1 (PR A1) of the schema-version-parts cutover. Adds the pure projection-substrate primitives in internal/fingerprint, beside the existing hashstructure path: the canonicalBuf length-prefixed encoder with the split omit-predicate, the fingerprint version-set tag parser, and the sha256 combiner step. Nothing is wired into ComputeIdentity and hashstructure is untouched, so no lock byte or scenario snapshot changes. Includes in-package unit tests and the phase 1 report; updates plan status. --- go.mod | 1 + go.sum | 2 + internal/fingerprint/canonical.go | 248 ++++++++++++++++++ .../fingerprint/canonical_internal_test.go | 186 +++++++++++++ internal/fingerprint/combine.go | 34 +++ internal/fingerprint/combine_internal_test.go | 82 ++++++ internal/fingerprint/fingerprint.go | 6 +- internal/fingerprint/versiontag.go | 214 +++++++++++++++ .../fingerprint/versiontag_internal_test.go | 129 +++++++++ 9 files changed, 899 insertions(+), 3 deletions(-) create mode 100644 internal/fingerprint/canonical.go create mode 100644 internal/fingerprint/canonical_internal_test.go create mode 100644 internal/fingerprint/combine.go create mode 100644 internal/fingerprint/combine_internal_test.go create mode 100644 internal/fingerprint/versiontag.go create mode 100644 internal/fingerprint/versiontag_internal_test.go diff --git a/go.mod b/go.mod index b3eaa94d..80521cc3 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/go-playground/validator/v10 v10.30.3 github.com/google/renameio v1.0.1 github.com/google/uuid v1.6.0 + github.com/gowebpki/jcs v1.0.1 github.com/h2non/gock v1.2.0 github.com/invopop/jsonschema v0.14.0 github.com/jedib0t/go-pretty/v6 v6.8.1 diff --git a/go.sum b/go.sum index 8d6fa306..3c9b943b 100644 --- a/go.sum +++ b/go.sum @@ -169,6 +169,8 @@ github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= diff --git a/internal/fingerprint/canonical.go b/internal/fingerprint/canonical.go new file mode 100644 index 00000000..8c82beed --- /dev/null +++ b/internal/fingerprint/canonical.go @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "fmt" + "reflect" +) + +// maxSafeInteger is the largest integer (2^53) that survives RFC 8785's +// ECMAScript number serialization without precision loss. An integer of greater +// magnitude is rejected rather than silently coerced - such a value is almost +// always an identifier or hash that belongs in the config as a string. +const maxSafeInteger = 1 << 53 + +// treeBuilder accumulates the canonical projection of a struct as a JSON-able +// map[string]any. The omit predicates mirror the frozen projection semantics: +// +// - emit: scalar leaf, omit-if-zero (the common "active field" path). +// - emitAlways: scalar leaf emitted even at its zero value (the '!' case, for a +// field whose zero is build-meaningful). +// - emitMap: scalar-valued map measuring key membership - every entry is kept +// (so {"k":""} differs from {}); the whole map is omitted only when empty. +// - emitComposite: nested-struct projection, omitted on projected emptiness (the +// sub-projector produced no measured keys). +// - emitSlice: struct-slice projection, omitted when empty; order is significant. +// +// Map key ordering is irrelevant here: RFC 8785 sorts object keys at +// serialization, so the builder need not sort. The first error is deferred +// (bufio-style) and surfaces from result, keeping the projectors readable. +type treeBuilder struct { + out map[string]any + err error +} + +func (b *treeBuilder) set(key string, value any) { + if b.out == nil { + b.out = map[string]any{} + } + + b.out[key] = value +} + +// emit adds a scalar-leaf field, omitting it when its resolved value is zero. +func (b *treeBuilder) emit(key string, value any) { + b.emitScalar(key, value, false) +} + +// emitAlways adds a scalar-leaf field even when its value is zero (the '!' case). +func (b *treeBuilder) emitAlways(key string, value any) { + b.emitScalar(key, value, true) +} + +func (b *treeBuilder) emitScalar(key string, value any, always bool) { + if b.err != nil { + return + } + + rval := reflect.ValueOf(value) + if !rval.IsValid() { + b.err = fmt.Errorf("emit %#q: a nil value has no fingerprint encoding", key) + + return + } + + if !isScalarLeaf(rval) { + b.err = fmt.Errorf( + "emit %#q: kind %s is not an encodable scalar leaf; composites use emitComposite, emitSlice, or emitMap", + key, rval.Kind()) + + return + } + + if !always && isScalarZero(rval) { + return + } + + encoded, err := scalarToJSON(rval) + if err != nil { + b.err = fmt.Errorf("encoding field %#q:\n%w", key, err) + + return + } + + b.set(key, encoded) +} + +// emitMap adds a scalar-valued map, measuring key membership. Struct-valued maps +// are a composite and must be projected entry-by-entry through emitComposite. +func (b *treeBuilder) emitMap(key string, mapValue any) { + if b.err != nil { + return + } + + rval := reflect.ValueOf(mapValue) + if !rval.IsValid() || rval.Kind() != reflect.Map { + b.err = fmt.Errorf("emitMap %#q: value is not a map", key) + + return + } + + if rval.Type().Key().Kind() != reflect.String { + b.err = fmt.Errorf("emitMap %#q: map key kind %s is not string", key, rval.Type().Key().Kind()) + + return + } + + if rval.Len() == 0 { + return + } + + entries := make(map[string]any, rval.Len()) + + iter := rval.MapRange() + for iter.Next() { + encoded, err := scalarToJSON(iter.Value()) + if err != nil { + b.err = fmt.Errorf("encoding map %#q entry %#q:\n%w", key, iter.Key().String(), err) + + return + } + + entries[iter.Key().String()] = encoded + } + + b.set(key, entries) +} + +// emitComposite adds a nested-struct projection, omitting it when the +// sub-projector produced no measured keys (projected emptiness). +func (b *treeBuilder) emitComposite(key string, sub map[string]any) { + if len(sub) == 0 { + return + } + + b.set(key, sub) +} + +// emitSlice adds a struct-slice projection, omitting it when empty. A JSON array +// preserves order, so reordering elements is a different encoding. +func (b *treeBuilder) emitSlice(key string, elems []any) { + if len(elems) == 0 { + return + } + + b.set(key, elems) +} + +// result returns the accumulated projection map (nil when nothing was emitted) +// or the first deferred error. +func (b *treeBuilder) result() (map[string]any, error) { + if b.err != nil { + return nil, b.err + } + + return b.out, nil +} + +// scalarToJSON converts a scalar leaf to its JSON-able Go value by underlying +// kind, so a named type is encoded by kind rather than via any MarshalJSON / +// MarshalText it might carry. An integer beyond the JSON-safe range is rejected +// rather than silently coerced by RFC 8785's number model (see maxSafeInteger). +func scalarToJSON(rval reflect.Value) (any, error) { + //exhaustive:ignore // the default branch deliberately rejects every un-pinned kind. + switch rval.Kind() { + case reflect.String: + return rval.String(), nil + case reflect.Bool: + return rval.Bool(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number := rval.Int() + if number > maxSafeInteger || number < -maxSafeInteger { + return nil, fmt.Errorf( + "integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number) + } + + return number, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + number := rval.Uint() + if number > maxSafeInteger { + return nil, fmt.Errorf( + "integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number) + } + + return number, nil + case reflect.Slice: + out := make([]any, 0, rval.Len()) + for i := range rval.Len() { + elem, err := scalarToJSON(rval.Index(i)) + if err != nil { + return nil, fmt.Errorf("slice element %d:\n%w", i, err) + } + + out = append(out, elem) + } + + return out, nil + default: + return nil, fmt.Errorf( + "kind %s is not encodable: only string, bool, JSON-safe integers, and slices of those are pinned", + rval.Kind()) + } +} + +// isScalarZero is the omit-if-zero predicate for a scalar leaf. A nil AND a +// non-nil empty scalar slice both count as zero, so emit cannot distinguish them +// (resolution's mergo WithAppendSlice merge yields either for the same intent). +// emitAlways bypasses this, so an explicit empty '!' slice still emits. Non-slice +// scalars fall back to plain IsZero, unchanged. +func isScalarZero(rval reflect.Value) bool { + if rval.Kind() == reflect.Slice { + return rval.Len() == 0 + } + + return rval.IsZero() +} + +// isScalarLeaf reports whether v is a scalar leaf for omit-predicate purposes: +// a scalar kind, or a slice whose element kind is scalar. Composites (struct, +// map, slice-of-struct, pointer, interface, ...) are not scalar leaves. +func isScalarLeaf(rval reflect.Value) bool { + if isScalarKind(rval.Kind()) { + return true + } + + if rval.Kind() == reflect.Slice { + return isScalarKind(rval.Type().Elem().Kind()) + } + + return false +} + +// isScalarKind reports whether k is a scalar kind pinned by the v1 encoding: +// string, bool, and the sized signed/unsigned integers. Named +// scalar types (e.g. SpecSourceType) report their underlying kind here, so they +// are measured by their underlying kind rather than failing. +func isScalarKind(k reflect.Kind) bool { + //exhaustive:ignore // the default branch deliberately rejects every other kind. + switch k { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return true + default: + return false + } +} diff --git a/internal/fingerprint/canonical_internal_test.go b/internal/fingerprint/canonical_internal_test.go new file mode 100644 index 00000000..18bf74e7 --- /dev/null +++ b/internal/fingerprint/canonical_internal_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// namedString and namedInt model the measured named scalar types in the config +// graph (e.g. SpecSourceType, ReleaseCalculation): they must encode by their +// underlying kind, not via any marshaler the named type might carry. +type ( + namedString string + namedInt int64 +) + +func mustResult(t *testing.T, b *treeBuilder) map[string]any { + t.Helper() + + tree, err := b.result() + require.NoError(t, err) + + return tree +} + +func TestTreeBuilder_OmitIfZeroVsAlways(t *testing.T) { + tests := []struct { + name string + value any + wantEmit map[string]any // emit (omit-if-zero) + wantAlways map[string]any // emitAlways + }{ + {name: "zero bool", value: false, wantEmit: nil, wantAlways: map[string]any{"key": false}}, + {name: "zero int", value: 0, wantEmit: nil, wantAlways: map[string]any{"key": int64(0)}}, + {name: "empty string", value: "", wantEmit: nil, wantAlways: map[string]any{"key": ""}}, + {name: "nil slice", value: []string(nil), wantEmit: nil, wantAlways: map[string]any{"key": []any{}}}, + {name: "non-zero string", value: "v", wantEmit: map[string]any{"key": "v"}, wantAlways: map[string]any{"key": "v"}}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + var omit treeBuilder + omit.emit("key", testCase.value) + assert.Equal(t, testCase.wantEmit, mustResult(t, &omit), "emit (omit-if-zero)") + + var always treeBuilder + always.emitAlways("key", testCase.value) + assert.Equal(t, testCase.wantAlways, mustResult(t, &always), "emitAlways") + }) + } +} + +func TestScalarToJSON_ByKind(t *testing.T) { + tests := []struct { + name string + value any + want any + }{ + {name: "string", value: "hello", want: "hello"}, + {name: "bool", value: true, want: true}, + {name: "int", value: 42, want: int64(42)}, + {name: "negative int", value: int64(-7), want: int64(-7)}, + {name: "uint", value: uint(255), want: uint64(255)}, + {name: "named string by underlying kind", value: namedString("x"), want: "x"}, + {name: "named int by underlying kind", value: namedInt(9), want: int64(9)}, + {name: "string slice in order", value: []string{"a", "bb"}, want: []any{"a", "bb"}}, + {name: "int slice", value: []int{1, 22}, want: []any{int64(1), int64(22)}}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got, err := scalarToJSON(reflect.ValueOf(testCase.value)) + require.NoError(t, err) + assert.Equal(t, testCase.want, got) + }) + } +} + +// TestScalarToJSON_RejectsUnsafeIntegers pins the one bounded special case the +// JCS substrate needs: an integer outside [-2^53, 2^53] cannot survive RFC 8785's +// ECMAScript number serialization, so it is rejected rather than silently coerced. +func TestScalarToJSON_RejectsUnsafeIntegers(t *testing.T) { + _, err := scalarToJSON(reflect.ValueOf(int64(maxSafeInteger))) + require.NoError(t, err, "2^53 is exactly representable and allowed") + + _, err = scalarToJSON(reflect.ValueOf(int64(maxSafeInteger + 1))) + require.Error(t, err, "2^53+1 cannot survive RFC 8785's number model") + + _, err = scalarToJSON(reflect.ValueOf(int64(-(maxSafeInteger + 1)))) + require.Error(t, err, "-(2^53+1) is rejected too") + + _, err = scalarToJSON(reflect.ValueOf(uint64(maxSafeInteger + 1))) + require.Error(t, err, "a uint64 above 2^53 is rejected") +} + +func TestScalarToJSON_UnpinnedKindsFail(t *testing.T) { + tests := []struct { + name string + value any + }{ + {name: "float64", value: float64(1.5)}, + {name: "float32", value: float32(1.5)}, + {name: "complex", value: complex128(1 + 2i)}, + {name: "uintptr", value: uintptr(8)}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + _, err := scalarToJSON(reflect.ValueOf(testCase.value)) + require.Error(t, err, "the default-fail branch must reject un-pinned kinds") + }) + } +} + +func TestTreeBuilder_EmitRejectsCompositesAndNil(t *testing.T) { + type nested struct{ A string } + + var structBuf treeBuilder + structBuf.emit("build", nested{A: "x"}) + _, err := structBuf.result() + require.Error(t, err, "a struct must go through emitComposite, not emit") + + var mapBuf treeBuilder + mapBuf.emit("defines", map[string]string{"a": "1"}) + _, err = mapBuf.result() + require.Error(t, err, "a map must go through emitMap, not emit") + + var sliceBuf treeBuilder + sliceBuf.emit("overlays", []nested{{A: "x"}}) + _, err = sliceBuf.result() + require.Error(t, err, "a slice-of-struct must go through emitSlice, not emit") + + var nilBuf treeBuilder + nilBuf.emit("key", nil) + _, err = nilBuf.result() + require.Error(t, err, "a nil value has no encoding") +} + +func TestTreeBuilder_EmitMapMembershipAndOmit(t *testing.T) { + var withEmptyValue treeBuilder + withEmptyValue.emitMap("defines", map[string]string{"k": ""}) + assert.Equal(t, map[string]any{"defines": map[string]any{"k": ""}}, mustResult(t, &withEmptyValue), + `{"k":""} is measured content`) + + var empty treeBuilder + empty.emitMap("defines", map[string]string{}) + assert.Nil(t, mustResult(t, &empty), "empty map is omitted") + + var nilMap treeBuilder + nilMap.emitMap("defines", map[string]string(nil)) + assert.Nil(t, mustResult(t, &nilMap), "nil map is omitted") +} + +func TestTreeBuilder_EmitMapErrors(t *testing.T) { + var nonString treeBuilder + nonString.emitMap("byID", map[int]string{1: "a"}) + _, err := nonString.result() + require.Error(t, err, "a non-string map key is rejected") + + var unpinned treeBuilder + unpinned.emitMap("ratios", map[string]float64{"a": 1.5}) + _, err = unpinned.result() + require.Error(t, err, "a non-scalar map value reaches the default-fail branch") +} + +func TestTreeBuilder_CompositeAndSliceProjectedEmptiness(t *testing.T) { + var omitted treeBuilder + omitted.emitComposite("build", nil) + omitted.emitComposite("spec", map[string]any{}) + omitted.emitSlice("overlays", nil) + omitted.emitSlice("source-files", []any{}) + assert.Nil(t, mustResult(t, &omitted), "composites/slices that projected empty are omitted") + + var emitted treeBuilder + emitted.emitComposite("build", map[string]any{"with": []any{"x"}}) + emitted.emitSlice("overlays", []any{map[string]any{"tag": "a"}}) + assert.Equal(t, map[string]any{ + "build": map[string]any{"with": []any{"x"}}, + "overlays": []any{map[string]any{"tag": "a"}}, + }, mustResult(t, &emitted)) +} diff --git a/internal/fingerprint/combine.go b/internal/fingerprint/combine.go new file mode 100644 index 00000000..e445d885 --- /dev/null +++ b/internal/fingerprint/combine.go @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/gowebpki/jcs" +) + +// canonicalDigest serializes the fingerprint document to RFC 8785 (JCS) canonical +// JSON and returns its sha256 as a "sha256:" string. The document's object +// keys provide domain separation between the config projection and the non-config +// inputs, so no manual length-prefixing is needed; JCS sorts keys and pins number +// and string formatting, so the bytes are stable across runs and Go versions. +func canonicalDigest(document map[string]any) (string, error) { + raw, err := json.Marshal(document) + if err != nil { + return "", fmt.Errorf("marshaling fingerprint document:\n%w", err) + } + + canonical, err := jcs.Transform(raw) + if err != nil { + return "", fmt.Errorf("canonicalizing fingerprint document:\n%w", err) + } + + sum := sha256.Sum256(canonical) + + return "sha256:" + hex.EncodeToString(sum[:]), nil +} diff --git a/internal/fingerprint/combine_internal_test.go b/internal/fingerprint/combine_internal_test.go new file mode 100644 index 00000000..2c62fe11 --- /dev/null +++ b/internal/fingerprint/combine_internal_test.go @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func baseDocument() map[string]any { + return map[string]any{ + "config": map[string]any{"spec": map[string]any{"upstream-name": "x"}}, + "sourceIdentity": "abc123", + "manualBump": 0, + "releaseVer": "4.0", + "overlays": map[string]any{"0": "ovl0"}, + } +} + +func TestCanonicalDigest_Deterministic(t *testing.T) { + digest1, err := canonicalDigest(baseDocument()) + require.NoError(t, err) + + digest2, err := canonicalDigest(baseDocument()) + require.NoError(t, err) + + assert.Equal(t, digest1, digest2, "identical documents must produce identical digests") + assert.Contains(t, digest1, "sha256:", "digest carries the sha256: prefix") +} + +func TestCanonicalDigest_InputsChangeDigest(t *testing.T) { + base, err := canonicalDigest(baseDocument()) + require.NoError(t, err) + + tests := []struct { + name string + mutate func(map[string]any) + }{ + {name: "config", mutate: func(d map[string]any) { + d["config"] = map[string]any{"spec": map[string]any{"upstream-name": "y"}} + }}, + {name: "source identity", mutate: func(d map[string]any) { d["sourceIdentity"] = "def456" }}, + {name: "manual bump", mutate: func(d map[string]any) { d["manualBump"] = 1 }}, + {name: "release ver", mutate: func(d map[string]any) { d["releaseVer"] = "5.0" }}, + {name: "changed overlay", mutate: func(d map[string]any) { + d["overlays"] = map[string]any{"0": "ovl0-changed"} + }}, + {name: "added overlay", mutate: func(d map[string]any) { + d["overlays"] = map[string]any{"0": "ovl0", "1": "ovl1"} + }}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + doc := baseDocument() + testCase.mutate(doc) + + got, err := canonicalDigest(doc) + require.NoError(t, err) + assert.NotEqual(t, base, got) + }) + } +} + +// TestCanonicalDigest_KeyOrderIndependent confirms RFC 8785 sorts object keys, so +// Go map insertion order cannot affect the digest - the property that lets the +// document's keys provide domain separation without manual length-prefixing. +func TestCanonicalDigest_KeyOrderIndependent(t *testing.T) { + a := map[string]any{"config": map[string]any{}, "manualBump": 0, "releaseVer": "4.0", "sourceIdentity": "id"} + b := map[string]any{"sourceIdentity": "id", "releaseVer": "4.0", "config": map[string]any{}, "manualBump": 0} + + digestA, err := canonicalDigest(a) + require.NoError(t, err) + + digestB, err := canonicalDigest(b) + require.NoError(t, err) + + assert.Equal(t, digestA, digestB, "RFC 8785 key sorting makes the digest independent of map order") +} diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go index 646e317b..e3601803 100644 --- a/internal/fingerprint/fingerprint.go +++ b/internal/fingerprint/fingerprint.go @@ -16,9 +16,9 @@ import ( "github.com/mitchellh/hashstructure/v2" ) -// hashstructureTagName is the struct tag name used by hashstructure to determine +// fingerprintTagName is the struct tag name used by hashstructure to determine // field inclusion. Fields tagged with `fingerprint:"-"` are excluded. -const hashstructureTagName = "fingerprint" +const fingerprintTagName = "fingerprint" // ComponentIdentity holds the computed fingerprint for a single component plus // a breakdown of individual input hashes for debugging. @@ -108,7 +108,7 @@ func ComputeIdentity( // 3. Hash the resolved config struct (excluding fingerprint:"-" fields). configHash, err := hashstructure.Hash(component, hashstructure.FormatV2, &hashstructure.HashOptions{ - TagName: hashstructureTagName, + TagName: fingerprintTagName, }) if err != nil { return nil, fmt.Errorf("hashing component config:\n%w", err) diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go new file mode 100644 index 00000000..de5afc2d --- /dev/null +++ b/internal/fingerprint/versiontag.go @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +// versionOpen is the high-bound sentinel for an open-ended range ("*"), meaning +// "this version and every later one". +const versionOpen = -1 + +// excludeTag is the fingerprint tag that marks a field as never measured. +const excludeTag = "-" + +// keyPrefix is the fingerprint-tag prefix for an explicit emit-key override. +const keyPrefix = "key=" + +// versionRange is one half-open or closed membership range in a version set. +// A range covers [low, high]; high == versionOpen means open-ended ("*"). +type versionRange struct { + low int + high int + alwaysEmit bool +} + +// versionSet is the parsed form of a field's fingerprint tag: the set of +// content versions that measure the field, whether each range always-emits, and +// an optional emit-key override. +type versionSet struct { + // excluded is true when the tag is "-" (the field is never measured). + excluded bool + // emitKey is the explicit "key=" override, or "" to use the field's toml key. + emitKey string + // ranges are the membership ranges, sorted by low bound and non-overlapping. + ranges []versionRange +} + +// parseVersionSet parses a fingerprint struct-tag value against currentVersion +// (the highest content version that exists). It rejects malformed, overlapping, +// future-referencing (any bound above currentVersion), and duplicate-key= tags. +// An absent tag is the caller's responsibility: the empty string is rejected +// here because every fingerprinted field must carry an explicit decision. +func parseVersionSet(tag string, currentVersion int) (versionSet, error) { + if tag == excludeTag { + return versionSet{excluded: true}, nil + } + + if tag == "" { + return versionSet{}, errors.New("empty fingerprint tag: every fingerprinted field must carry an explicit decision") + } + + members := strings.Split(tag, ",") + + set := versionSet{} + + // An optional key= override must be the first comma-separated element. + if strings.HasPrefix(members[0], keyPrefix) { + emitKey := strings.TrimPrefix(members[0], keyPrefix) + if emitKey == "" { + return versionSet{}, fmt.Errorf("empty key= override in fingerprint tag %#q", tag) + } + + set.emitKey = emitKey + members = members[1:] + } + + if len(members) == 0 { + return versionSet{}, fmt.Errorf("fingerprint tag %#q has a key= override but no version ranges", tag) + } + + for _, member := range members { + if strings.HasPrefix(member, keyPrefix) { + return versionSet{}, fmt.Errorf( + "fingerprint tag %#q: a key= override must be the first tag element", tag) + } + + rng, err := parseRange(member, currentVersion) + if err != nil { + return versionSet{}, fmt.Errorf("fingerprint tag %#q:\n%w", tag, err) + } + + set.ranges = append(set.ranges, rng) + } + + if err := sortAndValidateRanges(set.ranges); err != nil { + return versionSet{}, fmt.Errorf("fingerprint tag %#q:\n%w", tag, err) + } + + return set, nil +} + +// parseRange parses a single "[!]vLow[..(vHigh|*)]" member. +func parseRange(member string, currentVersion int) (versionRange, error) { + rng := versionRange{} + + if strings.HasPrefix(member, "!") { + rng.alwaysEmit = true + member = strings.TrimPrefix(member, "!") + } + + low, high, found := strings.Cut(member, "..") + + lowVersion, err := parseVersion(low) + if err != nil { + return versionRange{}, err + } + + rng.low = lowVersion + + switch { + case !found: + // "vN" is shorthand for the single-version range vN..vN. + rng.high = lowVersion + case high == "*": + rng.high = versionOpen + default: + highVersion, highErr := parseVersion(high) + if highErr != nil { + return versionRange{}, highErr + } + + if highVersion < lowVersion { + return versionRange{}, fmt.Errorf("range %#q is inverted: high bound is below low bound", member) + } + + rng.high = highVersion + } + + if rng.low > currentVersion { + return versionRange{}, fmt.Errorf( + "range %#q references v%d, which is beyond the current version v%d", member, rng.low, currentVersion) + } + + if rng.high != versionOpen && rng.high > currentVersion { + return versionRange{}, fmt.Errorf( + "range %#q references v%d, which is beyond the current version v%d", member, rng.high, currentVersion) + } + + return rng, nil +} + +// parseVersion parses a "vN" token into its integer N (N >= 1). +func parseVersion(token string) (int, error) { + if !strings.HasPrefix(token, "v") { + return 0, fmt.Errorf("version %#q must start with 'v'", token) + } + + digits := strings.TrimPrefix(token, "v") + + version, err := strconv.Atoi(digits) + if err != nil { + return 0, fmt.Errorf("version %#q has a non-numeric component", token) + } + + if version < 1 { + return 0, fmt.Errorf("version %#q must be v1 or greater", token) + } + + return version, nil +} + +// sortAndValidateRanges sorts the ranges by low bound and rejects any overlap, +// so a version can never match two ranges (which would alias two always-emit +// flags). +func sortAndValidateRanges(ranges []versionRange) error { + sort.Slice(ranges, func(i, j int) bool { + return ranges[i].low < ranges[j].low + }) + + for i := 1; i < len(ranges); i++ { + previous := ranges[i-1] + current := ranges[i] + + if previous.high == versionOpen || current.low <= previous.high { + return fmt.Errorf("overlapping ranges at v%d and v%d", previous.low, current.low) + } + } + + return nil +} + +// measuredAt reports whether the field is measured at the given content version +// and, if so, whether that version always-emits the field (the '!' flag). +func (s versionSet) measuredAt(version int) (measured bool, alwaysEmit bool) { + for _, rng := range s.ranges { + if version >= rng.low && (rng.high == versionOpen || version <= rng.high) { + return true, rng.alwaysEmit + } + } + + return false, false +} + +// resolveEmitKey returns the stable emit-key for the field: the explicit key= +// override if present, otherwise the field's toml key. A field with neither a +// usable toml key nor a key= override is an error, because its bytes could not +// be pinned to a stable string. +func (s versionSet) resolveEmitKey(tomlKey string) (string, error) { + if s.emitKey != "" { + return s.emitKey, nil + } + + if tomlKey == "" || tomlKey == excludeTag { + return "", errors.New("field has no usable toml key and no key= override in its fingerprint tag") + } + + return tomlKey, nil +} diff --git a/internal/fingerprint/versiontag_internal_test.go b/internal/fingerprint/versiontag_internal_test.go new file mode 100644 index 00000000..954340de --- /dev/null +++ b/internal/fingerprint/versiontag_internal_test.go @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVersionSet_Exclude(t *testing.T) { + set, err := parseVersionSet("-", 1) + require.NoError(t, err) + assert.True(t, set.excluded) + + measured, _ := set.measuredAt(1) + assert.False(t, measured, "an excluded field is never measured") +} + +func TestParseVersionSet_NonContiguousMembership(t *testing.T) { + // v1..v1,v3..* : measured at v1, dropped at v2, brought back from v3. + set, err := parseVersionSet("v1..v1,v3..*", 3) + require.NoError(t, err) + + cases := []struct { + version int + measured bool + }{ + {1, true}, + {2, false}, + {3, true}, + {4, true}, + } + + for _, want := range cases { + measured, always := set.measuredAt(want.version) + assert.Equal(t, want.measured, measured, "measuredAt(v%d)", want.version) + assert.False(t, always, "no range in this set is always-emit") + } +} + +func TestParseVersionSet_AlwaysEmit(t *testing.T) { + set, err := parseVersionSet("!v1..*", 1) + require.NoError(t, err) + + measured, always := set.measuredAt(1) + assert.True(t, measured) + assert.True(t, always, "! reports always-emit") +} + +func TestParseVersionSet_TemporalAlwaysToggle(t *testing.T) { + // v1..v4,!v5..* : omit-if-zero through v4, always-emit from v5. + set, err := parseVersionSet("v1..v4,!v5..*", 5) + require.NoError(t, err) + + _, alwaysV4 := set.measuredAt(4) + assert.False(t, alwaysV4, "v4 is omit-if-zero") + + measuredV5, alwaysV5 := set.measuredAt(5) + assert.True(t, measuredV5) + assert.True(t, alwaysV5, "v5 onward is always-emit") +} + +func TestParseVersionSet_SingleVersionShorthand(t *testing.T) { + set, err := parseVersionSet("v1", 1) + require.NoError(t, err) + + measuredV1, _ := set.measuredAt(1) + assert.True(t, measuredV1) + + measuredV2, _ := set.measuredAt(2) + assert.False(t, measuredV2, "vN shorthand is the single-version range vN..vN") +} + +func TestParseVersionSet_Rejects(t *testing.T) { + tests := []struct { + name string + tag string + currentVersion int + }{ + {"empty tag", "", 1}, + {"no v prefix", "1..*", 1}, + {"non-numeric version", "vx", 1}, + {"version zero", "v0", 1}, + {"dangling range", "v1..", 1}, + {"inverted range", "v3..v1", 3}, + {"non-numeric high", "v1..vx", 3}, + {"overlapping closed ranges", "v1..v3,v2..*", 5}, + {"overlapping open ranges", "v1..*,v2..*", 5}, + {"touching ranges share an endpoint", "v1..v3,v3..v5", 5}, + {"future-referencing low", "v3..*", 1}, + {"future-referencing high", "v1..v5", 1}, + {"duplicate key override", "key=foo,key=bar", 1}, + {"key override not first", "v1..*,key=foo", 1}, + {"empty key override", "key=,v1..*", 1}, + {"key override without ranges", "key=foo", 1}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseVersionSet(testCase.tag, testCase.currentVersion) + require.Error(t, err) + }) + } +} + +func TestVersionSet_ResolveEmitKey(t *testing.T) { + withOverride, err := parseVersionSet("key=upstream-name,v1..*", 1) + require.NoError(t, err) + + key, err := withOverride.resolveEmitKey("upstream") + require.NoError(t, err) + assert.Equal(t, "upstream-name", key, "key= override wins over the toml key") + + noOverride, err := parseVersionSet("v1..*", 1) + require.NoError(t, err) + + key, err = noOverride.resolveEmitKey("upstream") + require.NoError(t, err) + assert.Equal(t, "upstream", key, "the toml key is the default emit-key") + + _, err = noOverride.resolveEmitKey("") + require.Error(t, err, "no toml key and no override is an error") + + _, err = noOverride.resolveEmitKey("-") + require.Error(t, err, "a '-' toml key is not usable") +} From 24d6ef3982a33736557cd1568ed3f889c20fc0d2 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 25 Jun 2026 08:31:54 -0700 Subject: [PATCH 2/6] fixup! feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner --- internal/fingerprint/canonical.go | 6 +++++ internal/fingerprint/versiontag.go | 35 ++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/fingerprint/canonical.go b/internal/fingerprint/canonical.go index 8c82beed..d510fa8a 100644 --- a/internal/fingerprint/canonical.go +++ b/internal/fingerprint/canonical.go @@ -185,6 +185,12 @@ func scalarToJSON(rval reflect.Value) (any, error) { return number, nil case reflect.Slice: + if rval.Type().Elem().Kind() == reflect.Uint8 { + return nil, fmt.Errorf( + "%s is not encodable at v1: a byte slice ([]byte) must be represented as a string in the config schema", + rval.Type()) + } + out := make([]any, 0, rval.Len()) for i := range rval.Len() { elem, err := scalarToJSON(rval.Index(i)) diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go index de5afc2d..a07ba733 100644 --- a/internal/fingerprint/versiontag.go +++ b/internal/fingerprint/versiontag.go @@ -21,7 +21,7 @@ const excludeTag = "-" // keyPrefix is the fingerprint-tag prefix for an explicit emit-key override. const keyPrefix = "key=" -// versionRange is one half-open or closed membership range in a version set. +// versionRange is one closed (inclusive) membership range in a version set. // A range covers [low, high]; high == versionOpen means open-ended ("*"). type versionRange struct { low int @@ -66,6 +66,12 @@ func parseVersionSet(tag string, currentVersion int) (versionSet, error) { return versionSet{}, fmt.Errorf("empty key= override in fingerprint tag %#q", tag) } + if !isValidEmitKey(emitKey) { + return versionSet{}, fmt.Errorf( + "invalid key= override %#q in fingerprint tag %#q: must be a bare identifier "+ + "(letters, digits, '-', '_', '.')", emitKey, tag) + } + set.emitKey = emitKey members = members[1:] } @@ -95,10 +101,31 @@ func parseVersionSet(tag string, currentVersion int) (versionSet, error) { return set, nil } +// isValidEmitKey reports whether s is a bare emit-key identifier - the character +// set of a frozen TOML key (letters, digits, '-', '_', '.'). The grammar's key= +// override takes an identifier, so a malformed value like "foo bar" is rejected +// up front rather than silently freezing an odd emit-key. +func isValidEmitKey(s string) bool { + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + default: + return false + } + } + + return s != "" +} + // parseRange parses a single "[!]vLow[..(vHigh|*)]" member. func parseRange(member string, currentVersion int) (versionRange, error) { rng := versionRange{} + // rangeText keeps the original member (including any leading '!') for error + // messages, so a malformed "!v3..v1" is reported as written, not as "v3..v1". + rangeText := member + if strings.HasPrefix(member, "!") { rng.alwaysEmit = true member = strings.TrimPrefix(member, "!") @@ -126,7 +153,7 @@ func parseRange(member string, currentVersion int) (versionRange, error) { } if highVersion < lowVersion { - return versionRange{}, fmt.Errorf("range %#q is inverted: high bound is below low bound", member) + return versionRange{}, fmt.Errorf("range %#q is inverted: high bound is below low bound", rangeText) } rng.high = highVersion @@ -134,12 +161,12 @@ func parseRange(member string, currentVersion int) (versionRange, error) { if rng.low > currentVersion { return versionRange{}, fmt.Errorf( - "range %#q references v%d, which is beyond the current version v%d", member, rng.low, currentVersion) + "range %#q references v%d, which is beyond the current version v%d", rangeText, rng.low, currentVersion) } if rng.high != versionOpen && rng.high > currentVersion { return versionRange{}, fmt.Errorf( - "range %#q references v%d, which is beyond the current version v%d", member, rng.high, currentVersion) + "range %#q references v%d, which is beyond the current version v%d", rangeText, rng.high, currentVersion) } return rng, nil From 7b6bbc1511b4baa7ba3c6a2c22728cf79faee546 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 25 Jun 2026 08:53:08 -0700 Subject: [PATCH 3/6] fixup! feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner --- .../fingerprint/canonical_internal_test.go | 14 ++++++++++ internal/fingerprint/versiontag.go | 28 +++++++++++++++---- .../fingerprint/versiontag_internal_test.go | 26 +++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/internal/fingerprint/canonical_internal_test.go b/internal/fingerprint/canonical_internal_test.go index 18bf74e7..f2f80001 100644 --- a/internal/fingerprint/canonical_internal_test.go +++ b/internal/fingerprint/canonical_internal_test.go @@ -88,6 +88,12 @@ func TestScalarToJSON_RejectsUnsafeIntegers(t *testing.T) { _, err := scalarToJSON(reflect.ValueOf(int64(maxSafeInteger))) require.NoError(t, err, "2^53 is exactly representable and allowed") + _, err = scalarToJSON(reflect.ValueOf(int64(-maxSafeInteger))) + require.NoError(t, err, "-2^53 is exactly representable and allowed (the negative boundary)") + + _, err = scalarToJSON(reflect.ValueOf(uint64(maxSafeInteger))) + require.NoError(t, err, "a uint64 at exactly 2^53 is allowed (the unsigned boundary)") + _, err = scalarToJSON(reflect.ValueOf(int64(maxSafeInteger + 1))) require.Error(t, err, "2^53+1 cannot survive RFC 8785's number model") @@ -98,6 +104,14 @@ func TestScalarToJSON_RejectsUnsafeIntegers(t *testing.T) { require.Error(t, err, "a uint64 above 2^53 is rejected") } +// TestScalarToJSON_RejectsByteSlice pins that a []byte is rejected rather than +// encoded as a JSON number array: the RFC lists []byte-style values as +// fail-generation, so the encoder refuses one (it must be a string instead). +func TestScalarToJSON_RejectsByteSlice(t *testing.T) { + _, err := scalarToJSON(reflect.ValueOf([]byte("abc"))) + require.Error(t, err, "a byte slice ([]byte) is not encodable at v1") +} + func TestScalarToJSON_UnpinnedKindsFail(t *testing.T) { tests := []struct { name string diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go index a07ba733..5fe45c44 100644 --- a/internal/fingerprint/versiontag.go +++ b/internal/fingerprint/versiontag.go @@ -106,16 +106,34 @@ func parseVersionSet(tag string, currentVersion int) (versionSet, error) { // override takes an identifier, so a malformed value like "foo bar" is rejected // up front rather than silently freezing an odd emit-key. func isValidEmitKey(s string) bool { + if s == "" { + return false + } + for _, r := range s { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', - r == '-', r == '_', r == '.': - default: + if !isEmitKeyChar(r) { return false } } - return s != "" + return true +} + +// isEmitKeyChar reports whether r is allowed in an emit-key: a letter, digit, +// '-', '_', or '.'. +func isEmitKeyChar(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '-', r == '_', r == '.': + return true + default: + return false + } } // parseRange parses a single "[!]vLow[..(vHigh|*)]" member. diff --git a/internal/fingerprint/versiontag_internal_test.go b/internal/fingerprint/versiontag_internal_test.go index 954340de..6a0113d3 100644 --- a/internal/fingerprint/versiontag_internal_test.go +++ b/internal/fingerprint/versiontag_internal_test.go @@ -96,6 +96,8 @@ func TestParseVersionSet_Rejects(t *testing.T) { {"key override not first", "v1..*,key=foo", 1}, {"empty key override", "key=,v1..*", 1}, {"key override without ranges", "key=foo", 1}, + {"key override with space", "key=foo bar,v1..*", 1}, + {"key override with symbol", "key=foo@bar,v1..*", 1}, } for _, testCase := range tests { @@ -127,3 +129,27 @@ func TestVersionSet_ResolveEmitKey(t *testing.T) { _, err = noOverride.resolveEmitKey("-") require.Error(t, err, "a '-' toml key is not usable") } + +// TestVersionSet_EmitKeyAcceptsIdentifierChars exercises every allowed emit-key +// character class (lowercase, uppercase, digit, '-', '_', '.') so a boundary +// change to isValidEmitKey's char ranges is caught. +func TestVersionSet_EmitKeyAcceptsIdentifierChars(t *testing.T) { + set, err := parseVersionSet("key=az.AZ-09_,v1..*", 1) + require.NoError(t, err) + + key, err := set.resolveEmitKey("ignored") + require.NoError(t, err) + assert.Equal(t, "az.AZ-09_", key, "a key= override of allowed identifier chars is accepted verbatim") +} + +// TestParseRange_ErrorPreservesBangPrefix pins that range errors report the +// original member text including a leading '!', not the stripped form. +func TestParseRange_ErrorPreservesBangPrefix(t *testing.T) { + _, err := parseVersionSet("!v3..v1", 3) + require.Error(t, err) + assert.Contains(t, err.Error(), "!v3..v1", "an inverted '!'-range is reported as written, with '!'") + + _, err = parseVersionSet("!v3..*", 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "!v3..*", "a future-referencing '!'-range keeps the '!'") +} From e1ee2e79aa2021a1938e99f71bce75fa9652a682 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 25 Jun 2026 09:11:24 -0700 Subject: [PATCH 4/6] fixup! feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner --- internal/fingerprint/versiontag.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go index 5fe45c44..1d0d83b3 100644 --- a/internal/fingerprint/versiontag.go +++ b/internal/fingerprint/versiontag.go @@ -119,17 +119,17 @@ func isValidEmitKey(s string) bool { return true } -// isEmitKeyChar reports whether r is allowed in an emit-key: a letter, digit, +// isEmitKeyChar reports whether char is allowed in an emit-key: a letter, digit, // '-', '_', or '.'. -func isEmitKeyChar(r rune) bool { +func isEmitKeyChar(char rune) bool { switch { - case r >= 'a' && r <= 'z': + case char >= 'a' && char <= 'z': return true - case r >= 'A' && r <= 'Z': + case char >= 'A' && char <= 'Z': return true - case r >= '0' && r <= '9': + case char >= '0' && char <= '9': return true - case r == '-', r == '_', r == '.': + case char == '-', char == '_', char == '.': return true default: return false From 9ec0c21df144eda79a6d710bb4ddd41c12b82948 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 25 Jun 2026 10:33:56 -0700 Subject: [PATCH 5/6] fixup! feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner --- internal/fingerprint/canonical.go | 46 ++++++++++++++++--- internal/fingerprint/combine.go | 13 +++++- internal/fingerprint/combine_internal_test.go | 38 +++++++++++++++ internal/fingerprint/fingerprint.go | 5 +- internal/fingerprint/versiontag.go | 14 ++++-- internal/projectconfig/fingerprint_test.go | 6 +++ 6 files changed, 108 insertions(+), 14 deletions(-) diff --git a/internal/fingerprint/canonical.go b/internal/fingerprint/canonical.go index d510fa8a..d80f43cd 100644 --- a/internal/fingerprint/canonical.go +++ b/internal/fingerprint/canonical.go @@ -8,10 +8,13 @@ import ( "reflect" ) -// maxSafeInteger is the largest integer (2^53) that survives RFC 8785's -// ECMAScript number serialization without precision loss. An integer of greater -// magnitude is rejected rather than silently coerced - such a value is almost -// always an identifier or hash that belongs in the config as a string. +// maxSafeInteger is the largest integer magnitude (2^53) that survives RFC 8785's +// ECMAScript number serialization without precision loss. Note this is 2^53, one +// more than ECMAScript's Number.MAX_SAFE_INTEGER (2^53-1): 2^53 is itself exactly +// representable, and its only non-representable neighbour 2^53+1 is rejected by the +// strict ">" bound in scalarToJSON. An integer of greater magnitude is rejected +// rather than silently coerced - such a value is almost always an identifier or +// hash that belongs in the config as a string. const maxSafeInteger = 1 << 53 // treeBuilder accumulates the canonical projection of a struct as a JSON-able @@ -35,6 +38,10 @@ type treeBuilder struct { } func (b *treeBuilder) set(key string, value any) { + if b.err != nil { + return + } + if b.out == nil { b.out = map[string]any{} } @@ -86,8 +93,11 @@ func (b *treeBuilder) emitScalar(key string, value any, always bool) { b.set(key, encoded) } -// emitMap adds a scalar-valued map, measuring key membership. Struct-valued maps -// are a composite and must be projected entry-by-entry through emitComposite. +// emitMap adds a scalar-leaf-valued map, measuring key membership: each value is a +// scalar or a scalar slice (matching scalarToJSON's leaf set). Interface-kind values +// (from a map[string]any) are unwrapped to their concrete value, as emitScalar does. +// Struct-valued maps are a composite and must be projected entry-by-entry through +// emitComposite. func (b *treeBuilder) emitMap(key string, mapValue any) { if b.err != nil { return @@ -114,7 +124,21 @@ func (b *treeBuilder) emitMap(key string, mapValue any) { iter := rval.MapRange() for iter.Next() { - encoded, err := scalarToJSON(iter.Value()) + entryVal := iter.Value() + if entryVal.Kind() == reflect.Interface { + entryVal = entryVal.Elem() + } + + if !isScalarLeaf(entryVal) { + b.err = fmt.Errorf( + "emitMap %#q entry %#q: kind %s is not an encodable scalar leaf; "+ + "struct-valued maps use emitComposite", + key, iter.Key().String(), entryVal.Kind()) + + return + } + + encoded, err := scalarToJSON(entryVal) if err != nil { b.err = fmt.Errorf("encoding map %#q entry %#q:\n%w", key, iter.Key().String(), err) @@ -130,6 +154,10 @@ func (b *treeBuilder) emitMap(key string, mapValue any) { // emitComposite adds a nested-struct projection, omitting it when the // sub-projector produced no measured keys (projected emptiness). func (b *treeBuilder) emitComposite(key string, sub map[string]any) { + if b.err != nil { + return + } + if len(sub) == 0 { return } @@ -140,6 +168,10 @@ func (b *treeBuilder) emitComposite(key string, sub map[string]any) { // emitSlice adds a struct-slice projection, omitting it when empty. A JSON array // preserves order, so reordering elements is a different encoding. func (b *treeBuilder) emitSlice(key string, elems []any) { + if b.err != nil { + return + } + if len(elems) == 0 { return } diff --git a/internal/fingerprint/combine.go b/internal/fingerprint/combine.go index e445d885..230cb3ce 100644 --- a/internal/fingerprint/combine.go +++ b/internal/fingerprint/combine.go @@ -17,6 +17,11 @@ import ( // keys provide domain separation between the config projection and the non-config // inputs, so no manual length-prefixing is needed; JCS sorts keys and pins number // and string formatting, so the bytes are stable across runs and Go versions. +// +// Cross-language RFC 8785 reproducibility is a hard requirement (a fingerprint must +// be reconstructable outside Go), which is why this uses the JCS reference +// implementation rather than encoding/json alone; the JSON-safe integer ceiling in +// scalarToJSON (see maxSafeInteger) is the deliberate consequence of that choice. func canonicalDigest(document map[string]any) (string, error) { raw, err := json.Marshal(document) if err != nil { @@ -30,5 +35,11 @@ func canonicalDigest(document map[string]any) (string, error) { sum := sha256.Sum256(canonical) - return "sha256:" + hex.EncodeToString(sum[:]), nil + return sha256Hex(sum[:]), nil +} + +// sha256Hex renders a digest as the canonical "sha256:" fingerprint token used +// across this package. +func sha256Hex(sum []byte) string { + return "sha256:" + hex.EncodeToString(sum) } diff --git a/internal/fingerprint/combine_internal_test.go b/internal/fingerprint/combine_internal_test.go index 2c62fe11..a78b1a79 100644 --- a/internal/fingerprint/combine_internal_test.go +++ b/internal/fingerprint/combine_internal_test.go @@ -80,3 +80,41 @@ func TestCanonicalDigest_KeyOrderIndependent(t *testing.T) { assert.Equal(t, digestA, digestB, "RFC 8785 key sorting makes the digest independent of map order") } + +// These frozen digests pin the exact json.Marshal -> jcs.Transform -> sha256 +// pipeline output. Unlike the relative tests above (A==A, A!=B, order-independent), +// a literal hash catches a uniform digest shift from a gowebpki/jcs upgrade or an +// encoding/json change - a regression those tests would silently pass. Update these +// ONLY with a deliberate, documented encoding change. +const ( + goldenDocumentDigest = "sha256:4163081a8d13db70252a592f08eb67a98844e92d334fdf22842e70795423b437" + goldenStringNormalizationDigest = "sha256:644342679ac15c21339d2cc1584791ae7b933f1fbe716a3d73878facfe2eac0d" +) + +// TestCanonicalDigest_GoldenVector pins the digest of a fixed document, freezing +// the end-to-end encoding contract this package delivers. +func TestCanonicalDigest_GoldenVector(t *testing.T) { + doc := map[string]any{ + "config": map[string]any{"spec": map[string]any{"upstream-name": "x"}}, + "sourceIdentity": "abc123", + "manualBump": 0, + "releaseVer": "4.0", + "overlays": map[string]any{"0": "ovl0"}, + } + + digest, err := canonicalDigest(doc) + require.NoError(t, err) + assert.Equal(t, goldenDocumentDigest, digest) +} + +// TestCanonicalDigest_StringNormalization pins the JCS string-serialization path. +// Go's encoding/json HTML-escapes '<', '>' and '&' (to \u003c etc.); RFC 8785 +// normalizes them back to literal characters. The value also carries a non-ASCII +// rune (é) and a C0 control char (U+0001) to lock their canonical escaping. +func TestCanonicalDigest_StringNormalization(t *testing.T) { + doc := map[string]any{"k": "ad\u0001é"} + + digest, err := canonicalDigest(doc) + require.NoError(t, err) + assert.Equal(t, goldenStringNormalizationDigest, digest) +} diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go index e3601803..3692abe9 100644 --- a/internal/fingerprint/fingerprint.go +++ b/internal/fingerprint/fingerprint.go @@ -5,7 +5,6 @@ package fingerprint import ( "crypto/sha256" - "encoding/hex" "fmt" "io" "sort" @@ -164,7 +163,7 @@ func combineInputs(inputs ComponentInputs) string { } } - return "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + return sha256Hex(hasher.Sum(nil)) } // writeField writes a labeled value to the hasher for domain separation. @@ -219,5 +218,5 @@ func ComputeResolutionHash(inputs UpstreamCommitResolutionInputs) string { writeField(hasher, "upstream_commit_pin", inputs.UpstreamCommitPin) writeField(hasher, "upstream_name", inputs.UpstreamName) - return "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + return sha256Hex(hasher.Sum(nil)) } diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go index 1d0d83b3..8a72bfc0 100644 --- a/internal/fingerprint/versiontag.go +++ b/internal/fingerprint/versiontag.go @@ -4,9 +4,10 @@ package fingerprint import ( + "cmp" "errors" "fmt" - "sort" + "slices" "strconv" "strings" ) @@ -46,6 +47,13 @@ type versionSet struct { // future-referencing (any bound above currentVersion), and duplicate-key= tags. // An absent tag is the caller's responsibility: the empty string is rejected // here because every fingerprinted field must carry an explicit decision. +// +// This grammar is not yet active on the projectconfig structs: the legacy +// hashstructure path still measures fields and treats any non-"-" tag as +// "included", so introducing a range tag is behaviour-neutral until the wiring in +// part 2/3. At that point TestAllFingerprintedFieldsHaveDecision is updated to +// delegate to this parser; today it accepts only ""/"-" and fails loudly on a +// richer tag, which is the desired guard against jumping ahead of the wiring. func parseVersionSet(tag string, currentVersion int) (versionSet, error) { if tag == excludeTag { return versionSet{excluded: true}, nil @@ -214,8 +222,8 @@ func parseVersion(token string) (int, error) { // so a version can never match two ranges (which would alias two always-emit // flags). func sortAndValidateRanges(ranges []versionRange) error { - sort.Slice(ranges, func(i, j int) bool { - return ranges[i].low < ranges[j].low + slices.SortFunc(ranges, func(a, b versionRange) int { + return cmp.Compare(a.low, b.low) }) for i := 1; i < len(ranges); i++ { diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index 3bd14eff..ceee86d3 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -113,6 +113,12 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // hashstructure only recognises "" (include) and "-" (exclude). // Any other value is silently treated as included, which is // almost certainly a typo. + // + // The richer version-range grammar (fingerprint.parseVersionSet) + // is NOT yet wired here: while the legacy hashstructure path is + // active, only ""/"-" are valid and any range tag is rejected so + // nobody adds one ahead of the part 2/3 wiring. When hashstructure + // is removed, replace this switch with a parseVersionSet call. assert.Failf(t, "invalid fingerprint tag", "field %q has unrecognised fingerprint tag value %q — "+ "only `fingerprint:\"-\"` (exclude) is valid; "+ From 5f515a6f8d031d057f8cc8fefc9434f552823505 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 25 Jun 2026 11:22:48 -0700 Subject: [PATCH 6/6] fixup! feat(fingerprint): add canonical encoder, version-set tag parser, sha256 combiner --- internal/fingerprint/canonical.go | 24 ++++++++-- .../fingerprint/canonical_internal_test.go | 46 ++++++++++++++++--- internal/fingerprint/versiontag.go | 7 --- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/internal/fingerprint/canonical.go b/internal/fingerprint/canonical.go index d80f43cd..52738f29 100644 --- a/internal/fingerprint/canonical.go +++ b/internal/fingerprint/canonical.go @@ -152,12 +152,21 @@ func (b *treeBuilder) emitMap(key string, mapValue any) { } // emitComposite adds a nested-struct projection, omitting it when the -// sub-projector produced no measured keys (projected emptiness). -func (b *treeBuilder) emitComposite(key string, sub map[string]any) { +// sub-projector produced no measured keys (projected emptiness). A non-nil err +// from the sub-projector is folded into the deferred error (wrapped with key), so +// a projector threads sub-projection errors without a manual check, under the same +// deferred-error discipline as the scalar emitters. +func (b *treeBuilder) emitComposite(key string, sub map[string]any, err error) { if b.err != nil { return } + if err != nil { + b.err = fmt.Errorf("%s:\n%w", key, err) + + return + } + if len(sub) == 0 { return } @@ -166,12 +175,19 @@ func (b *treeBuilder) emitComposite(key string, sub map[string]any) { } // emitSlice adds a struct-slice projection, omitting it when empty. A JSON array -// preserves order, so reordering elements is a different encoding. -func (b *treeBuilder) emitSlice(key string, elems []any) { +// preserves order, so reordering elements is a different encoding. A non-nil err +// from the element projector is folded into the deferred error (wrapped with key). +func (b *treeBuilder) emitSlice(key string, elems []any, err error) { if b.err != nil { return } + if err != nil { + b.err = fmt.Errorf("%s:\n%w", key, err) + + return + } + if len(elems) == 0 { return } diff --git a/internal/fingerprint/canonical_internal_test.go b/internal/fingerprint/canonical_internal_test.go index f2f80001..c7285b14 100644 --- a/internal/fingerprint/canonical_internal_test.go +++ b/internal/fingerprint/canonical_internal_test.go @@ -4,6 +4,7 @@ package fingerprint import ( + "errors" "reflect" "testing" @@ -184,17 +185,50 @@ func TestTreeBuilder_EmitMapErrors(t *testing.T) { func TestTreeBuilder_CompositeAndSliceProjectedEmptiness(t *testing.T) { var omitted treeBuilder - omitted.emitComposite("build", nil) - omitted.emitComposite("spec", map[string]any{}) - omitted.emitSlice("overlays", nil) - omitted.emitSlice("source-files", []any{}) + omitted.emitComposite("build", nil, nil) + omitted.emitComposite("spec", map[string]any{}, nil) + omitted.emitSlice("overlays", nil, nil) + omitted.emitSlice("source-files", []any{}, nil) assert.Nil(t, mustResult(t, &omitted), "composites/slices that projected empty are omitted") var emitted treeBuilder - emitted.emitComposite("build", map[string]any{"with": []any{"x"}}) - emitted.emitSlice("overlays", []any{map[string]any{"tag": "a"}}) + emitted.emitComposite("build", map[string]any{"with": []any{"x"}}, nil) + emitted.emitSlice("overlays", []any{map[string]any{"tag": "a"}}, nil) assert.Equal(t, map[string]any{ "build": map[string]any{"with": []any{"x"}}, "overlays": []any{map[string]any{"tag": "a"}}, }, mustResult(t, &emitted)) } + +// TestTreeBuilder_CompositeSliceErrorThreading verifies that a non-nil +// sub-projector error passed to emitComposite/emitSlice is folded into the +// deferred error (wrapped with the key), and that the first error wins. +func TestTreeBuilder_CompositeSliceErrorThreading(t *testing.T) { + sentinel := errors.New("sub-projector failed") + + var fromComposite treeBuilder + fromComposite.emitComposite("spec", nil, sentinel) + fromComposite.emit("after", "ignored") + _, err := fromComposite.result() + require.ErrorIs(t, err, sentinel, "emitComposite folds a sub-projector error into the deferred error") + assert.Contains(t, err.Error(), "spec", "the threaded error is wrapped with the composite key") + + var fromSlice treeBuilder + fromSlice.emitSlice("overlays", nil, sentinel) + _, err = fromSlice.result() + require.ErrorIs(t, err, sentinel, "emitSlice folds an element-projector error into the deferred error") + assert.Contains(t, err.Error(), "overlays", "the threaded error is wrapped with the slice key") + + // A non-nil sub-projector error is reported even when the sub-map is non-empty. + var nonEmptyErr treeBuilder + nonEmptyErr.emitComposite("spec", map[string]any{"k": "v"}, sentinel) + _, err = nonEmptyErr.result() + require.ErrorIs(t, err, sentinel, "the error takes precedence over a would-be emission") + + // First error wins: a later emit failure does not overwrite the threaded one. + var firstWins treeBuilder + firstWins.emitComposite("spec", nil, sentinel) + firstWins.emit("bad", nil) + _, err = firstWins.result() + require.ErrorIs(t, err, sentinel, "the first (threaded) error wins") +} diff --git a/internal/fingerprint/versiontag.go b/internal/fingerprint/versiontag.go index 8a72bfc0..37903acd 100644 --- a/internal/fingerprint/versiontag.go +++ b/internal/fingerprint/versiontag.go @@ -47,13 +47,6 @@ type versionSet struct { // future-referencing (any bound above currentVersion), and duplicate-key= tags. // An absent tag is the caller's responsibility: the empty string is rejected // here because every fingerprinted field must carry an explicit decision. -// -// This grammar is not yet active on the projectconfig structs: the legacy -// hashstructure path still measures fields and treats any non-"-" tag as -// "included", so introducing a range tag is behaviour-neutral until the wiring in -// part 2/3. At that point TestAllFingerprintedFieldsHaveDecision is updated to -// delegate to this parser; today it accepts only ""/"-" and fails loudly on a -// richer tag, which is the desired guard against jumping ahead of the wiring. func parseVersionSet(tag string, currentVersion int) (versionSet, error) { if tag == excludeTag { return versionSet{excluded: true}, nil