|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +package fingerprint |
| 5 | + |
| 6 | +import ( |
| 7 | + "crypto/sha256" |
| 8 | + "encoding/hex" |
| 9 | + "encoding/json" |
| 10 | + "flag" |
| 11 | + "fmt" |
| 12 | + "os" |
| 13 | + "testing" |
| 14 | + |
| 15 | + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" |
| 16 | + "github.com/stretchr/testify/assert" |
| 17 | + "github.com/stretchr/testify/require" |
| 18 | +) |
| 19 | + |
| 20 | +// updateGolden, when set, appends newly-named golden vectors to the frozen table. |
| 21 | +// It MUST NOT change an existing entry's digest - see goldenPath's header comment. |
| 22 | +// Bootstrap a missing table with: go test ./internal/fingerprint -run TestGoldenVectors -update-golden |
| 23 | +// |
| 24 | +//nolint:gochecknoglobals // a test flag must be a package-level var to register at init. |
| 25 | +var updateGolden = flag.Bool("update-golden", false, "append new golden fingerprint vectors") |
| 26 | + |
| 27 | +// goldenPath is the golden-vector file for the current content version. Each |
| 28 | +// version pins its own file (golden_v1.json, golden_v2.json, ...); a new version |
| 29 | +// adds a file rather than rewriting an existing one. |
| 30 | +// |
| 31 | +//nolint:gochecknoglobals // derived from currentContentVersion; cannot be const. |
| 32 | +var goldenPath = fmt.Sprintf("testdata/golden_v%d.json", currentContentVersion) |
| 33 | + |
| 34 | +// goldenConfigs is the freeze corpus for the current content version: one cutover |
| 35 | +// config (every measured field set) plus named edge configs the cutover case |
| 36 | +// cannot express at once. |
| 37 | +// |
| 38 | +// # Managing golden vectors (read before editing this map or testdata/golden_v<N>.json) |
| 39 | +// |
| 40 | +// A golden vector is a frozen (config -> digest) pair that pins a content |
| 41 | +// version's projection digest irreversibly. The freeze is append-only and these |
| 42 | +// rules are load-bearing because the encoding becomes a one-way door the moment a |
| 43 | +// version ships (see the lazy-schema-migration RFC): |
| 44 | +// |
| 45 | +// - One file per content version: testdata/golden_v1.json pins v1, a future |
| 46 | +// testdata/golden_v2.json pins v2, and so on. A new version ADDS its own file |
| 47 | +// and never rewrites an existing one; this harness reads the file for |
| 48 | +// currentContentVersion. (The future projection generator follows the same |
| 49 | +// rule - it emits golden_v<N>.json for the version it adds and leaves older |
| 50 | +// files untouched.) |
| 51 | +// - NEVER edit an existing entry's digest in testdata/golden_v<N>.json. The |
| 52 | +// -update-golden flag only APPENDS; a moved existing digest is a deliberate |
| 53 | +// FATAL. If an existing digest moves, your change was NOT output-preserving for |
| 54 | +// the fleet - that is a bug to fix, not a value to -update away. |
| 55 | +// - cutoverFieldSet (the "maximal" vector) is FROZEN: it is a corpus config, so |
| 56 | +// changing what it returns moves its pinned digest. Do not add fields to it. |
| 57 | +// - emissionProbeConfig GROWS instead: it sets every measured field so the |
| 58 | +// emission probe can assert each measured key is emitted. The two are split |
| 59 | +// precisely because one config cannot be both frozen and growing. |
| 60 | +// |
| 61 | +// Adding an omit-if-zero field "foo" (fingerprint:"v<N>..*", no version bump): |
| 62 | +// 1. add the field + tag and its projectV<N> emit line; |
| 63 | +// 2. set foo non-zero in emissionProbeConfig (the probe fails until you do - that |
| 64 | +// failure is the "you forgot to emit the new key" guard working); |
| 65 | +// 3. append a new named vector that sets foo in isolation, then run: |
| 66 | +// go test ./internal/fingerprint -run TestGoldenVectors -update-golden |
| 67 | +// 4. confirm the run APPENDS ONLY - every existing digest must be byte-identical. |
| 68 | +// Any movement of an existing entry is the bug, not the fix. |
| 69 | +// |
| 70 | +// NEVER retroactively measure a new field at a superseded version (e.g. editing a |
| 71 | +// frozen projectV<N> to read foo once v<N+1> exists). That changes the bytes that |
| 72 | +// version emitted in production, so every stored lock at that version becomes |
| 73 | +// unreproducible on replay (Problem 6) -> mass false-Stale. Omit-if-zero means no |
| 74 | +// pre-existing lock ever set foo, so each replays byte-identically without it; a |
| 75 | +// lock that later needs foo measured gets it by FORWARD migration, never by editing |
| 76 | +// the old version. |
| 77 | +// |
| 78 | +// Naming convention (semantic, not chronological - the date is already in git, and |
| 79 | +// a name must say what broke when a vector moves): |
| 80 | +// - "maximal" / "minimal": the frozen baselines, never changed. |
| 81 | +// - edge cases: name the property exercised ("defines-empty-value", |
| 82 | +// "single-overlay", ...). |
| 83 | +// - an additive field: "<toml-key>-set", one isolated vector per field. |
| 84 | +func goldenConfigs() map[string]projectconfig.ComponentConfig { |
| 85 | + return map[string]projectconfig.ComponentConfig{ |
| 86 | + "maximal": cutoverFieldSet(), |
| 87 | + "minimal": {}, |
| 88 | + "defines-empty-value": {Build: projectconfig.ComponentBuildConfig{Defines: map[string]string{"k": ""}}}, |
| 89 | + "single-overlay": {Overlays: []projectconfig.ComponentOverlay{{ |
| 90 | + Type: projectconfig.ComponentOverlayAddPatch, Filename: "p.patch", |
| 91 | + }}}, |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +// TestGoldenVectors pins the v1 projection encoding irreversibly. The expected |
| 96 | +// digests are append-only: a commit that changes an existing entry fails (a |
| 97 | +// frozen v1 vector cannot move - a new encoding is a new version, not an edit). |
| 98 | +func TestGoldenVectors(t *testing.T) { |
| 99 | + computed := make(map[string]string) |
| 100 | + for name, cfg := range goldenConfigs() { |
| 101 | + computed[name] = projectionDigest(t, cfg) |
| 102 | + } |
| 103 | + |
| 104 | + // S2: every vector that sets a measured field must differ from the empty |
| 105 | + // projection. A vector that collapses to `minimal` means a measured emit was |
| 106 | + // dropped or mis-wired (e.g. a duplicate emit-key the global probe misses, or a |
| 107 | + // '!' field that used emit instead of emitAlways and so dropped its zero value). |
| 108 | + // A deliberately projected-empty vector (none today) would opt out here. |
| 109 | + expectedEmptyProjection := map[string]bool{"minimal": true} |
| 110 | + for name, digest := range computed { |
| 111 | + if expectedEmptyProjection[name] { |
| 112 | + continue |
| 113 | + } |
| 114 | + |
| 115 | + assert.NotEqualf(t, computed["minimal"], digest, |
| 116 | + "golden vector %q collapsed to the empty projection - a measured emit was dropped or mis-wired", name) |
| 117 | + } |
| 118 | + |
| 119 | + frozen := loadGolden(t) |
| 120 | + |
| 121 | + if *updateGolden { |
| 122 | + for name, digest := range computed { |
| 123 | + if old, ok := frozen[name]; ok && old != digest { |
| 124 | + t.Fatalf("append-only violation: vector %q would change\n from %s\n to %s\n"+ |
| 125 | + "a frozen v1 vector cannot move; introduce a new version instead", name, old, digest) |
| 126 | + } |
| 127 | + |
| 128 | + frozen[name] = digest |
| 129 | + } |
| 130 | + |
| 131 | + writeGolden(t, frozen) |
| 132 | + |
| 133 | + return |
| 134 | + } |
| 135 | + |
| 136 | + for name, digest := range computed { |
| 137 | + expected, ok := frozen[name] |
| 138 | + require.Truef(t, ok, "golden vector %q is missing; bootstrap with -update-golden", name) |
| 139 | + assert.Equalf(t, expected, digest, "v1 projection for %q changed - this is a frozen byte encoding", name) |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +func projectionDigest(t *testing.T, cfg projectconfig.ComponentConfig) string { |
| 144 | + t.Helper() |
| 145 | + |
| 146 | + digest := sha256.Sum256(mustProject(t, cfg)) |
| 147 | + |
| 148 | + return "sha256:" + hex.EncodeToString(digest[:]) |
| 149 | +} |
| 150 | + |
| 151 | +func loadGolden(t *testing.T) map[string]string { |
| 152 | + t.Helper() |
| 153 | + |
| 154 | + data, err := os.ReadFile(goldenPath) |
| 155 | + if os.IsNotExist(err) { |
| 156 | + return make(map[string]string) |
| 157 | + } |
| 158 | + |
| 159 | + require.NoError(t, err) |
| 160 | + |
| 161 | + golden := make(map[string]string) |
| 162 | + require.NoError(t, json.Unmarshal(data, &golden)) |
| 163 | + |
| 164 | + return golden |
| 165 | +} |
| 166 | + |
| 167 | +func writeGolden(t *testing.T, golden map[string]string) { |
| 168 | + t.Helper() |
| 169 | + |
| 170 | + golden["_README"] = fmt.Sprintf("Frozen v%d projection digests. APPEND-ONLY: never edit an "+ |
| 171 | + "existing digest - a new encoding is a new content version, not an edit here. "+ |
| 172 | + "Management rules and the additive-field workflow live in goldenConfigs's doc "+ |
| 173 | + "comment in golden_internal_test.go.", currentContentVersion) |
| 174 | + |
| 175 | + // json.Marshal emits map keys sorted, giving a stable diff. |
| 176 | + data, err := json.MarshalIndent(golden, "", " ") |
| 177 | + require.NoError(t, err) |
| 178 | + |
| 179 | + require.NoError(t, os.WriteFile(goldenPath, append(data, '\n'), 0o644)) //nolint:gosec // golden fixture, not a secret. |
| 180 | +} |
0 commit comments