diff --git a/runtime/lua/modules/contract/module.go b/runtime/lua/modules/contract/module.go index 6f8c3a2c4..0d6fdb022 100644 --- a/runtime/lua/modules/contract/module.go +++ b/runtime/lua/modules/contract/module.go @@ -5,6 +5,7 @@ package contract import ( "context" "net/url" + "reflect" "strconv" "strings" @@ -105,6 +106,23 @@ type InstanceWrapper struct { hasOptions bool } +// isNilInstance handles both a nil contract.Instance interface and an interface +// containing a typed nil pointer. The latter can cross a Go interface boundary +// without comparing equal to nil and would otherwise panic on Implements/Call. +func isNilInstance(instance contract.Instance) bool { + if instance == nil { + return true + } + + value := reflect.ValueOf(instance) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} + func applyOpenSecurityContext(ctx context.Context, yield *OpenYield, actor secapi.Actor, hasActor bool, scope secapi.Scope, hasScope bool) { if hasActor { yield.Actor = actor @@ -273,7 +291,7 @@ func isContract(l *lua.LState) int { contractID := l.CheckString(2) wrapper, ok := ud.Value.(*InstanceWrapper) - if !ok { + if !ok || wrapper == nil || isNilInstance(wrapper.instance) { l.Push(lua.LBool(false)) return 1 } diff --git a/runtime/lua/modules/contract/module_test.go b/runtime/lua/modules/contract/module_test.go index 25828aed7..be52b3a35 100644 --- a/runtime/lua/modules/contract/module_test.go +++ b/runtime/lua/modules/contract/module_test.go @@ -292,6 +292,47 @@ func TestOpenYield_HandleResult(t *testing.T) { require.Len(t, results, 2) assert.Equal(t, lua.LNil, results[0]) assert.NotEqual(t, lua.LNil, results[1]) + + // A successful dispatcher response must still carry an instance. This + // protects the Lua boundary from wrapping a nil instance and letting + // contract.is or instance method dispatch dereference it later. + results = y.HandleResult(l, contract.OpenResult{}, nil) + require.Len(t, results, 2) + assert.Equal(t, lua.LNil, results[0]) + assert.Contains(t, results[1].String(), "contract instance is nil") + + // An interface containing a typed nil pointer is not itself equal to nil; + // the boundary must reject that shape as well and preserve its error class. + var typedNil *mockInstanceForTest + results = y.HandleResult(l, contract.OpenResult{Instance: typedNil}, nil) + require.Len(t, results, 2) + assert.Equal(t, lua.LNil, results[0]) + luaErr, ok := lua.AsError(results[1]) + require.True(t, ok) + assert.Equal(t, lua.Internal, luaErr.Kind()) + assert.Equal(t, lua.TernaryFalse, luaErr.Retryable()) +} + +func TestIsContract_NilInstanceValuesReturnFalse(t *testing.T) { + l := lua.NewState() + defer l.Close() + + assertFalse := func(t *testing.T, value any) { + t.Helper() + l.SetTop(0) + ud := l.NewUserData() + ud.Value = value + l.Push(ud) + l.Push(lua.LString("test:contract")) + require.Equal(t, 1, isContract(l)) + assert.Equal(t, lua.LFalse, l.Get(-1)) + } + + assertFalse(t, (*InstanceWrapper)(nil)) + assertFalse(t, &InstanceWrapper{}) + + var typedNil *mockInstanceForTest + assertFalse(t, &InstanceWrapper{instance: typedNil}) } func TestOpenYield_HandleResult_WithOptions(t *testing.T) { diff --git a/runtime/lua/modules/contract/yields.go b/runtime/lua/modules/contract/yields.go index 0dfbc3fd7..bf59a2600 100644 --- a/runtime/lua/modules/contract/yields.go +++ b/runtime/lua/modules/contract/yields.go @@ -70,6 +70,12 @@ func (y *OpenYield) HandleResult(l *lua.LState, data any, err error) []lua.LValu luaErr := lua.WrapErrorWithLua(l, resp.Error, "") return []lua.LValue{lua.LNil, luaErr} } + if isNilInstance(resp.Instance) { + luaErr := lua.WrapErrorWithLua(l, contract.ErrInstanceNil, "open failed"). + WithKind(lua.Internal). + WithRetryable(false) + return []lua.LValue{lua.LNil, luaErr} + } // Wrap instance in userdata wrapper := &InstanceWrapper{