|
| 1 | +package roast |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "math" |
| 7 | + "math/rand" |
| 8 | + "os" |
| 9 | + "strconv" |
| 10 | + "testing" |
| 11 | + |
| 12 | + "github.com/keep-network/keep-core/pkg/protocol/group" |
| 13 | +) |
| 14 | + |
| 15 | +// coordinatorShuffleCorpusPath is the cross-language differential |
| 16 | +// corpus for the legacy Go math/rand coordinator shuffle. Where |
| 17 | +// testdata/coordinator_seed_vectors.json pins the full RFC-21 Annex A |
| 18 | +// derivation on a handful of hand-picked vectors, this corpus pins |
| 19 | +// the shuffle itself -- SelectCoordinator semantics over explicit |
| 20 | +// int64 seeds -- across hundreds of generated cases and the integer |
| 21 | +// boundary values where a port is most likely to diverge (negative |
| 22 | +// seeds, i64 extremes, wrapping seed+attempt composition, unsorted |
| 23 | +// inputs, 1..255-member sets). |
| 24 | +// |
| 25 | +// The Rust signer carries a byte-identical copy at |
| 26 | +// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json consumed |
| 27 | +// by go_math_rand.rs tests; either side drifting fails its own suite. |
| 28 | +// Regenerate here with ROAST_SHUFFLE_CORPUS_REGEN=1 and re-copy. |
| 29 | +const coordinatorShuffleCorpusPath = "testdata/coordinator_shuffle_corpus.json" |
| 30 | + |
| 31 | +type coordinatorShuffleCorpusFile struct { |
| 32 | + Description string `json:"description"` |
| 33 | + Cases []coordinatorShuffleCase `json:"cases"` |
| 34 | +} |
| 35 | + |
| 36 | +type coordinatorShuffleCase struct { |
| 37 | + Name string `json:"name"` |
| 38 | + // SeedInt64 is the legacy shuffle seed as a decimal string so |
| 39 | + // JSON number precision cannot corrupt it. |
| 40 | + SeedInt64 string `json:"seedInt64"` |
| 41 | + // AttemptNumber is the 0-based attempt composed into the |
| 42 | + // rand.Source seed as seed + int64(attemptNumber) (two's- |
| 43 | + // complement wrapping). |
| 44 | + AttemptNumber uint32 `json:"attemptNumber"` |
| 45 | + // Members is the included set in the order passed to the |
| 46 | + // shuffle. Deliberately not always sorted: both implementations |
| 47 | + // must sort internally, and unsorted cases pin that. |
| 48 | + Members []uint16 `json:"members"` |
| 49 | + ExpectedCoordinator uint16 `json:"expectedCoordinator"` |
| 50 | +} |
| 51 | + |
| 52 | +func loadCoordinatorShuffleCorpus(t *testing.T) coordinatorShuffleCorpusFile { |
| 53 | + t.Helper() |
| 54 | + raw, err := os.ReadFile(coordinatorShuffleCorpusPath) |
| 55 | + if err != nil { |
| 56 | + t.Fatalf( |
| 57 | + "cannot read %s (regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1): %v", |
| 58 | + coordinatorShuffleCorpusPath, |
| 59 | + err, |
| 60 | + ) |
| 61 | + } |
| 62 | + var file coordinatorShuffleCorpusFile |
| 63 | + if err := json.Unmarshal(raw, &file); err != nil { |
| 64 | + t.Fatalf("cannot parse %s: %v", coordinatorShuffleCorpusPath, err) |
| 65 | + } |
| 66 | + if len(file.Cases) == 0 { |
| 67 | + t.Fatalf("%s contains no cases", coordinatorShuffleCorpusPath) |
| 68 | + } |
| 69 | + return file |
| 70 | +} |
| 71 | + |
| 72 | +func runCoordinatorShuffleCase( |
| 73 | + t *testing.T, |
| 74 | + shuffleCase coordinatorShuffleCase, |
| 75 | +) group.MemberIndex { |
| 76 | + t.Helper() |
| 77 | + seed, err := strconv.ParseInt(shuffleCase.SeedInt64, 10, 64) |
| 78 | + if err != nil { |
| 79 | + t.Fatalf( |
| 80 | + "case %q: seedInt64 %q is not a valid int64: %v", |
| 81 | + shuffleCase.Name, shuffleCase.SeedInt64, err, |
| 82 | + ) |
| 83 | + } |
| 84 | + members := make([]group.MemberIndex, 0, len(shuffleCase.Members)) |
| 85 | + for _, m := range shuffleCase.Members { |
| 86 | + if m == 0 || m > math.MaxUint8 { |
| 87 | + t.Fatalf( |
| 88 | + "case %q: member %d outside group.MemberIndex range", |
| 89 | + shuffleCase.Name, m, |
| 90 | + ) |
| 91 | + } |
| 92 | + members = append(members, group.MemberIndex(m)) |
| 93 | + } |
| 94 | + coordinator, err := SelectCoordinator( |
| 95 | + members, |
| 96 | + seed, |
| 97 | + uint(shuffleCase.AttemptNumber), |
| 98 | + ) |
| 99 | + if err != nil { |
| 100 | + t.Fatalf("case %q: SelectCoordinator: %v", shuffleCase.Name, err) |
| 101 | + } |
| 102 | + return coordinator |
| 103 | +} |
| 104 | + |
| 105 | +// TestCoordinatorShuffle_DifferentialCorpus replays the generated |
| 106 | +// corpus against SelectCoordinator. The Rust go_math_rand port runs |
| 107 | +// the identical corpus, so any semantic drift in either shuffle -- |
| 108 | +// source seeding, Fisher-Yates order, int31n bounds, sign handling, |
| 109 | +// wrapping composition, internal sorting -- fails the drifting side's |
| 110 | +// own unit suite instead of fracturing coordinator agreement in a |
| 111 | +// mixed deployment. |
| 112 | +func TestCoordinatorShuffle_DifferentialCorpus(t *testing.T) { |
| 113 | + file := loadCoordinatorShuffleCorpus(t) |
| 114 | + |
| 115 | + for _, shuffleCase := range file.Cases { |
| 116 | + coordinator := runCoordinatorShuffleCase(t, shuffleCase) |
| 117 | + if coordinator != group.MemberIndex(shuffleCase.ExpectedCoordinator) { |
| 118 | + t.Fatalf( |
| 119 | + "case %q: coordinator mismatch: derived %d, corpus pins %d", |
| 120 | + shuffleCase.Name, coordinator, shuffleCase.ExpectedCoordinator, |
| 121 | + ) |
| 122 | + } |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +// TestRegenerateCoordinatorShuffleCorpus rewrites the corpus from the |
| 127 | +// deterministic case matrix below using the current Go |
| 128 | +// implementation. Guarded behind an env flag so it never rewrites |
| 129 | +// during normal CI: |
| 130 | +// |
| 131 | +// ROAST_SHUFFLE_CORPUS_REGEN=1 go test ./pkg/frost/roast -run TestRegenerateCoordinatorShuffleCorpus |
| 132 | +// |
| 133 | +// After regenerating, copy the file byte-identically to |
| 134 | +// pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json on the |
| 135 | +// signer branch. |
| 136 | +// |
| 137 | +// Regenerating is a protocol-change event, not a refresh: the corpus |
| 138 | +// pins the legacy math/rand shuffle semantics shared with the Rust |
| 139 | +// port, so a run that produces different bytes means the shuffle |
| 140 | +// changed and deployed engines would disagree on coordinator |
| 141 | +// rotation. Both language suites passing after a dual regen is NOT |
| 142 | +// evidence of compatibility with already-deployed engines. |
| 143 | +func TestRegenerateCoordinatorShuffleCorpus(t *testing.T) { |
| 144 | + if os.Getenv("ROAST_SHUFFLE_CORPUS_REGEN") != "1" { |
| 145 | + t.Skip("set ROAST_SHUFFLE_CORPUS_REGEN=1 to regenerate the corpus file") |
| 146 | + } |
| 147 | + |
| 148 | + cases := make([]coordinatorShuffleCase, 0, 600) |
| 149 | + |
| 150 | + // Boundary block: integer extremes, sign boundaries, the |
| 151 | + // wrapping seed+attempt composition, and the historical |
| 152 | + // cross-language pin seed from PR #4026. |
| 153 | + // |
| 154 | + // +/-MaxInt32 are the rand.NewSource seed-normalization collision: |
| 155 | + // Go reduces the source seed mod (2^31 - 1) and maps 0 to 89482311, |
| 156 | + // so +/-(2^31 - 1) seed the generator identically to 0. Pinning them |
| 157 | + // catches a port that special-cases literal 0 but skips the modulo. |
| 158 | + boundarySeeds := []int64{ |
| 159 | + 0, |
| 160 | + 1, |
| 161 | + -1, |
| 162 | + math.MaxInt64, |
| 163 | + math.MinInt64, |
| 164 | + math.MaxInt64 - 3, |
| 165 | + math.MinInt64 + 3, |
| 166 | + math.MaxInt32, // == 2^31 - 1; normalizes to the seed-0 state |
| 167 | + -math.MaxInt32, // negative wrap then the same collision |
| 168 | + 6879463052285329321, |
| 169 | + -6879463052285329321, |
| 170 | + } |
| 171 | + boundaryAttempts := []uint32{0, 1, 7, math.MaxUint32} |
| 172 | + boundarySets := [][]uint16{ |
| 173 | + {1}, |
| 174 | + {1, 2}, |
| 175 | + {2, 1}, // unsorted: pins internal sorting |
| 176 | + {5, 4, 3, 2, 1}, // reverse order |
| 177 | + {1, 2, 3, 4, 5}, |
| 178 | + {7, 2, 11, 9}, // sparse, unsorted |
| 179 | + } |
| 180 | + for _, seed := range boundarySeeds { |
| 181 | + for _, attemptNumber := range boundaryAttempts { |
| 182 | + for setIndex, members := range boundarySets { |
| 183 | + cases = append(cases, coordinatorShuffleCase{ |
| 184 | + Name: fmt.Sprintf( |
| 185 | + "boundary-seed-%d-attempt-%d-set-%d", |
| 186 | + seed, attemptNumber, setIndex, |
| 187 | + ), |
| 188 | + SeedInt64: strconv.FormatInt(seed, 10), |
| 189 | + AttemptNumber: attemptNumber, |
| 190 | + Members: members, |
| 191 | + }) |
| 192 | + } |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + // Generated block: deterministic pseudo-random sweep over set |
| 197 | + // sizes 1..255 (every member index value reachable), seeds across |
| 198 | + // the full int64 range, attempts across realistic and large |
| 199 | + // values. The generator RNG seed is fixed; it only chooses the |
| 200 | + // case inputs and has no parity significance itself. |
| 201 | + generatorRng := rand.New(rand.NewSource(0x5EED_C0DE)) |
| 202 | + memberPool := make([]uint16, 255) |
| 203 | + for i := range memberPool { |
| 204 | + memberPool[i] = uint16(i + 1) |
| 205 | + } |
| 206 | + for caseIndex := 0; caseIndex < 384; caseIndex++ { |
| 207 | + // Bias towards small sets (production-realistic) while still |
| 208 | + // sweeping up to the full 255. |
| 209 | + var setSize int |
| 210 | + switch caseIndex % 4 { |
| 211 | + case 0: |
| 212 | + setSize = 1 + generatorRng.Intn(5) |
| 213 | + case 1: |
| 214 | + setSize = 6 + generatorRng.Intn(46) |
| 215 | + case 2: |
| 216 | + setSize = 51 + generatorRng.Intn(50) |
| 217 | + default: |
| 218 | + setSize = 101 + generatorRng.Intn(155) |
| 219 | + } |
| 220 | + |
| 221 | + generatorRng.Shuffle(len(memberPool), func(i, j int) { |
| 222 | + memberPool[i], memberPool[j] = memberPool[j], memberPool[i] |
| 223 | + }) |
| 224 | + members := make([]uint16, setSize) |
| 225 | + copy(members, memberPool[:setSize]) |
| 226 | + |
| 227 | + seed := int64(generatorRng.Uint64()) |
| 228 | + var attemptNumber uint32 |
| 229 | + switch caseIndex % 3 { |
| 230 | + case 0: |
| 231 | + attemptNumber = uint32(generatorRng.Intn(8)) |
| 232 | + case 1: |
| 233 | + attemptNumber = uint32(generatorRng.Intn(1 << 16)) |
| 234 | + default: |
| 235 | + attemptNumber = generatorRng.Uint32() |
| 236 | + } |
| 237 | + |
| 238 | + cases = append(cases, coordinatorShuffleCase{ |
| 239 | + Name: fmt.Sprintf("generated-%03d-size-%d", caseIndex, setSize), |
| 240 | + SeedInt64: strconv.FormatInt(seed, 10), |
| 241 | + AttemptNumber: attemptNumber, |
| 242 | + Members: members, |
| 243 | + }) |
| 244 | + } |
| 245 | + |
| 246 | + for caseIndex := range cases { |
| 247 | + coordinator := runCoordinatorShuffleCase(t, cases[caseIndex]) |
| 248 | + cases[caseIndex].ExpectedCoordinator = uint16(coordinator) |
| 249 | + } |
| 250 | + |
| 251 | + file := coordinatorShuffleCorpusFile{ |
| 252 | + Description: "Cross-language differential corpus for the legacy Go math/rand " + |
| 253 | + "coordinator shuffle (SelectCoordinator / go_math_rand.rs " + |
| 254 | + "select_coordinator_identifier): source seed = seedInt64 + " + |
| 255 | + "int64(attemptNumber) with two's-complement wrapping; members sorted " + |
| 256 | + "ascending internally before the Fisher-Yates shuffle; first element " + |
| 257 | + "after shuffling is the coordinator. Canonical copy: " + |
| 258 | + "pkg/frost/roast/testdata/coordinator_shuffle_corpus.json (Go); " + |
| 259 | + "mirrored byte-identically to " + |
| 260 | + "pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json (Rust). " + |
| 261 | + "Regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1.", |
| 262 | + Cases: cases, |
| 263 | + } |
| 264 | + |
| 265 | + encoded, err := json.Marshal(file) |
| 266 | + if err != nil { |
| 267 | + t.Fatalf("encode corpus file: %v", err) |
| 268 | + } |
| 269 | + encoded = append(encoded, '\n') |
| 270 | + if err := os.MkdirAll("testdata", 0o755); err != nil { |
| 271 | + t.Fatalf("create testdata dir: %v", err) |
| 272 | + } |
| 273 | + if err := os.WriteFile(coordinatorShuffleCorpusPath, encoded, 0o644); err != nil { |
| 274 | + t.Fatalf("write corpus file: %v", err) |
| 275 | + } |
| 276 | + t.Logf("regenerated %s with %d cases", coordinatorShuffleCorpusPath, len(cases)) |
| 277 | +} |
0 commit comments