From 81713fa183c81e8c49e928a915c86772e631e71f Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:04:23 +0530 Subject: [PATCH] feat(verifier): refuse a bundle built against a different action encoding ActionWireContract names the encoding wireAction decodes and must equal the one the bundled runtime entry declares. An absent declaration is a mismatch, not a default: it is the package that shipped the zero-distance scroll. A bundle that installs no picker generates no actions and is exempt. --- internal/verifier/action_encoding_test.go | 125 ++++++++++++++++++++++ internal/verifier/marshal.go | 39 +++++++ internal/verifier/worker.go | 28 +++++ 3 files changed, 192 insertions(+) create mode 100644 internal/verifier/action_encoding_test.go diff --git a/internal/verifier/action_encoding_test.go b/internal/verifier/action_encoding_test.go new file mode 100644 index 0000000..5a5fbe7 --- /dev/null +++ b/internal/verifier/action_encoding_test.go @@ -0,0 +1,125 @@ +package verifier + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/priyanshujain/sanderling/internal/bundler" +) + +// legacyRuntimeEntry stands in for the @sanderling/spec 0.0.3 runtime entry: +// it installs the picker and declares nothing, and its authored Scroll carries +// the container's own point as both endpoints. Paired with a binary that +// treats pre-computed endpoints as authoritative, every scroll it produces is +// a drag from a point to itself. +const legacyRuntimeEntry = ` +const target = globalThis as Record; +Object.defineProperty(target, "__sanderlingNextAction__", { + value: () => ({ + kind: "Scroll", + direction: "down", + fromX: 540, + fromY: 1200, + toX: 540, + toY: 1200, + durationMillis: 250, + selector: "id:ledger", + }), + writable: false, + configurable: false, + enumerable: false, +}); +` + +func bundleWithRuntimeEntry(t *testing.T, runtimeSource string) string { + t.Helper() + dir := t.TempDir() + specPath := filepath.Join(dir, "spec.ts") + if err := os.WriteFile(specPath, []byte("globalThis.properties = {};\n"), 0o600); err != nil { + t.Fatal(err) + } + runtimePath := filepath.Join(dir, "legacy-runtime.ts") + if err := os.WriteFile(runtimePath, []byte(runtimeSource), 0o600); err != nil { + t.Fatal(err) + } + bundle, err := bundler.Bundle(bundler.Options{EntryFile: specPath, RuntimeFile: runtimePath}) + if err != nil { + t.Fatal(err) + } + return string(bundle.JavaScript) +} + +func TestLoad_RefusesABundleThatDeclaresNoActionEncoding(t *testing.T) { + verifier := newVerifier(t) + + err := verifier.Load(bundleWithRuntimeEntry(t, legacyRuntimeEntry)) + if err == nil { + t.Fatal("Load accepted a bundle that declares no action encoding; a spec " + + "bundled by a package older than this binary dispatches every scroll " + + "successfully and travels zero distance, and the run reports nothing") + } + message := err.Error() + for _, want := range []string{ActionWireContract, "same commit"} { + if !strings.Contains(message, want) { + t.Errorf("Load error %q does not mention %q; the failure has to name "+ + "both encodings and what to do about it", message, want) + } + } +} + +func TestLoad_RefusesABundleThatDeclaresADifferentActionEncoding(t *testing.T) { + verifier := newVerifier(t) + runtime := strings.Replace(legacyRuntimeEntry, + `const target = globalThis as Record;`, + `const target = globalThis as Record; +target.__sanderlingActionEncoding__ = "action-wire/1";`, 1) + + err := verifier.Load(bundleWithRuntimeEntry(t, runtime)) + if err == nil { + t.Fatal("Load accepted a bundle built against a different action encoding") + } + if !strings.Contains(err.Error(), "action-wire/1") { + t.Errorf("Load error %q does not name the encoding the spec was built "+ + "against", err.Error()) + } +} + +// The declaration the shipped runtime entry makes and the one this binary +// decodes are one contract. When they drift apart every run refuses to start, +// so the drift has to be reported here rather than as a bundling failure in +// every other suite. +func TestLoad_ShippedRuntimeEntryDeclaresTheContractThisBinaryImplements(t *testing.T) { + verifier := newVerifier(t) + loadActionSpec(t, verifier, ` +import { actions, Wait } from "@sanderling/spec"; +globalThis.actions = actions(Wait({ durationMillis: 1 })); +globalThis.properties = {}; +`) + + declared := verifier.runtime.GlobalObject().Get(actionEncodingGlobal) + if declared == nil || declared.String() != ActionWireContract { + t.Fatalf("pkg/spec/src/runtime-entry.ts declares %v, this binary implements %q", + declared, ActionWireContract) + } +} + +// pickerFreeRuntimeEntry stands in for a bundle that registers properties and +// generates no actions: a raw-JS fixture, or the bundle-check tool, which +// bundles with no runtime entry at all. +const pickerFreeRuntimeEntry = ` +const target = globalThis as Record; +target.__sanderlingBundleCheck__ = true; +` + +// A bundle with no picker has no encoding to disagree about, so demanding a +// declaration from it would refuse to load specs that were never going to +// dispatch anything. chrome.Driver.InstallBundle applies the same rule. +func TestLoad_AcceptsABundleThatInstallsNoPicker(t *testing.T) { + verifier := newVerifier(t) + + if err := verifier.Load(bundleWithRuntimeEntry(t, pickerFreeRuntimeEntry)); err != nil { + t.Fatalf("Load refused a bundle that generates no actions: %v", err) + } +} diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 6d82310..fbfca56 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "slices" + "strconv" "strings" "time" @@ -594,6 +595,44 @@ func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, erro return runtime.ToValue(generic), nil } +// ActionWireContract names the encoding wireAction below reads, and must equal +// the ACTION_WIRE_CONTRACT the bundled runtime entry declares. It is versioned +// apart from @sanderling/spec because the encoding and the package move +// independently: what this binary needs to know is which reading of the fields +// it is being handed, not which release shipped it. +// +// The two halves can disagree without either failing. Revision 1 +// (@sanderling/spec 0.0.3 and earlier, which declares nothing) sent an authored +// Scroll's container point as both endpoints; this binary reads pre-computed +// endpoints as authoritative, so every such scroll dispatched successfully as a +// 250ms press and hold and travelled zero distance, and no run said so. +const ActionWireContract = "action-wire/2" + +// actionEncodingGlobal is where the bundled runtime entry declares its +// contract (defineLockedGlobal in pkg/spec/src/runtime-entry.ts). +const actionEncodingGlobal = "__sanderlingActionEncoding__" + +// ActionEncodingError reports a bundle whose declared contract is not the one +// this binary decodes. declared is empty for a package published before the +// declaration existed, which is the pairing that has to fail loudest: it is the +// one that already produced a campaign of zero-distance scrolls. +func ActionEncodingError(declared string) error { + built := strconv.Quote(declared) + if declared == "" { + built = "an @sanderling/spec that declares none at all (every release before " + + strconv.Quote(ActionWireContract) + ")" + } + return fmt.Errorf( + "action encoding mismatch: this specification was bundled against %s and this "+ + "binary implements %q. Every action would still dispatch successfully while "+ + "executing a different gesture, so the run stops here instead of producing "+ + "wrong data. The @sanderling/spec the spec is bundled against and this binary "+ + "must come from the same commit or a compatible release: re-install "+ + "@sanderling/spec, or point the spec at the pkg/spec checkout this binary was "+ + "built from", + built, ActionWireContract) +} + // wireAction is the unified flat wire contract both runtime entries emit // (runtime-entry.ts serializeAction). ONE decoder reads it on both the goja // (native) and the runner (web) sides, so a field rename cannot silently turn diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 0f760a4..789848c 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -150,6 +150,10 @@ func (v *Verifier) Load(source string) error { return fmt.Errorf("run spec: %w", err) } + if err := v.checkActionEncoding(); err != nil { + return err + } + propertiesValue := v.runtime.GlobalObject().Get("properties") if propertiesValue != nil && !goja.IsUndefined(propertiesValue) && !goja.IsNull(propertiesValue) { propertiesObject := propertiesValue.ToObject(v.runtime) @@ -210,6 +214,30 @@ func (v *Verifier) Load(source string) error { return nil } +// checkActionEncoding fails the load when the bundle's action encoding is not +// the one this binary decodes. A bundle that installs the picker and declares +// nothing is an @sanderling/spec older than the declaration, which is the +// pairing that has to be caught: an absent declaration is a mismatch, never a +// default. A bundle with no picker (a raw-JS fixture, the bundle-check tool) +// generates no actions at all, so it has no encoding to disagree about. +// +// chrome.Driver.InstallBundle applies this same rule to the web host. +func (v *Verifier) checkActionEncoding() error { + picker := v.runtime.GlobalObject().Get("__sanderlingNextAction__") + if picker == nil || goja.IsUndefined(picker) || goja.IsNull(picker) { + return nil + } + declared := "" + if value := v.runtime.GlobalObject().Get(actionEncodingGlobal); value != nil && + !goja.IsUndefined(value) && !goja.IsNull(value) { + declared = value.String() + } + if declared == ActionWireContract { + return nil + } + return ActionEncodingError(declared) +} + // buildFormula walks the formula-spec registry and produces a Go ltl.Formula // tree rooted at the given spec index. //