diff --git a/internal/routing/hash.go b/internal/routing/hash.go new file mode 100644 index 0000000..b08512b --- /dev/null +++ b/internal/routing/hash.go @@ -0,0 +1,21 @@ +package routing + +import ( + "crypto/sha256" + "encoding/binary" +) + +// CanonicalHash returns a deterministic 64-bit hash of (system, user). +// Used to make sample-band routing decisions reproducible: identical input +// strings produce the same hash on every call, independent of process state. +// +// Inputs are joined with a 0x00 byte separator before hashing — distinguishes +// (system="ab", user="cd") from (system="abcd", user=""). +func CanonicalHash(system, user string) uint64 { + h := sha256.New() + h.Write([]byte(system)) + h.Write([]byte{0}) + h.Write([]byte(user)) + sum := h.Sum(nil) + return binary.BigEndian.Uint64(sum[:8]) +} diff --git a/internal/routing/hash_test.go b/internal/routing/hash_test.go new file mode 100644 index 0000000..2983d10 --- /dev/null +++ b/internal/routing/hash_test.go @@ -0,0 +1,46 @@ +package routing_test + +import ( + "testing" + + "github.com/mathiasbq/supervisor/internal/routing" + "github.com/stretchr/testify/assert" +) + +func TestCanonicalHashDeterministic(t *testing.T) { + a := routing.CanonicalHash("system one", "user one") + b := routing.CanonicalHash("system one", "user one") + assert.Equal(t, a, b, "same inputs must produce same hash") +} + +func TestCanonicalHashDistinguishesInputs(t *testing.T) { + cases := [][2]string{ + {"sys", "user"}, + {"sys", "user2"}, + {"sys2", "user"}, + {"", "system\x00user"}, // separator collision attempt + {"system\x00user", ""}, + } + seen := make(map[uint64]bool) + for _, c := range cases { + h := routing.CanonicalHash(c[0], c[1]) + assert.False(t, seen[h], "collision on %v", c) + seen[h] = true + } +} + +func TestCanonicalHashLowBitDistribution(t *testing.T) { + // Sanity check: across 1000 distinct inputs, low-bit split is roughly even. + zeros, ones := 0, 0 + for i := 0; i < 1000; i++ { + h := routing.CanonicalHash("sys", string(rune('a'+(i%26)))+string(rune(i))) + if h&1 == 0 { + zeros++ + } else { + ones++ + } + } + // Allow ±15% deviation from 500/500. Tighter would be flaky on real data. + assert.InDelta(t, 500, zeros, 150) + assert.InDelta(t, 500, ones, 150) +}