From 326e3a4717673a167fc55df734ee31483a82f4cc Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Tue, 16 Jun 2026 15:49:41 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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 From 7ffefaa898981227160b69440fbf2d80787c36b2 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Tue, 16 Jun 2026 17:05:32 -0700 Subject: [PATCH 7/7] feat(fingerprint): add hand-written projectV1 + golden vectors (PR A2) Add the v1 projection layer beside the existing hashstructure path, additive and not yet wired into ComputeIdentity: - projectV1 + frozen nested sub-projectors, emitting measured fields by literal Go path under their frozen TOML emit-key - canonicalizeForFingerprint: reflective nil-or-empty scalar-slice normalizer at the hash boundary, pruning at fingerprint:"-" edges (cycle-safe, no field inventory) - fingerprint:"v1..*" tags on every measured field across the 10 fingerprinted structs; hazard comments on each '-'-pruned composite; Packages kept measured - golden vectors with an append-only -update-golden guard; emission probe; composite-'!' placeholder gate; mandatory-tag decision test - frozen maximalConfig vs growing emissionProbeConfig split + documented additive-field workflow and naming convention No lock byte or scenario snapshot moves; hashstructure untouched. --- .github/instructions/go.instructions.md | 15 +- internal/fingerprint/golden_internal_test.go | 180 ++++++++ internal/fingerprint/project.go | 87 ++++ internal/fingerprint/project_internal_test.go | 385 ++++++++++++++++++ internal/fingerprint/testdata/golden_v1.json | 7 + internal/fingerprint/v1.go | 191 +++++++++ internal/projectconfig/build.go | 16 +- internal/projectconfig/component.go | 31 +- internal/projectconfig/distro.go | 4 +- internal/projectconfig/fingerprint_test.go | 47 +-- internal/projectconfig/overlay.go | 18 +- internal/projectconfig/package.go | 2 + internal/projectconfig/render.go | 2 +- internal/projectconfig/specsource.go | 8 +- 14 files changed, 926 insertions(+), 67 deletions(-) create mode 100644 internal/fingerprint/golden_internal_test.go create mode 100644 internal/fingerprint/project.go create mode 100644 internal/fingerprint/project_internal_test.go create mode 100644 internal/fingerprint/testdata/golden_v1.json create mode 100644 internal/fingerprint/v1.go diff --git a/.github/instructions/go.instructions.md b/.github/instructions/go.instructions.md index 5b923283..55e5768b 100644 --- a/.github/instructions/go.instructions.md +++ b/.github/instructions/go.instructions.md @@ -98,17 +98,18 @@ description: "Instructions for working on the azldev Go codebase. IMPORTANT: Alw } ``` -## Component Fingerprinting — `fingerprint:"-"` Tags +## Component Fingerprinting: version-set tags + `projectV1` -Structs in `internal/projectconfig/` are hashed by `hashstructure.Hash()` to detect component changes. Fields **included by default** (safe: false positive > false negative). +Structs in `internal/projectconfig/` feed the component fingerprint, and **every field must carry an explicit `fingerprint` tag** - an absent tag fails `TestAllFingerprintedFieldsHaveDecision`. (The live hash is still `hashstructure.Hash()`; the hand-written `projectV1` in `internal/fingerprint/project.go` is wired in parallel and replaces it at the reset.) -When adding a new field to a fingerprinted struct, ask: **"Does changing this field change the build output?"** -- **Yes** (build flags, spec source, defines, etc.) → do nothing, included automatically. -- **No** (human docs, scheduling hints, CI policy, metadata, back-references) → add `fingerprint:"-"` to the struct tag and register the exclusion in `expectedExclusions` in `internal/projectconfig/fingerprint_test.go`. +When adding a field to a fingerprinted struct, ask: **"Does changing this field change the build output?"** +- **No** (human docs, scheduling hints, CI policy, metadata, back-references) → tag it `fingerprint:"-"` and register the exclusion in `expectedExclusions` in `internal/projectconfig/fingerprint_test.go`. +- **Yes** (build flags, spec source, defines, etc.) → tag it `fingerprint:"v1..*"` (omit-if-zero) and wire it into `projectV1`: add its `emit`/sub-projector line, set it in `emissionProbeConfig`, then append a `-set` golden vector with `go test ./internal/fingerprint -run TestGoldenVectors -update-golden`. The golden table is append-only - never edit an existing digest. The full contract lives in `goldenConfigs`'s doc comment. +- **Build-meaningful zero?** A field whose *zero value* changes the build (e.g. a `bool` where `false` is a deliberate setting) is tagged `fingerprint:"!v1..*"` (always-emit) and wired with `builder.emitAlways(...)`, **not** `builder.emit(...)`; it MUST get a golden vector at its zero value (the differ-from-`minimal` guard in `golden_internal_test.go` then proves it actually emits). -If a parent struct field is already excluded (e.g. `Failure ComponentBuildFailureConfig ... fingerprint:"-"`), do **not** also tag the inner struct's fields — `hashstructure` skips the entire subtree. +If a parent field is already excluded (e.g. `Failure ComponentBuildFailureConfig ... fingerprint:"-"`), do **not** tag the inner struct's fields - the excluded subtree is pruned from both the walk and `projectV1`. -Run `mage unit` to verify — the guard test will catch unregistered exclusions or missing tags. +Run `mage unit` to verify: the decision test catches a missing tag, the emission probe catches a measured-but-unemitted field, and the golden vectors catch a moved digest. ### Cmdline Returns diff --git a/internal/fingerprint/golden_internal_test.go b/internal/fingerprint/golden_internal_test.go new file mode 100644 index 00000000..c157a048 --- /dev/null +++ b/internal/fingerprint/golden_internal_test.go @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// updateGolden, when set, appends newly-named golden vectors to the frozen table. +// It MUST NOT change an existing entry's digest - see goldenPath's header comment. +// Bootstrap a missing table with: go test ./internal/fingerprint -run TestGoldenVectors -update-golden +// +//nolint:gochecknoglobals // a test flag must be a package-level var to register at init. +var updateGolden = flag.Bool("update-golden", false, "append new golden fingerprint vectors") + +// goldenPath is the golden-vector file for the current content version. Each +// version pins its own file (golden_v1.json, golden_v2.json, ...); a new version +// adds a file rather than rewriting an existing one. +// +//nolint:gochecknoglobals // derived from currentContentVersion; cannot be const. +var goldenPath = fmt.Sprintf("testdata/golden_v%d.json", currentContentVersion) + +// goldenConfigs is the freeze corpus for the current content version: one cutover +// config (every measured field set) plus named edge configs the cutover case +// cannot express at once. +// +// # Managing golden vectors (read before editing this map or testdata/golden_v.json) +// +// A golden vector is a frozen (config -> digest) pair that pins a content +// version's projection digest irreversibly. The freeze is append-only and these +// rules are load-bearing because the encoding becomes a one-way door the moment a +// version ships (see the lazy-schema-migration RFC): +// +// - One file per content version: testdata/golden_v1.json pins v1, a future +// testdata/golden_v2.json pins v2, and so on. A new version ADDS its own file +// and never rewrites an existing one; this harness reads the file for +// currentContentVersion. (The future projection generator follows the same +// rule - it emits golden_v.json for the version it adds and leaves older +// files untouched.) +// - NEVER edit an existing entry's digest in testdata/golden_v.json. The +// -update-golden flag only APPENDS; a moved existing digest is a deliberate +// FATAL. If an existing digest moves, your change was NOT output-preserving for +// the fleet - that is a bug to fix, not a value to -update away. +// - cutoverFieldSet (the "maximal" vector) is FROZEN: it is a corpus config, so +// changing what it returns moves its pinned digest. Do not add fields to it. +// - emissionProbeConfig GROWS instead: it sets every measured field so the +// emission probe can assert each measured key is emitted. The two are split +// precisely because one config cannot be both frozen and growing. +// +// Adding an omit-if-zero field "foo" (fingerprint:"v..*", no version bump): +// 1. add the field + tag and its projectV emit line; +// 2. set foo non-zero in emissionProbeConfig (the probe fails until you do - that +// failure is the "you forgot to emit the new key" guard working); +// 3. append a new named vector that sets foo in isolation, then run: +// go test ./internal/fingerprint -run TestGoldenVectors -update-golden +// 4. confirm the run APPENDS ONLY - every existing digest must be byte-identical. +// Any movement of an existing entry is the bug, not the fix. +// +// NEVER retroactively measure a new field at a superseded version (e.g. editing a +// frozen projectV to read foo once v exists). That changes the bytes that +// version emitted in production, so every stored lock at that version becomes +// unreproducible on replay (Problem 6) -> mass false-Stale. Omit-if-zero means no +// pre-existing lock ever set foo, so each replays byte-identically without it; a +// lock that later needs foo measured gets it by FORWARD migration, never by editing +// the old version. +// +// Naming convention (semantic, not chronological - the date is already in git, and +// a name must say what broke when a vector moves): +// - "maximal" / "minimal": the frozen baselines, never changed. +// - edge cases: name the property exercised ("defines-empty-value", +// "single-overlay", ...). +// - an additive field: "-set", one isolated vector per field. +func goldenConfigs() map[string]projectconfig.ComponentConfig { + return map[string]projectconfig.ComponentConfig{ + "maximal": cutoverFieldSet(), + "minimal": {}, + "defines-empty-value": {Build: projectconfig.ComponentBuildConfig{Defines: map[string]string{"k": ""}}}, + "single-overlay": {Overlays: []projectconfig.ComponentOverlay{{ + Type: projectconfig.ComponentOverlayAddPatch, Filename: "p.patch", + }}}, + } +} + +// TestGoldenVectors pins the v1 projection encoding irreversibly. The expected +// digests are append-only: a commit that changes an existing entry fails (a +// frozen v1 vector cannot move - a new encoding is a new version, not an edit). +func TestGoldenVectors(t *testing.T) { + computed := make(map[string]string) + for name, cfg := range goldenConfigs() { + computed[name] = projectionDigest(t, cfg) + } + + // S2: every vector that sets a measured field must differ from the empty + // projection. A vector that collapses to `minimal` means a measured emit was + // dropped or mis-wired (e.g. a duplicate emit-key the global probe misses, or a + // '!' field that used emit instead of emitAlways and so dropped its zero value). + // A deliberately projected-empty vector (none today) would opt out here. + expectedEmptyProjection := map[string]bool{"minimal": true} + for name, digest := range computed { + if expectedEmptyProjection[name] { + continue + } + + assert.NotEqualf(t, computed["minimal"], digest, + "golden vector %q collapsed to the empty projection - a measured emit was dropped or mis-wired", name) + } + + frozen := loadGolden(t) + + if *updateGolden { + for name, digest := range computed { + if old, ok := frozen[name]; ok && old != digest { + t.Fatalf("append-only violation: vector %q would change\n from %s\n to %s\n"+ + "a frozen v1 vector cannot move; introduce a new version instead", name, old, digest) + } + + frozen[name] = digest + } + + writeGolden(t, frozen) + + return + } + + for name, digest := range computed { + expected, ok := frozen[name] + require.Truef(t, ok, "golden vector %q is missing; bootstrap with -update-golden", name) + assert.Equalf(t, expected, digest, "v1 projection for %q changed - this is a frozen byte encoding", name) + } +} + +func projectionDigest(t *testing.T, cfg projectconfig.ComponentConfig) string { + t.Helper() + + digest := sha256.Sum256(mustProject(t, cfg)) + + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func loadGolden(t *testing.T) map[string]string { + t.Helper() + + data, err := os.ReadFile(goldenPath) + if os.IsNotExist(err) { + return make(map[string]string) + } + + require.NoError(t, err) + + golden := make(map[string]string) + require.NoError(t, json.Unmarshal(data, &golden)) + + return golden +} + +func writeGolden(t *testing.T, golden map[string]string) { + t.Helper() + + golden["_README"] = fmt.Sprintf("Frozen v%d projection digests. APPEND-ONLY: never edit an "+ + "existing digest - a new encoding is a new content version, not an edit here. "+ + "Management rules and the additive-field workflow live in goldenConfigs's doc "+ + "comment in golden_internal_test.go.", currentContentVersion) + + // json.Marshal emits map keys sorted, giving a stable diff. + data, err := json.MarshalIndent(golden, "", " ") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(goldenPath, append(data, '\n'), 0o644)) //nolint:gosec // golden fixture, not a secret. +} diff --git a/internal/fingerprint/project.go b/internal/fingerprint/project.go new file mode 100644 index 00000000..1e819a4e --- /dev/null +++ b/internal/fingerprint/project.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "fmt" + "reflect" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" +) + +// currentContentVersion is the highest content version that exists. The reset +// establishes v1, so projectV1 is the current (and only) projection and the tag +// parser rejects any field that references a future version. +const currentContentVersion = 1 + +// FingerprintedStructTypes returns every struct type whose fields participate in +// the component fingerprint. It is the single source of truth that both the +// mandatory-tag decision test (`projectconfig.TestAllFingerprintedFieldsHaveDecision`) +// and the emission probe (`measuredLeafEmitKeys`) walk - keeping it in one +// exported place is what stops those two walks from drifting silently (a struct +// dropped from one but not the other would weaken coverage with no failure). +// +// It is hand-maintained until the projection generator (which would derive it +// from projectV1 directly) lands. When you add a new struct type to the +// fingerprinted graph, add it here - and nowhere else. +func FingerprintedStructTypes() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[projectconfig.ComponentConfig](), + reflect.TypeFor[projectconfig.ComponentBuildConfig](), + reflect.TypeFor[projectconfig.CheckConfig](), + reflect.TypeFor[projectconfig.PackageConfig](), + reflect.TypeFor[projectconfig.ComponentOverlay](), + reflect.TypeFor[projectconfig.SpecSource](), + reflect.TypeFor[projectconfig.DistroReference](), + reflect.TypeFor[projectconfig.SourceFileReference](), + reflect.TypeFor[projectconfig.ReleaseConfig](), + reflect.TypeFor[projectconfig.ComponentRenderConfig](), + } +} + +// ValidateFieldTag checks one fingerprinted struct field's tag at the current +// content version. It enforces the mandatory-decision rule (an absent tag is an +// error - every fingerprinted field must declare a version-set or exclusion), +// rejects an unparseable / future-referencing tag, and requires a resolvable +// emit-key for a measured field. +// +// It also gates the documented composite-'!' placeholder: a nested struct, map, +// or slice-of-struct tagged with an always-emit range is not implemented at v1, +// so it is rejected here rather than silently guessing an encoding. +func ValidateFieldTag(field reflect.StructField) error { + set, err := parseVersionSet(field.Tag.Get(fingerprintTagName), currentContentVersion) + if err != nil { + return fmt.Errorf("field %#q:\n%w", field.Name, err) + } + + if set.excluded { + return nil + } + + if _, err := set.resolveEmitKey(tomlKeyOf(field)); err != nil { + return fmt.Errorf("field %#q:\n%w", field.Name, err) + } + + if !isScalarLeaf(reflect.New(field.Type).Elem()) { + for _, rng := range set.ranges { + if rng.alwaysEmit { + return fmt.Errorf( + "field %#q: composite-'!' (always-emit nested struct, map, or slice-of-struct) "+ + "is not implemented at v1", + field.Name) + } + } + } + + return nil +} + +// tomlKeyOf returns the field's bare toml key (the part before the first comma), +// or "" when there is no toml tag. +func tomlKeyOf(field reflect.StructField) string { + key, _, _ := strings.Cut(field.Tag.Get("toml"), ",") + + return key +} diff --git a/internal/fingerprint/project_internal_test.go b/internal/fingerprint/project_internal_test.go new file mode 100644 index 00000000..696f6513 --- /dev/null +++ b/internal/fingerprint/project_internal_test.go @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/gowebpki/jcs" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cutoverFieldSet is the frozen v1-cutover field set: every field measured at the +// cutover, each set to a distinct non-zero value, golden-vectored as "maximal". +// +// Do NOT add fields here. It is effectively frozen - it is a golden corpus config, +// so changing what it returns moves its pinned digest and trips the append-only +// guard. A field added after the cutover gets its own new golden vector and is set +// in [emissionProbeConfig], never here. +func cutoverFieldSet() projectconfig.ComponentConfig { + return projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamCommit: "abc1234", + UpstreamName: "upstream-name", + UpstreamDistro: projectconfig.DistroReference{ + Name: "distro-name", + Version: "distro-version", + }, + }, + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationStatic, + }, + Build: projectconfig.ComponentBuildConfig{ + With: []string{"feature-a"}, + Without: []string{"feature-b"}, + Defines: map[string]string{"macro": "value"}, + Undefines: []string{"macro-c"}, + Check: projectconfig.CheckConfig{Skip: true}, + }, + Render: projectconfig.ComponentRenderConfig{SkipFileFilter: true}, + Overlays: []projectconfig.ComponentOverlay{{ + Type: projectconfig.ComponentOverlaySetSpecTag, + Filename: "file.txt", + SectionName: "%build", + PackageName: "subpkg", + Tag: "Release", + Value: "match-value", + Regex: "match-regex", + Replacement: "replacement", + Lines: []string{"line-1"}, + }}, + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "source.tar.gz", + Hash: "deadbeef", + HashType: projectconfig.HashTypeSHA256, + ReplaceUpstream: true, + }}, + Packages: map[string]projectconfig.PackageConfig{"pkg": {}}, + } +} + +// emissionProbeConfig sets every measured field non-zero so the emission probe can +// assert each measured emit-key appears in the projection. Unlike [cutoverFieldSet] +// (frozen), this GROWS: every omit-if-zero field added after the cutover must be +// set here, or the probe correctly fails that the new measured key is missing. +// +// Additive-field workflow (no version bump): +// 1. add the field + `fingerprint:"v1..*"` tag and its projectV1 emit line; +// 2. set it non-zero below (the probe fails until you do); +// 3. append a new named golden vector that sets it and run -update-golden; +// 4. confirm every pre-existing digest (maximal, minimal, ...) is unchanged. +func emissionProbeConfig() projectconfig.ComponentConfig { + cfg := cutoverFieldSet() // the frozen v1-cutover field set. + // Post-cutover additive fields are set here as they are introduced. + return cfg +} + +func mustProject(t *testing.T, cfg projectconfig.ComponentConfig) []byte { + t.Helper() + + tree, err := projectV1(cfg) + require.NoError(t, err) + + if tree == nil { + tree = map[string]any{} + } + + raw, err := json.Marshal(tree) + require.NoError(t, err) + + canonical, err := jcs.Transform(raw) + require.NoError(t, err) + + return canonical +} + +// TestProjectV1NilEmptySliceEquivalent confirms the omit predicate treats a nil +// and a non-nil empty scalar slice identically (resolution's WithAppendSlice merge +// yields either for the same intent), so neither emits and both project the same +// canonical JSON - the property that lets projectV1 read the resolved config with +// no config-normalization pre-pass. +func TestProjectV1NilEmptySliceEquivalent(t *testing.T) { + withNil := projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{With: nil}, + } + withEmpty := projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{With: []string{}}, + } + + assert.Equal(t, mustProject(t, withNil), mustProject(t, withEmpty), + "nil and non-nil empty scalar slice must project identically") + assert.Equal(t, "{}", string(mustProject(t, withEmpty)), "an empty scalar slice omits (empty projection)") + + withVals := projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{With: []string{"x"}}, + } + assert.NotEqual(t, "{}", string(mustProject(t, withVals)), "a set scalar slice emits") +} + +// TestProjEmitAlwaysEmitsZero confirms the treeBuilder's emitAlways method emits a +// scalar at its zero value (the '!' always-emit path) while emit omits it. Without +// it, a future `fingerprint:"!v1..*"` field would validate and pass the probe but +// be silently dropped at zero, since projectV1* could only reach emit. +func TestProjEmitAlwaysEmitsZero(t *testing.T) { + var always treeBuilder + always.emitAlways("strip-debug", false) + + treeAlways, err := always.result() + require.NoError(t, err) + assert.Equal(t, map[string]any{"strip-debug": false}, treeAlways, "emitAlways emits a build-meaningful zero") + + var omit treeBuilder + omit.emit("strip-debug", false) + + treeOmit, err := omit.result() + require.NoError(t, err) + assert.Nil(t, treeOmit, "emit omits the zero") +} + +// TestProjectV1OmitsExcludedAndZeroFields confirms a fingerprint:"-" field never +// reaches the projection and an omit-if-zero field is absent at its zero value. +func TestProjectV1OmitsExcludedAndZeroFields(t *testing.T) { + t.Run("an empty config projects to an empty object", func(t *testing.T) { + assert.Equal(t, "{}", string(mustProject(t, projectconfig.ComponentConfig{}))) + }) + + t.Run("an excluded field does not move the projection", func(t *testing.T) { + withExcluded := cutoverFieldSet() + withExcluded.Build.Failure.Expected = true + withExcluded.Build.Hints.Expensive = true + + assert.Equal(t, mustProject(t, cutoverFieldSet()), mustProject(t, withExcluded), + "varying a fingerprint:\"-\" field alone must not move the digest") + }) +} + +// TestProjectV1EmitKeyIsTomlKeyNotGoName pins the frozen-emit-key rule: the +// projection emits the field's toml key, never its Go identifier, so a Go-only +// rename is byte-neutral. +func TestProjectV1EmitKeyIsTomlKeyNotGoName(t *testing.T) { + cfg := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{UpstreamName: "x"}, + } + + encoded := mustProject(t, cfg) + + assert.True(t, bytes.Contains(encoded, []byte(`"upstream-name":`)), + "must emit the frozen toml key") + assert.False(t, bytes.Contains(encoded, []byte("UpstreamName")), + "must not emit the Go field name") +} + +// TestProjectV1EmissionProbe runs projectV1 on the emission-probe config (every +// measured field set) and asserts that every measured scalar-leaf / map field's +// emit-key appears in the output - the guard against a hand-written projector that +// measures a field but forgets to emit it. +func TestProjectV1EmissionProbe(t *testing.T) { + encoded := mustProject(t, emissionProbeConfig()) + + for _, key := range measuredLeafEmitKeys(t) { + slot := fmt.Sprintf("%q:", key) + assert.Truef(t, bytes.Contains(encoded, []byte(slot)), + "emit-key %q is measured but not emitted by projectV1. Wiring a fingerprinted "+ + "field (full contract in goldenConfigs's doc comment): add its projectV1 emit "+ + "line, set it in emissionProbeConfig, then append a -set golden vector "+ + "via -update-golden.", key) + } +} + +// TestFingerprintedStructTypesIsComplete walks the measured config graph outward +// from ComponentConfig and asserts the set of struct types it reaches is exactly +// the set registered in FingerprintedStructTypes(). A measured nested struct type +// wired into projectV1 but forgotten from the list is reachable-but-undeclared - +// both the decision test and the emission probe would silently skip its fields. +// A declared-but-unreachable type is a stale entry. Either direction fails here. +func TestFingerprintedStructTypesIsComplete(t *testing.T) { + declared := make(map[reflect.Type]bool) + for _, st := range FingerprintedStructTypes() { + declared[st] = true + } + + reachable := make(map[reflect.Type]bool) + + var visit func(reflect.Type) + + visit = func(structType reflect.Type) { + if reachable[structType] { + return + } + + reachable[structType] = true + + for i := range structType.NumField() { + field := structType.Field(i) + if field.PkgPath != "" { + continue // unexported: not measured. + } + + if field.Tag.Get(fingerprintTagName) == excludeTag { + continue // pruned subtree (matches projectV1's descent). + } + + if elem := structElem(field.Type); elem != nil { + visit(elem) + } + } + } + + visit(reflect.TypeFor[projectconfig.ComponentConfig]()) + + for st := range reachable { + assert.Truef(t, declared[st], + "struct type %s is reachable through a measured field but is missing from "+ + "FingerprintedStructTypes() - add it there so the decision test and emission "+ + "probe police its fields", st.Name()) + } + + for st := range declared { + assert.Truef(t, reachable[st], + "struct type %s is in FingerprintedStructTypes() but is no longer reachable "+ + "through a measured field (stale entry?)", st.Name()) + } +} + +// structElem unwraps pointer/slice/array/map layers and returns the struct type at +// the bottom, or nil when the type bottoms out in a non-struct (scalar) leaf. +func structElem(typ reflect.Type) reflect.Type { + for { + //exhaustive:ignore // only struct-bearing kinds matter; the rest are scalar leaves. + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: + typ = typ.Elem() + case reflect.Struct: + return typ + default: + return nil + } + } +} + +// measuredLeafEmitKeys enumerates, via reflection, the emit-keys of every +// measured non-composite field (scalar leaf or map) across the fingerprinted +// graph - the keys that must emit a direct slot when set non-zero. Composite +// container keys are intentionally excluded (they omit on projected emptiness, +// so e.g. an all-publish-only "packages" legitimately produces no slot). +// +// The struct list mirrors the decision test's; the reflective auto-discovery +// walk that would remove this hand list is deferred with the generator. +func measuredLeafEmitKeys(t *testing.T) []string { + t.Helper() + + structs := FingerprintedStructTypes() + + var keys []string + + for _, st := range structs { + for i := range st.NumField() { + field := st.Field(i) + + set, err := parseVersionSet(field.Tag.Get(fingerprintTagName), currentContentVersion) + require.NoError(t, err) + + if set.excluded { + continue + } + + // Only fields that emit a direct slot under their own key are probed: + // scalar leaves and scalar-valued maps. Composites (nested structs, + // struct slices, and struct-valued maps like Packages) omit on + // projected emptiness, so they need not appear. + zero := reflect.New(field.Type).Elem() + directSlot := isScalarLeaf(zero) || + (field.Type.Kind() == reflect.Map && isScalarKind(field.Type.Elem().Kind())) + + if !directSlot { + continue + } + + key, err := set.resolveEmitKey(tomlKeyOf(field)) + require.NoError(t, err) + + keys = append(keys, key) + } + } + + return keys +} + +// TestValidateFieldTagCompositeBangPlaceholder confirms the documented +// composite-'!' placeholder: an always-emit nested struct is rejected with the +// placeholder error, while an always-emit scalar and a plain version set pass. +func TestValidateFieldTagCompositeBangPlaceholder(t *testing.T) { + type composite struct { + Nested struct { + X string `toml:"x" fingerprint:"v1..*"` + } `toml:"nested" fingerprint:"!v1..*"` + } + + type scalar struct { + Flag bool `toml:"flag" fingerprint:"!v1..*"` + } + + type untagged struct { + X string `toml:"x"` + } + + compositeField, found := reflect.TypeFor[composite]().FieldByName("Nested") + require.True(t, found) + + err := ValidateFieldTag(compositeField) + require.Error(t, err) + assert.Contains(t, err.Error(), "composite-'!'") + + scalarField, found := reflect.TypeFor[scalar]().FieldByName("Flag") + require.True(t, found) + require.NoError(t, ValidateFieldTag(scalarField)) + + untaggedField, found := reflect.TypeFor[untagged]().FieldByName("X") + require.True(t, found) + require.Error(t, ValidateFieldTag(untaggedField), "an absent tag must fail the mandatory decision") +} + +// TestProjectV1MapAndDiscriminationVectors covers the named edge cases a single +// all-set maximal config misses. +func TestProjectV1MapAndDiscriminationVectors(t *testing.T) { + t.Run("map key membership is measured", func(t *testing.T) { + withEmptyValue := projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{Defines: map[string]string{"k": ""}}, + } + withoutKey := projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{Defines: map[string]string{}}, + } + + assert.NotEqual(t, mustProject(t, withEmptyValue), mustProject(t, withoutKey), + `{"k":""} must differ from {}`) + }) + + t.Run("a projected-empty package entry adds no bytes", func(t *testing.T) { + withEmptyEntry := projectconfig.ComponentConfig{ + Packages: map[string]projectconfig.PackageConfig{"pkg": {}}, + } + + assert.Equal(t, mustProject(t, projectconfig.ComponentConfig{}), mustProject(t, withEmptyEntry), + "a package whose value projects empty must not move the digest") + }) + + t.Run("overlay slice order is significant", func(t *testing.T) { + first := projectconfig.ComponentConfig{Overlays: []projectconfig.ComponentOverlay{ + {Tag: "a"}, {Tag: "b"}, + }} + swapped := projectconfig.ComponentConfig{Overlays: []projectconfig.ComponentOverlay{ + {Tag: "b"}, {Tag: "a"}, + }} + + assert.NotEqual(t, mustProject(t, first), mustProject(t, swapped), + "reordering overlays is a different encoding") + }) +} diff --git a/internal/fingerprint/testdata/golden_v1.json b/internal/fingerprint/testdata/golden_v1.json new file mode 100644 index 00000000..edb7b730 --- /dev/null +++ b/internal/fingerprint/testdata/golden_v1.json @@ -0,0 +1,7 @@ +{ + "_README": "Frozen v1 projection digests. APPEND-ONLY: never edit an existing digest - a new encoding is a new content version, not an edit here. Management rules and the additive-field workflow live in goldenConfigs's doc comment in golden_internal_test.go.", + "defines-empty-value": "sha256:0c279e94184764f5337a3560ed925a72ce77d799b80f35f8d8bc391c6c02d7ee", + "maximal": "sha256:5e847df93fb896e3b7a0e0508755087bbb3dc18713a210c03c451d04a5d276b6", + "minimal": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "single-overlay": "sha256:bb3a8d293e79ff82d245d4b5efa6c9d3f3039ee9271dc12d5e89e375eafb0a95" +} diff --git a/internal/fingerprint/v1.go b/internal/fingerprint/v1.go new file mode 100644 index 00000000..52f92c10 --- /dev/null +++ b/internal/fingerprint/v1.go @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "fmt" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" +) + +// projectV1 is the hand-written v1 reference projection. A future generator must +// reproduce this projection tree (proven by the golden vectors, which pin the +// sha256 of its RFC 8785 canonical form), not this source. Do NOT change v1's +// output - a new encoding is a new version, never an edit here. +// +// Each measured field is emitted by literal Go path under its frozen emit-key +// (the field's toml key), so deleting a measured field will not compile and the +// key can never drift to the Go identifier. Map key order is irrelevant: the +// assembled document is canonicalized (RFC 8785) at serialization. projectV1 +// reads the resolved config directly; the omit predicate treats a nil-or-empty +// scalar slice as zero (see isScalarZero), so no config-normalization pre-pass is +// needed before projecting. +func projectV1(config projectconfig.ComponentConfig) (map[string]any, error) { + var builder treeBuilder + + build, err := projectV1Build(config.Build) + builder.emitComposite("build", build, err) + + overlays, err := projectV1Slice(len(config.Overlays), func(index int) (map[string]any, error) { + return projectV1Overlay(config.Overlays[index]) + }) + builder.emitSlice("overlays", overlays, err) + + packages, err := projectV1Packages(config.Packages) + builder.emitComposite("packages", packages, err) + + release, err := projectV1Release(config.Release) + builder.emitComposite("release", release, err) + + render, err := projectV1Render(config.Render) + builder.emitComposite("render", render, err) + + sourceFiles, err := projectV1Slice(len(config.SourceFiles), func(index int) (map[string]any, error) { + return projectV1SourceFile(config.SourceFiles[index]) + }) + builder.emitSlice("source-files", sourceFiles, err) + + spec, err := projectV1Spec(config.Spec) + builder.emitComposite("spec", spec, err) + + return builder.result() +} + +func projectV1Spec(spec projectconfig.SpecSource) (map[string]any, error) { + var builder treeBuilder + + builder.emit("type", spec.SourceType) + builder.emit("upstream-commit", spec.UpstreamCommit) + + distro, err := projectV1Distro(spec.UpstreamDistro) + builder.emitComposite("upstream-distro", distro, err) + builder.emit("upstream-name", spec.UpstreamName) + + return builder.result() +} + +func projectV1Distro(distro projectconfig.DistroReference) (map[string]any, error) { + var builder treeBuilder + + builder.emit("name", distro.Name) + builder.emit("version", distro.Version) + + return builder.result() +} + +func projectV1Release(release projectconfig.ReleaseConfig) (map[string]any, error) { + var builder treeBuilder + + builder.emit("calculation", release.Calculation) + + return builder.result() +} + +func projectV1Build(build projectconfig.ComponentBuildConfig) (map[string]any, error) { + var builder treeBuilder + + check, err := projectV1Check(build.Check) + builder.emitComposite("check", check, err) + builder.emitMap("defines", build.Defines) + builder.emit("undefines", build.Undefines) + builder.emit("with", build.With) + builder.emit("without", build.Without) + + return builder.result() +} + +func projectV1Check(check projectconfig.CheckConfig) (map[string]any, error) { + var builder treeBuilder + + builder.emit("skip", check.Skip) + + return builder.result() +} + +func projectV1Render(render projectconfig.ComponentRenderConfig) (map[string]any, error) { + var builder treeBuilder + + builder.emit("skip-file-filter", render.SkipFileFilter) + + return builder.result() +} + +func projectV1Overlay(overlay projectconfig.ComponentOverlay) (map[string]any, error) { + var builder treeBuilder + + builder.emit("file", overlay.Filename) + builder.emit("lines", overlay.Lines) + builder.emit("package", overlay.PackageName) + builder.emit("regex", overlay.Regex) + builder.emit("replacement", overlay.Replacement) + builder.emit("section", overlay.SectionName) + builder.emit("tag", overlay.Tag) + builder.emit("type", overlay.Type) + builder.emit("value", overlay.Value) + + return builder.result() +} + +func projectV1SourceFile(sourceFile projectconfig.SourceFileReference) (map[string]any, error) { + var builder treeBuilder + + builder.emit("filename", sourceFile.Filename) + builder.emit("hash", sourceFile.Hash) + builder.emit("hash-type", sourceFile.HashType) + builder.emit("replace-upstream", sourceFile.ReplaceUpstream) + + return builder.result() +} + +// projectV1Package projects a single per-package override. Every PackageConfig +// leaf is publish-only (fingerprint:"-"), so this projects empty today and each +// map entry projects empty - yet the map stays measured (walked), so a future +// build-effective PackageConfig field is auto-measured. +func projectV1Package(_ projectconfig.PackageConfig) (map[string]any, error) { + var builder treeBuilder + + return builder.result() +} + +// projectV1Packages projects the per-package overrides map, each value via its +// frozen sub-projector. An entry whose value projects empty contributes nothing +// (projected emptiness), so an all-publish-only map adds nothing while remaining +// in the measured graph. Key ordering is left to RFC 8785 at serialization. +func projectV1Packages(packages map[string]projectconfig.PackageConfig) (map[string]any, error) { + var builder treeBuilder + + for key, value := range packages { + projected, err := projectV1Package(value) + builder.emitComposite(key, projected, err) + } + + return builder.result() +} + +// projectV1Slice projects the elements of a struct slice in resolved slice order +// as a JSON array, so distinct positions cannot collide. Element order is a +// frozen semantic: reordering elements is a different encoding. An element that +// projects empty is kept as an empty object so positions stay aligned. +func projectV1Slice(length int, element func(index int) (map[string]any, error)) ([]any, error) { + if length == 0 { + return nil, nil + } + + out := make([]any, 0, length) + + for index := range length { + encoded, err := element(index) + if err != nil { + return nil, fmt.Errorf("element %d:\n%w", index, err) + } + + if encoded == nil { + encoded = map[string]any{} + } + + out = append(out, encoded) + } + + return out, nil +} diff --git a/internal/projectconfig/build.go b/internal/projectconfig/build.go index 385e3108..f9a1e3e8 100644 --- a/internal/projectconfig/build.go +++ b/internal/projectconfig/build.go @@ -11,7 +11,7 @@ import ( // CheckConfig encapsulates configuration for the %check section of a spec file. type CheckConfig struct { // Skip indicates whether the %check section should be disabled for this component. - Skip bool `toml:"skip,omitempty" json:"skip,omitempty" jsonschema:"title=Skip check,description=Disables the %check section by prepending 'exit 0' when set to true"` + Skip bool `toml:"skip,omitempty" json:"skip,omitempty" jsonschema:"title=Skip check,description=Disables the %check section by prepending 'exit 0' when set to true" fingerprint:"v1..*"` // SkipReason provides a required justification when Skip is true. SkipReason string `toml:"skip_reason,omitempty" json:"skipReason,omitempty" jsonschema:"title=Skip reason,description=Required justification for skipping the %check section" fingerprint:"-"` } @@ -33,18 +33,22 @@ func (c *CheckConfig) Validate() error { // or prepare the sources for a component are out of scope. type ComponentBuildConfig struct { // Which features should be enabled via `with` options to the builder. - With []string `toml:"with,omitempty" json:"with,omitempty" jsonschema:"title=With options,description='with' options to pass to the builder."` + With []string `toml:"with,omitempty" json:"with,omitempty" jsonschema:"title=With options,description='with' options to pass to the builder." fingerprint:"v1..*"` // Which features should be disabled via `without` options to the builder. - Without []string `toml:"without,omitempty" json:"without,omitempty" jsonschema:"title=Without options,description='without' options to pass to the builder."` + Without []string `toml:"without,omitempty" json:"without,omitempty" jsonschema:"title=Without options,description='without' options to pass to the builder." fingerprint:"v1..*"` // Macro definitions. - Defines map[string]string `toml:"defines,omitempty" json:"defines,omitempty" jsonschema:"title=Macro definitions,description=Macro definitions to pass to the builder."` + Defines map[string]string `toml:"defines,omitempty" json:"defines,omitempty" jsonschema:"title=Macro definitions,description=Macro definitions to pass to the builder." fingerprint:"v1..*"` // Undefine macros that would otherwise be defined by the component configuration. - Undefines []string `toml:"undefines,omitempty" json:"undefines,omitempty" jsonschema:"title=Undefined macros,description=Macro names to undefine when passing to the builder."` + Undefines []string `toml:"undefines,omitempty" json:"undefines,omitempty" jsonschema:"title=Undefined macros,description=Macro names to undefine when passing to the builder." fingerprint:"v1..*"` // Check section configuration. - Check CheckConfig `toml:"check,omitempty" json:"check,omitempty" jsonschema:"title=Check configuration,description=Configuration for the %check section"` + Check CheckConfig `toml:"check,omitempty" json:"check,omitempty" jsonschema:"title=Check configuration,description=Configuration for the %check section" fingerprint:"v1..*"` // Failure configuration and policy for this component's build. + // fingerprint:"-" prunes this subtree; a build-effective field added here is unmeasured + // until the parent is un-pruned. Failure ComponentBuildFailureConfig `toml:"failure,omitempty" json:"failure,omitempty" jsonschema:"title=Build failure configuration,description=Configuration and policy regarding build failures for this component." fingerprint:"-"` // Hints for how or when to build the component; must not be required for correctness of builds. + // fingerprint:"-" prunes this subtree; a build-effective field added here is unmeasured + // until the parent is un-pruned. Hints ComponentBuildHints `toml:"hints,omitempty" json:"hints,omitempty" jsonschema:"title=Build hints,description=Non-essential hints for how or when to build the component." fingerprint:"-"` } diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 9d45a2b4..c3a885ae 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -60,15 +60,17 @@ type SourceFileReference struct { Component ComponentReference `toml:"-" json:"-" fingerprint:"-"` // Name of the source file; must be non-empty. - Filename string `toml:"filename" json:"filename"` + Filename string `toml:"filename" json:"filename" fingerprint:"v1..*"` // Hash of the source file, expressed as a hex string. - Hash string `toml:"hash,omitempty" json:"hash,omitempty"` + Hash string `toml:"hash,omitempty" json:"hash,omitempty" fingerprint:"v1..*"` // Type of hash used by Hash (e.g., "SHA256", "SHA512"). - HashType fileutils.HashType `toml:"hash-type,omitempty" json:"hashType,omitempty" jsonschema:"enum=SHA256,enum=SHA512,title=Hash type,description=Hash algorithm used for the hash value"` + HashType fileutils.HashType `toml:"hash-type,omitempty" json:"hashType,omitempty" jsonschema:"enum=SHA256,enum=SHA512,title=Hash type,description=Hash algorithm used for the hash value" fingerprint:"v1..*"` // Origin for this source file. When omitted, the file is resolved via the lookaside cache. + // fingerprint:"-" prunes this subtree; a build-effective field added here is unmeasured + // until the parent is un-pruned. Origin Origin `toml:"origin,omitempty" json:"origin,omitempty" fingerprint:"-"` // ReplaceUpstream, when true, intentionally replaces an upstream entry with the same @@ -76,7 +78,7 @@ type SourceFileReference struct { // entry must exist or source preparation fails. When false (the default), a filename // collision with an existing 'sources' entry is treated as an error. // [SourceFileReference.ReplaceReason] is required when this is true. - ReplaceUpstream bool `toml:"replace-upstream,omitempty" json:"replaceUpstream,omitempty" jsonschema:"title=Replace upstream,description=When true\\, intentionally replaces a same-named entry in the upstream 'sources' file. Requires 'replace-reason'."` + ReplaceUpstream bool `toml:"replace-upstream,omitempty" json:"replaceUpstream,omitempty" jsonschema:"title=Replace upstream,description=When true\\, intentionally replaces a same-named entry in the upstream 'sources' file. Requires 'replace-reason'." fingerprint:"v1..*"` // ReplaceReason is a human-readable explanation for why an upstream 'sources' entry is // being replaced. Required when [SourceFileReference.ReplaceUpstream] is true; must be @@ -174,7 +176,7 @@ const ( // ReleaseConfig holds release-related configuration for a component. type ReleaseConfig struct { // Calculation controls how the Release tag is managed during rendering. - Calculation ReleaseCalculation `toml:"calculation,omitempty" json:"calculation,omitempty" validate:"omitempty,oneof=auto autorelease static manual" jsonschema:"enum=auto,enum=autorelease,enum=static,enum=manual,default=auto,title=Release calculation,description=Controls how the Release tag is managed during rendering. Empty or omitted means auto."` + Calculation ReleaseCalculation `toml:"calculation,omitempty" json:"calculation,omitempty" validate:"omitempty,oneof=auto autorelease static manual" jsonschema:"enum=auto,enum=autorelease,enum=static,enum=manual,default=auto,title=Release calculation,description=Controls how the Release tag is managed during rendering. Empty or omitted means auto." fingerprint:"v1..*"` } // FreshnessStatus indicates whether a component's current config matches @@ -265,30 +267,35 @@ type ComponentConfig struct { Locked *ComponentLockData `toml:"-" json:"locked,omitempty" table:"-" fingerprint:"-"` // Where to get its spec and adjacent files from. - Spec SpecSource `toml:"spec,omitempty" json:"spec,omitempty" jsonschema:"title=Spec,description=Identifies where to find the spec for this component"` + Spec SpecSource `toml:"spec,omitempty" json:"spec,omitempty" jsonschema:"title=Spec,description=Identifies where to find the spec for this component" fingerprint:"v1..*"` // Release configuration for this component. - Release ReleaseConfig `toml:"release,omitempty" json:"release,omitempty" table:"-" jsonschema:"title=Release configuration,description=Configuration for how the Release tag is managed during rendering."` + Release ReleaseConfig `toml:"release,omitempty" json:"release,omitempty" table:"-" jsonschema:"title=Release configuration,description=Configuration for how the Release tag is managed during rendering." fingerprint:"v1..*"` // Overlays to apply to sources after they've been acquired. May mutate the spec as well as sources. - Overlays []ComponentOverlay `toml:"overlays,omitempty" json:"overlays,omitempty" table:"-" jsonschema:"title=Overlays,description=Overlays to apply to this component's spec and/or sources"` + Overlays []ComponentOverlay `toml:"overlays,omitempty" json:"overlays,omitempty" table:"-" jsonschema:"title=Overlays,description=Overlays to apply to this component's spec and/or sources" fingerprint:"v1..*"` // Configuration for building the component. - Build ComponentBuildConfig `toml:"build,omitempty" json:"build,omitempty" table:"-" jsonschema:"title=Build configuration,description=Configuration for building the component"` + Build ComponentBuildConfig `toml:"build,omitempty" json:"build,omitempty" table:"-" jsonschema:"title=Build configuration,description=Configuration for building the component" fingerprint:"v1..*"` // Configuration for rendering the component. - Render ComponentRenderConfig `toml:"render,omitempty" json:"render,omitempty" table:"-" jsonschema:"title=Render configuration,description=Configuration for rendering the component"` + Render ComponentRenderConfig `toml:"render,omitempty" json:"render,omitempty" table:"-" jsonschema:"title=Render configuration,description=Configuration for rendering the component" fingerprint:"v1..*"` // Source file references for this component. - SourceFiles []SourceFileReference `toml:"source-files,omitempty" json:"sourceFiles,omitempty" table:"-" jsonschema:"title=Source files,description=Source files to download for this component"` + SourceFiles []SourceFileReference `toml:"source-files,omitempty" json:"sourceFiles,omitempty" table:"-" jsonschema:"title=Source files,description=Source files to download for this component" fingerprint:"v1..*"` // Per-package configuration overrides, keyed by exact binary package name. // Takes precedence over package-group defaults. - Packages map[string]PackageConfig `toml:"packages,omitempty" json:"packages,omitempty" table:"-" validate:"dive" jsonschema:"title=Package overrides,description=Per-package configuration overrides keyed by exact binary package name"` + // Kept measured (not pruned with "-"): every PackageConfig leaf is publish-only, so each + // entry projects empty and adds no fingerprint bytes today, while the completeness walk still + // descends so a future build-effective PackageConfig field is auto-measured. + Packages map[string]PackageConfig `toml:"packages,omitempty" json:"packages,omitempty" table:"-" validate:"dive" jsonschema:"title=Package overrides,description=Per-package configuration overrides keyed by exact binary package name" fingerprint:"v1..*"` // Publish holds the component-level publish settings. These provide default channels for // all packages produced by this component. Overridden by package-group and per-package settings // for binary and debuginfo channels. + // fingerprint:"-" prunes this subtree; a build-effective field added here is unmeasured + // until the parent is un-pruned. Publish ComponentPublishConfig `toml:"publish,omitempty" json:"publish,omitempty" table:"-" jsonschema:"title=Publish settings,description=Component-level publish channel settings" fingerprint:"-"` } diff --git a/internal/projectconfig/distro.go b/internal/projectconfig/distro.go index 90d046f8..bc49bbd2 100644 --- a/internal/projectconfig/distro.go +++ b/internal/projectconfig/distro.go @@ -14,9 +14,9 @@ import ( // Encapsulates a reference to a version of a distro. type DistroReference struct { // Name of the referenced distro. - Name string `toml:"name" json:"name,omitempty" jsonschema:"required,title=Name,description=Name of the referenced distro"` + Name string `toml:"name" json:"name,omitempty" jsonschema:"required,title=Name,description=Name of the referenced distro" fingerprint:"v1..*"` // Version of the referenced distro. - Version string `toml:"version,omitempty" json:"version,omitempty" jsonschema:"title=Version,description=Version of the referenced distro"` + Version string `toml:"version,omitempty" json:"version,omitempty" jsonschema:"title=Version,description=Version of the referenced distro" fingerprint:"v1..*"` // Snapshot date/time for source code if specified components will use source as it existed at this time. // Note: set this on the distro or group default-component-config, not on individual components. // Per-component snapshots are rejected when lock validation is enabled. diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index ceee86d3..8cd94b3b 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -7,18 +7,25 @@ import ( "reflect" "testing" + "github.com/microsoft/azure-linux-dev-tools/internal/fingerprint" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestAllFingerprintedFieldsHaveDecision verifies that every field in every -// fingerprinted struct has been consciously categorized as either included -// (no fingerprint tag) or excluded (`fingerprint:"-"`). +// fingerprinted struct carries an explicit, valid fingerprint decision: either a +// version-set tag (e.g. `fingerprint:"v1..*"`) declaring when it is measured, or +// `fingerprint:"-"` excluding it. An absent tag is now a failure - the +// mandatory-decision rule the projection substrate relies on (a forgotten field +// must never silently drop out of the hash). // -// This test serves two purposes: -// 1. It ensures that newly added fields default to **included** in the fingerprint -// (the safe default — you get a false positive, never a false negative). -// 2. It catches accidental removal of `fingerprint:"-"` tags from excluded fields, +// This test serves three purposes: +// 1. It enforces the mandatory tag via [fingerprint.ValidateFieldTag], which also +// rejects malformed / future-referencing tags and the unimplemented +// composite-'!' placeholder. +// 2. It ensures version-set tags parse and resolve an emit-key. +// 3. It catches accidental removal of `fingerprint:"-"` tags from excluded fields, // since all exclusions are tracked in expectedExclusions. func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // All struct types whose fields participate in component fingerprinting. @@ -94,7 +101,8 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { "SpecSource.Path": true, } - // Collect all actual exclusions found via reflection, and flag invalid tag values. + // Collect all actual exclusions found via reflection, and validate every + // field's tag through the production decision gate. actualExclusions := make(map[string]bool) for _, st := range fingerprintedStructs { @@ -102,27 +110,14 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { field := st.Field(i) key := st.Name() + "." + field.Name - tag := field.Tag.Get("fingerprint") + // ValidateFieldTag enforces the mandatory decision: an absent tag, + // a malformed/future-referencing version set, or an unimplemented + // composite-'!' all fail here. + require.NoErrorf(t, fingerprint.ValidateFieldTag(field), + "field %q has no valid fingerprint decision", key) - switch tag { - case "": - // No tag — included by default (the safe default). - case "-": + if field.Tag.Get("fingerprint") == "-" { actualExclusions[key] = true - default: - // 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; "+ - "remove the tag to include the field", key, tag) } } } diff --git a/internal/projectconfig/overlay.go b/internal/projectconfig/overlay.go index 7ec0b50e..4999bb36 100644 --- a/internal/projectconfig/overlay.go +++ b/internal/projectconfig/overlay.go @@ -17,27 +17,27 @@ import ( // ComponentOverlay represents an overlay that may be applied to a component's spec and/or its sources. type ComponentOverlay struct { // The type of overlay to apply. - Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-prepend-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=spec-remove-subpackage,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply"` + Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-prepend-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=spec-remove-subpackage,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply" fingerprint:"v1..*"` // Human readable description of overlay; primarily present to document the need for the change. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human readable description of overlay" fingerprint:"-"` // For overlays that apply to non-spec files, indicates the filename. For overlays that can // apply to multiple files, supports glob patterns (including globstar). - Filename string `toml:"file,omitempty" json:"file,omitempty" jsonschema:"title=Filename,description=The name of the non-spec file to which this overlay applies, or a glob pattern matching multiple files"` + Filename string `toml:"file,omitempty" json:"file,omitempty" jsonschema:"title=Filename,description=The name of the non-spec file to which this overlay applies, or a glob pattern matching multiple files" fingerprint:"v1..*"` // For overlays that apply to specs, indicates the name of the section to which it applies. - SectionName string `toml:"section,omitempty" json:"section,omitempty" jsonschema:"title=Section name,description=The name of the section to which this overlay applies"` + SectionName string `toml:"section,omitempty" json:"section,omitempty" jsonschema:"title=Section name,description=The name of the section to which this overlay applies" fingerprint:"v1..*"` // For overlays that apply to specs, indicates the name of the sub-package to which it applies. - PackageName string `toml:"package,omitempty" json:"package,omitempty" jsonschema:"title=Package name,description=The name of the sub-package to which this overlay applies"` + PackageName string `toml:"package,omitempty" json:"package,omitempty" jsonschema:"title=Package name,description=The name of the sub-package to which this overlay applies" fingerprint:"v1..*"` // For overlays that apply to spec tags, indicates the name of the tag. - Tag string `toml:"tag,omitempty" json:"tag,omitempty" jsonschema:"title=Tag,description=For overlays that apply to spec tags, indicates the name of the tag"` + Tag string `toml:"tag,omitempty" json:"tag,omitempty" jsonschema:"title=Tag,description=For overlays that apply to spec tags, indicates the name of the tag" fingerprint:"v1..*"` // For overlays that apply to values in specs, an exact string value to match. - Value string `toml:"value,omitempty" json:"value,omitempty" jsonschema:"title=Value,description=An exact string value to match in the spec"` + Value string `toml:"value,omitempty" json:"value,omitempty" jsonschema:"title=Value,description=An exact string value to match in the spec" fingerprint:"v1..*"` // For overlays that use a regular expression to match text in the spec, the regular expression to match. - Regex string `toml:"regex,omitempty" json:"regex,omitempty" jsonschema:"title=Regular expression,description=The regular expression to match in the spec"` + Regex string `toml:"regex,omitempty" json:"regex,omitempty" jsonschema:"title=Regular expression,description=The regular expression to match in the spec" fingerprint:"v1..*"` // For overlays that replace text in a spec, the replacement text to use. - Replacement string `toml:"replacement,omitempty" json:"replacement,omitempty" jsonschema:"title=Replacement text,description=The replacement text to use in the spec"` + Replacement string `toml:"replacement,omitempty" json:"replacement,omitempty" jsonschema:"title=Replacement text,description=The replacement text to use in the spec" fingerprint:"v1..*"` // For overlays that reference lines of text, the lines of text to use. - Lines []string `toml:"lines,omitempty" json:"lines,omitempty" jsonschema:"title=Lines,description=The lines of text to use"` + Lines []string `toml:"lines,omitempty" json:"lines,omitempty" jsonschema:"title=Lines,description=The lines of text to use" fingerprint:"v1..*"` // For overlays that require a source file as input, indicates a path to that file; relative paths are relative to // the config file that defines the overlay. // Excluded from fingerprint because it contains an absolute path that varies by checkout diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go index c504c978..0d8a1274 100644 --- a/internal/projectconfig/package.go +++ b/internal/projectconfig/package.go @@ -47,6 +47,8 @@ func (p PackagePublishConfig) EffectiveRPMChannel() string { // Currently only publish settings are supported; additional fields may be added in the future. type PackageConfig struct { // Publish holds the publish settings for this package. + // fingerprint:"-" prunes this subtree; a build-effective field added here is unmeasured + // until the parent is un-pruned. Publish PackagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this binary package" fingerprint:"-"` } diff --git a/internal/projectconfig/render.go b/internal/projectconfig/render.go index c623f8af..0aa4f425 100644 --- a/internal/projectconfig/render.go +++ b/internal/projectconfig/render.go @@ -11,5 +11,5 @@ type ComponentRenderConfig struct { // Some specs use macros that spectool cannot expand, causing referenced // files to be incorrectly removed. Setting this to true preserves all // files from the dist-git checkout. - SkipFileFilter bool `toml:"skip-file-filter,omitempty" json:"skipFileFilter,omitempty" jsonschema:"title=Skip file filter,description=Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags"` + SkipFileFilter bool `toml:"skip-file-filter,omitempty" json:"skipFileFilter,omitempty" jsonschema:"title=Skip file filter,description=Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" fingerprint:"v1..*"` } diff --git a/internal/projectconfig/specsource.go b/internal/projectconfig/specsource.go index 48116631..180edec2 100644 --- a/internal/projectconfig/specsource.go +++ b/internal/projectconfig/specsource.go @@ -6,7 +6,7 @@ package projectconfig // Provides source information for locating the spec for a component. type SpecSource struct { // SourceType indicates the type of source for the spec. - SourceType SpecSourceType `toml:"type" json:"type,omitempty" validate:"omitempty,oneof=local upstream" jsonschema:"required,enum=local,enum=upstream,enum=,title=Source Type,description=The type of the spec source"` + SourceType SpecSourceType `toml:"type" json:"type,omitempty" validate:"omitempty,oneof=local upstream" jsonschema:"required,enum=local,enum=upstream,enum=,title=Source Type,description=The type of the spec source" fingerprint:"v1..*"` // Path indicates the path to the spec file; only relevant for local specs. // Excluded from fingerprint because it contains an absolute path that varies by checkout @@ -14,14 +14,14 @@ type SpecSource struct { Path string `toml:"path,omitempty" json:"path,omitempty" validate:"excluded_unless=SourceType local,required_if=SourceType local" jsonschema:"title=Path,description=Path to the spec (if available locally),example=specs/mycomponent.spec" fingerprint:"-"` // UpstreamDistro indicates the upstream distro providing the spec; only relevant for upstream specs. - UpstreamDistro DistroReference `toml:"upstream-distro,omitempty" json:"upstreamDistro,omitempty" jsonschema:"title=Upstream distro,description=Reference to the upstream distro providing the spec"` + UpstreamDistro DistroReference `toml:"upstream-distro,omitempty" json:"upstreamDistro,omitempty" jsonschema:"title=Upstream distro,description=Reference to the upstream distro providing the spec" fingerprint:"v1..*"` // UpstreamName indicates the name of the component in the upstream distro; only relevant for upstream specs. - UpstreamName string `toml:"upstream-name,omitempty" json:"upstreamName,omitempty" validate:"excluded_unless=SourceType upstream" jsonschema:"title=Upstream component name,description=Name of the component in the upstream distro,example=different-name"` + UpstreamName string `toml:"upstream-name,omitempty" json:"upstreamName,omitempty" validate:"excluded_unless=SourceType upstream" jsonschema:"title=Upstream component name,description=Name of the component in the upstream distro,example=different-name" fingerprint:"v1..*"` // UpstreamCommit pins the upstream spec to a specific git commit hash; only relevant for upstream specs. // When set, this takes priority over the snapshot date-time on the distro reference. - UpstreamCommit string `toml:"upstream-commit,omitempty" json:"upstreamCommit,omitempty" validate:"excluded_unless=SourceType upstream,omitempty,hexadecimal,min=7,max=40" jsonschema:"title=Upstream commit,description=Git commit hash to pin the upstream spec to. Takes priority over snapshot.,minLength=7,maxLength=40,pattern=^[0-9a-fA-F]+$,example=abc1234def5678"` + UpstreamCommit string `toml:"upstream-commit,omitempty" json:"upstreamCommit,omitempty" validate:"excluded_unless=SourceType upstream,omitempty,hexadecimal,min=7,max=40" jsonschema:"title=Upstream commit,description=Git commit hash to pin the upstream spec to. Takes priority over snapshot.,minLength=7,maxLength=40,pattern=^[0-9a-fA-F]+$,example=abc1234def5678" fingerprint:"v1..*"` } // Implements the [Stringer] interface.