|
| 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