Skip to content

Commit fa6efa4

Browse files
committed
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.
1 parent 0256227 commit fa6efa4

9 files changed

Lines changed: 899 additions & 3 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/go-playground/validator/v10 v10.30.2
2626
github.com/google/renameio v1.0.1
2727
github.com/google/uuid v1.6.0
28+
github.com/gowebpki/jcs v1.0.1
2829
github.com/h2non/gock v1.2.0
2930
github.com/invopop/jsonschema v0.14.0
3031
github.com/jedib0t/go-pretty/v6 v6.7.10

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU
169169
github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
170170
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
171171
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
172+
github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU=
173+
github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI=
172174
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
173175
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
174176
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=

internal/fingerprint/canonical.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package fingerprint
5+
6+
import (
7+
"fmt"
8+
"reflect"
9+
)
10+
11+
// maxSafeInteger is the largest integer (2^53) that survives RFC 8785's
12+
// ECMAScript number serialization without precision loss. An integer of greater
13+
// magnitude is rejected rather than silently coerced - such a value is almost
14+
// always an identifier or hash that belongs in the config as a string.
15+
const maxSafeInteger = 1 << 53
16+
17+
// treeBuilder accumulates the canonical projection of a struct as a JSON-able
18+
// map[string]any. The omit predicates mirror the frozen projection semantics:
19+
//
20+
// - emit: scalar leaf, omit-if-zero (the common "active field" path).
21+
// - emitAlways: scalar leaf emitted even at its zero value (the '!' case, for a
22+
// field whose zero is build-meaningful).
23+
// - emitMap: scalar-valued map measuring key membership - every entry is kept
24+
// (so {"k":""} differs from {}); the whole map is omitted only when empty.
25+
// - emitComposite: nested-struct projection, omitted on projected emptiness (the
26+
// sub-projector produced no measured keys).
27+
// - emitSlice: struct-slice projection, omitted when empty; order is significant.
28+
//
29+
// Map key ordering is irrelevant here: RFC 8785 sorts object keys at
30+
// serialization, so the builder need not sort. The first error is deferred
31+
// (bufio-style) and surfaces from result, keeping the projectors readable.
32+
type treeBuilder struct {
33+
out map[string]any
34+
err error
35+
}
36+
37+
func (b *treeBuilder) set(key string, value any) {
38+
if b.out == nil {
39+
b.out = map[string]any{}
40+
}
41+
42+
b.out[key] = value
43+
}
44+
45+
// emit adds a scalar-leaf field, omitting it when its resolved value is zero.
46+
func (b *treeBuilder) emit(key string, value any) {
47+
b.emitScalar(key, value, false)
48+
}
49+
50+
// emitAlways adds a scalar-leaf field even when its value is zero (the '!' case).
51+
func (b *treeBuilder) emitAlways(key string, value any) {
52+
b.emitScalar(key, value, true)
53+
}
54+
55+
func (b *treeBuilder) emitScalar(key string, value any, always bool) {
56+
if b.err != nil {
57+
return
58+
}
59+
60+
rval := reflect.ValueOf(value)
61+
if !rval.IsValid() {
62+
b.err = fmt.Errorf("emit %#q: a nil value has no fingerprint encoding", key)
63+
64+
return
65+
}
66+
67+
if !isScalarLeaf(rval) {
68+
b.err = fmt.Errorf(
69+
"emit %#q: kind %s is not an encodable scalar leaf; composites use emitComposite, emitSlice, or emitMap",
70+
key, rval.Kind())
71+
72+
return
73+
}
74+
75+
if !always && isScalarZero(rval) {
76+
return
77+
}
78+
79+
encoded, err := scalarToJSON(rval)
80+
if err != nil {
81+
b.err = fmt.Errorf("encoding field %#q:\n%w", key, err)
82+
83+
return
84+
}
85+
86+
b.set(key, encoded)
87+
}
88+
89+
// emitMap adds a scalar-valued map, measuring key membership. Struct-valued maps
90+
// are a composite and must be projected entry-by-entry through emitComposite.
91+
func (b *treeBuilder) emitMap(key string, mapValue any) {
92+
if b.err != nil {
93+
return
94+
}
95+
96+
rval := reflect.ValueOf(mapValue)
97+
if !rval.IsValid() || rval.Kind() != reflect.Map {
98+
b.err = fmt.Errorf("emitMap %#q: value is not a map", key)
99+
100+
return
101+
}
102+
103+
if rval.Type().Key().Kind() != reflect.String {
104+
b.err = fmt.Errorf("emitMap %#q: map key kind %s is not string", key, rval.Type().Key().Kind())
105+
106+
return
107+
}
108+
109+
if rval.Len() == 0 {
110+
return
111+
}
112+
113+
entries := make(map[string]any, rval.Len())
114+
115+
iter := rval.MapRange()
116+
for iter.Next() {
117+
encoded, err := scalarToJSON(iter.Value())
118+
if err != nil {
119+
b.err = fmt.Errorf("encoding map %#q entry %#q:\n%w", key, iter.Key().String(), err)
120+
121+
return
122+
}
123+
124+
entries[iter.Key().String()] = encoded
125+
}
126+
127+
b.set(key, entries)
128+
}
129+
130+
// emitComposite adds a nested-struct projection, omitting it when the
131+
// sub-projector produced no measured keys (projected emptiness).
132+
func (b *treeBuilder) emitComposite(key string, sub map[string]any) {
133+
if len(sub) == 0 {
134+
return
135+
}
136+
137+
b.set(key, sub)
138+
}
139+
140+
// emitSlice adds a struct-slice projection, omitting it when empty. A JSON array
141+
// preserves order, so reordering elements is a different encoding.
142+
func (b *treeBuilder) emitSlice(key string, elems []any) {
143+
if len(elems) == 0 {
144+
return
145+
}
146+
147+
b.set(key, elems)
148+
}
149+
150+
// result returns the accumulated projection map (nil when nothing was emitted)
151+
// or the first deferred error.
152+
func (b *treeBuilder) result() (map[string]any, error) {
153+
if b.err != nil {
154+
return nil, b.err
155+
}
156+
157+
return b.out, nil
158+
}
159+
160+
// scalarToJSON converts a scalar leaf to its JSON-able Go value by underlying
161+
// kind, so a named type is encoded by kind rather than via any MarshalJSON /
162+
// MarshalText it might carry. An integer beyond the JSON-safe range is rejected
163+
// rather than silently coerced by RFC 8785's number model (see maxSafeInteger).
164+
func scalarToJSON(rval reflect.Value) (any, error) {
165+
//exhaustive:ignore // the default branch deliberately rejects every un-pinned kind.
166+
switch rval.Kind() {
167+
case reflect.String:
168+
return rval.String(), nil
169+
case reflect.Bool:
170+
return rval.Bool(), nil
171+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
172+
number := rval.Int()
173+
if number > maxSafeInteger || number < -maxSafeInteger {
174+
return nil, fmt.Errorf(
175+
"integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number)
176+
}
177+
178+
return number, nil
179+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
180+
number := rval.Uint()
181+
if number > maxSafeInteger {
182+
return nil, fmt.Errorf(
183+
"integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number)
184+
}
185+
186+
return number, nil
187+
case reflect.Slice:
188+
out := make([]any, 0, rval.Len())
189+
for i := range rval.Len() {
190+
elem, err := scalarToJSON(rval.Index(i))
191+
if err != nil {
192+
return nil, fmt.Errorf("slice element %d:\n%w", i, err)
193+
}
194+
195+
out = append(out, elem)
196+
}
197+
198+
return out, nil
199+
default:
200+
return nil, fmt.Errorf(
201+
"kind %s is not encodable: only string, bool, JSON-safe integers, and slices of those are pinned",
202+
rval.Kind())
203+
}
204+
}
205+
206+
// isScalarZero is the omit-if-zero predicate for a scalar leaf. A nil AND a
207+
// non-nil empty scalar slice both count as zero, so emit cannot distinguish them
208+
// (resolution's mergo WithAppendSlice merge yields either for the same intent).
209+
// emitAlways bypasses this, so an explicit empty '!' slice still emits. Non-slice
210+
// scalars fall back to plain IsZero, unchanged.
211+
func isScalarZero(rval reflect.Value) bool {
212+
if rval.Kind() == reflect.Slice {
213+
return rval.Len() == 0
214+
}
215+
216+
return rval.IsZero()
217+
}
218+
219+
// isScalarLeaf reports whether v is a scalar leaf for omit-predicate purposes:
220+
// a scalar kind, or a slice whose element kind is scalar. Composites (struct,
221+
// map, slice-of-struct, pointer, interface, ...) are not scalar leaves.
222+
func isScalarLeaf(rval reflect.Value) bool {
223+
if isScalarKind(rval.Kind()) {
224+
return true
225+
}
226+
227+
if rval.Kind() == reflect.Slice {
228+
return isScalarKind(rval.Type().Elem().Kind())
229+
}
230+
231+
return false
232+
}
233+
234+
// isScalarKind reports whether k is a scalar kind pinned by the v1 encoding:
235+
// string, bool, and the sized signed/unsigned integers. Named
236+
// scalar types (e.g. SpecSourceType) report their underlying kind here, so they
237+
// are measured by their underlying kind rather than failing.
238+
func isScalarKind(k reflect.Kind) bool {
239+
//exhaustive:ignore // the default branch deliberately rejects every other kind.
240+
switch k {
241+
case reflect.String, reflect.Bool,
242+
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
243+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
244+
return true
245+
default:
246+
return false
247+
}
248+
}

0 commit comments

Comments
 (0)