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..52738f29 --- /dev/null +++ b/internal/fingerprint/canonical.go @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "fmt" + "reflect" +) + +// 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 +// 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.err != nil { + return + } + + 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-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 + } + + 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() { + 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) + + 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). 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 + } + + 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. 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 + } + + 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: + 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)) + 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..c7285b14 --- /dev/null +++ b/internal/fingerprint/canonical_internal_test.go @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "errors" + "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))) + 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") + + _, 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") +} + +// 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 + 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, 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"}}, 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/combine.go b/internal/fingerprint/combine.go new file mode 100644 index 00000000..230cb3ce --- /dev/null +++ b/internal/fingerprint/combine.go @@ -0,0 +1,45 @@ +// 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. +// +// 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 { + 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 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 new file mode 100644 index 00000000..a78b1a79 --- /dev/null +++ b/internal/fingerprint/combine_internal_test.go @@ -0,0 +1,120 @@ +// 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") +} + +// 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 646e317b..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" @@ -16,9 +15,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 +107,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) @@ -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 new file mode 100644 index 00000000..37903acd --- /dev/null +++ b/internal/fingerprint/versiontag.go @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "cmp" + "errors" + "fmt" + "slices" + "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 closed (inclusive) 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) + } + + 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:] + } + + 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 +} + +// 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 { + if s == "" { + return false + } + + for _, r := range s { + if !isEmitKeyChar(r) { + return false + } + } + + return true +} + +// isEmitKeyChar reports whether char is allowed in an emit-key: a letter, digit, +// '-', '_', or '.'. +func isEmitKeyChar(char rune) bool { + switch { + case char >= 'a' && char <= 'z': + return true + case char >= 'A' && char <= 'Z': + return true + case char >= '0' && char <= '9': + return true + case char == '-', char == '_', char == '.': + return true + default: + return false + } +} + +// 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, "!") + } + + 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", rangeText) + } + + rng.high = highVersion + } + + if rng.low > currentVersion { + return versionRange{}, fmt.Errorf( + "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", rangeText, 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 { + slices.SortFunc(ranges, func(a, b versionRange) int { + return cmp.Compare(a.low, b.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..6a0113d3 --- /dev/null +++ b/internal/fingerprint/versiontag_internal_test.go @@ -0,0 +1,155 @@ +// 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}, + {"key override with space", "key=foo bar,v1..*", 1}, + {"key override with symbol", "key=foo@bar,v1..*", 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") +} + +// 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 '!'") +} 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; "+