carlos / platform Public

moves: DecodeTimers refuses any snapshot version but 1; golden digests pin the canonical bytes (Task 8 fix round 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 71026be5ccb26172a823c8d4cc3936e527485cc9 parent 430298d
2 files changed, +72 −1
  • internal/moves/timers.go +11 −1
  • internal/moves/timers_test.go +61 −0
diff --git a/internal/moves/timers.go b/internal/moves/timers.go
index eb21b050f..482732353 100644
--- a/internal/moves/timers.go
+++ b/internal/moves/timers.go
@@ -47,18 +47,28 @@ type timersWire struct {
}
func (s TimersSnapshot) Encode() ([]byte, error) {
- w := timersWire{V: 1, Host: s.Host, TakenAt: s.TakenAt}
+ w := timersWire{V: timersVersion, Host: s.Host, TakenAt: s.TakenAt}
for _, r := range s.Rows {
w.Rows = append(w.Rows, toWire(r))
}
return json.Marshal(w)
}
+// timersVersion is the only snapshot schema this reader understands.
+const timersVersion = 1
+
+// DecodeTimers reads a v1 snapshot. Any other version, a missing version
+// and a JSON null are refused before any row is returned: a newer schema
+// could carry state this reader would drop, and re-encoding would then
+// label the partial reading v1.
func DecodeTimers(b []byte) (TimersSnapshot, error) {
var w timersWire
if err := json.Unmarshal(b, &w); err != nil {
return TimersSnapshot{}, fmt.Errorf("moves: timers snapshot: %w", err)
}
+ if w.V != timersVersion {
+ return TimersSnapshot{}, fmt.Errorf("moves: timers snapshot: unsupported version %d (want %d)", w.V, timersVersion)
+ }
s := TimersSnapshot{V: w.V, Host: w.Host, TakenAt: w.TakenAt}
for _, r := range w.Rows {
s.Rows = append(s.Rows, fromWire(r))
diff --git a/internal/moves/timers_test.go b/internal/moves/timers_test.go
index 2a83cf59d..44cb8beaa 100644
--- a/internal/moves/timers_test.go
+++ b/internal/moves/timers_test.go
@@ -1,6 +1,8 @@
package moves
import (
+ "crypto/sha256"
+ "encoding/hex"
"strings"
"testing"
"time"
@@ -161,3 +163,62 @@ func TestTimersSnapshotEmpty(t *testing.T) {
t.Fatal("empty digests differ")
}
}
+
+// The decoder reads only schema v1. Anything else could carry state this
+// reader would silently drop, and a re-encode would then label the partial
+// reading v1, so it is refused before any row is returned.
+func TestDecodeTimersRejectsUnsupportedVersions(t *testing.T) {
+ row := `"rows":[{"Host":"h","Name":"n","Kind":"once","NextAt":9,"Registration":"r"}]`
+ for name, in := range map[string]string{
+ "missing v": `{"host":"h",` + row + `}`,
+ "v 0": `{"v":0,"host":"h",` + row + `}`,
+ "v 2": `{"v":2,"host":"h",` + row + `}`,
+ "v -1": `{"v":-1,"host":"h",` + row + `}`,
+ "null": `null`,
+ "empty obj": `{}`,
+ } {
+ got, err := DecodeTimers([]byte(in))
+ if err == nil {
+ t.Errorf("%s: decoded without error: %+v", name, got)
+ continue
+ }
+ if len(got.Rows) != 0 {
+ t.Errorf("%s: rows returned with the error: %+v", name, got.Rows)
+ }
+ }
+ if _, err := DecodeTimers([]byte(`{"v":1,"host":"h","rows":[]}`)); err != nil {
+ t.Fatalf("v 1 refused: %v", err)
+ }
+}
+
+// Golden digests: the bytes a clean recovery compares must not change
+// between releases, or two boxes on different builds would disagree about
+// identical timers. The expected strings are fixed; a failure here means
+// the canonical form changed (field order, names, separators, sort, or
+// the active-row rule), which needs a version bump, not a new golden.
+func TestTimersDigestGolden(t *testing.T) {
+ rows := []registry.Timer{
+ {Host: "h", Name: "send-later", Kind: "once", Path: "/s", NextAt: 200, DueAt: 200, Registration: "r2"},
+ {Host: "h", Name: "nightly", Kind: "schedule", Spec: "0 2 * * *", Path: "/n", NextAt: 100, LastAt: 40, LastStatus: "200", LastMs: 12, Attempts: 1, CreatedAt: 10, DueAt: 100, Registration: "r1"},
+ {Host: "h", Name: "sent", Kind: "once", Path: "/s", LastAt: 50, LastStatus: "200", Registration: "r3"},
+ }
+ const want = "sha256:8ac11b38bc2b5b6a658410fc56c8422690fdf53f76489ea95f6c7f9039a42654"
+ if got := TimersDigest(rows); got != want {
+ t.Fatalf("digest = %s, want %s", got, want)
+ }
+ // The same bytes spelled out, so the golden is not just a snapshot of
+ // the implementation: two JSON lines, sorted by name, no history.
+ canon := `{"Host":"h","Name":"nightly","Kind":"schedule","Spec":"0 2 * * *","Path":"/n","NextAt":100,"LastAt":40,"LastStatus":"200","LastMs":12,"Attempts":1,"CreatedAt":10,"DueAt":100,"Registration":"r1"}` + "\n" +
+ `{"Host":"h","Name":"send-later","Kind":"once","Spec":"","Path":"/s","NextAt":200,"LastAt":0,"LastStatus":"","LastMs":0,"Attempts":0,"CreatedAt":0,"DueAt":200,"Registration":"r2"}` + "\n"
+ sum := sha256.Sum256([]byte(canon))
+ if got := "sha256:" + hex.EncodeToString(sum[:]); got != want {
+ t.Fatalf("spelled-out canonical form hashes to %s, want %s", got, want)
+ }
+ const wantEmpty = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ if got := TimersDigest(nil); got != wantEmpty {
+ t.Fatalf("empty digest = %s", got)
+ }
+ if got := TimersDigest([]registry.Timer{rows[2]}); got != wantEmpty {
+ t.Fatalf("history-only digest = %s", got)
+ }
+}