release: randomized G1/G2 check for the Authorize hook (#229)
Mutation checks, run from committed HEAD in throwaway copies: - mutant 1 (hook moved before the pointer read): fails G1 at seed 4 of 400. - mutant 2 (DirStore compare-and-swap disabled): fails G2 at seed 28 of 2000. Both replay with DEPLOY_SIM_SEED=<n>. The unmutated run passes 2000 seeds.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 file changed,
+189
−0
internal/release/authorize_sim_test.go+189 −0
diff --git a/internal/release/authorize_sim_test.go b/internal/release/authorize_sim_test.go| new file mode 100644 |
| index 000000000..933fbf499 |
| --- /dev/null |
| +++ b/internal/release/authorize_sim_test.go |
| @@ -0,0 +1,189 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "math/rand" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestAuthorizeInterleavings runs random schedules of one deploy request |
| +// against an owner who installs and removes pipelines, promotes and rolls |
| +// back, at random points inside the request. It checks G1 and G2 and |
| +// nothing else (spec §7.2, §12): |
| +// |
| +// G1: the last hook call before the write came after the pointer read the |
| +// write was conditioned on, and the pipeline it was given permits it. |
| +// G2: the bytes the write replaced equal the bytes that read returned. |
| +// |
| +// Seeds: DEPLOY_SIM_SEEDS (default 400). A failure prints the seed and the |
| +// event log; DEPLOY_SIM_SEED=<n> replays exactly that seed. |
| +func TestAuthorizeInterleavings(t *testing.T) { |
| + first, last := 0, 400 |
| + if v := os.Getenv("DEPLOY_SIM_SEEDS"); v != "" { |
| + n, err := strconv.Atoi(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + last = n |
| + } |
| + if v := os.Getenv("DEPLOY_SIM_SEED"); v != "" { |
| + n, err := strconv.Atoi(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first, last = n, n+1 |
| + } |
| + priv, _ := testKeys(t) |
| + for seed := first; seed < last; seed++ { |
| + simulateOnce(t, seed, priv) |
| + } |
| +} |
| + |
| +type simRead struct { |
| + seq int |
| + body []byte |
| + etag string |
| + absent bool |
| +} |
| + |
| +type simHook struct { |
| + seq int |
| + pipelined bool |
| +} |
| + |
| +func simulateOnce(t *testing.T, seed int, priv ed25519.PrivateKey) { |
| + t.Helper() |
| + ctx := context.Background() |
| + rng := rand.New(rand.NewSource(int64(seed))) |
| + inner := NewDirStore(t.TempDir()) |
| + for _, v := range []string{"v1", "v2", "v3", "v4"} { |
| + shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v)) |
| + } |
| + for _, v := range []string{"v1", "v2"} { |
| + if _, err := PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil { |
| + t.Fatalf("seed %d setup: %v", seed, err) |
| + } |
| + } |
| + var ( |
| + events []string |
| + reads []simRead |
| + hooks []simHook |
| + put *storeCall |
| + replaced []byte |
| + replacedAbsent bool |
| + ) |
| + owner := func() { |
| + var err error |
| + var what string |
| + switch rng.Intn(4) { |
| + case 0: |
| + what = "install" |
| + _, err = SetPipeline(ctx, inner, priv, "bab", "myapp", []PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "owner") |
| + case 1: |
| + what = "remove pipeline" |
| + err = inner.Delete(ctx, PipelineKey("bab", "myapp")) |
| + case 2: |
| + v := fmt.Sprintf("v%d", 1+rng.Intn(4)) |
| + what = "promote " + v |
| + _, err = PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}) |
| + case 3: |
| + what = "rollback" |
| + err = Rollback(ctx, inner, priv, "bab", "myapp", "edge") |
| + } |
| + events = append(events, fmt.Sprintf(" owner %s: %v", what, err)) |
| + } |
| + h := &hookStore{inner: inner} |
| + h.before = func(c storeCall) { |
| + if rng.Float64() < 0.3 { |
| + for i := 0; i <= rng.Intn(2); i++ { |
| + owner() |
| + } |
| + } |
| + events = append(events, fmt.Sprintf("%d %s %s %s", c.seq, c.op, c.key, c.ifMatch)) |
| + if c.op == "put" && c.key == edgeKey { |
| + cc := c |
| + put = &cc |
| + body, _, err := inner.Get(ctx, edgeKey) |
| + replaced, replacedAbsent = body, errors.Is(err, os.ErrNotExist) |
| + } |
| + } |
| + h.after = func(c storeCall, body []byte, etag string, err error) { |
| + if c.key == edgeKey { |
| + reads = append(reads, simRead{c.seq, body, etag, errors.Is(err, os.ErrNotExist)}) |
| + } |
| + } |
| + hook := func(p *Pipeline) error { |
| + hooks = append(hooks, simHook{h.seq, p != nil}) |
| + events = append(events, fmt.Sprintf("%d hook pipelined=%v", h.seq, p != nil)) |
| + return noPipelineOnly(p) |
| + } |
| + var err error |
| + noop := false |
| + switch rng.Intn(3) { |
| + case 0: |
| + var res PromoteResult |
| + res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", PromoteOptions{Authorize: hook}) |
| + noop = res.NoOp |
| + case 1: |
| + var res PromoteResult |
| + res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", PromoteOptions{Authorize: hook}) |
| + noop = res.NoOp |
| + case 2: |
| + err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", hook) |
| + } |
| + fail := func(msg string) { |
| + t.Fatalf("seed %d: %s\nreplay with DEPLOY_SIM_SEED=%d\n%s", seed, msg, seed, strings.Join(events, "\n")) |
| + } |
| + if err != nil { |
| + return // refusals and conflicts write nothing |
| + } |
| + if noop { |
| + if len(hooks) == 0 || hooks[len(hooks)-1].pipelined { |
| + fail("a no-op succeeded without a clean hook call") |
| + } |
| + return |
| + } |
| + if put == nil { |
| + fail("succeeded without writing") |
| + } |
| + var r *simRead |
| + for i := len(reads) - 1; i >= 0; i-- { |
| + rd := reads[i] |
| + if rd.seq >= put.seq { |
| + continue |
| + } |
| + if (put.ifMatch == "" && rd.absent) || (put.ifMatch != "" && !rd.absent && rd.etag == put.ifMatch) { |
| + r = &rd |
| + break |
| + } |
| + } |
| + if r == nil { |
| + fail("no pointer read matches the write's condition") |
| + } |
| + var a *simHook |
| + for i := len(hooks) - 1; i >= 0; i-- { |
| + if hooks[i].seq < put.seq { |
| + a = &hooks[i] |
| + break |
| + } |
| + } |
| + if a == nil { |
| + fail("a write landed with no hook call") |
| + } |
| + if a.seq <= r.seq { |
| + fail(fmt.Sprintf("G1: the hook at %d is not after the pointer read at %d", a.seq, r.seq)) |
| + } |
| + if a.pipelined { |
| + fail("G1: the hook saw a pipeline and the write landed") |
| + } |
| + if replacedAbsent != r.absent || !bytes.Equal(replaced, r.body) { |
| + fail("G2: the write replaced bytes other than the ones it read") |
| + } |
| +} |