From 1bad88f94b7a764d2c737253e3d35f498138a1dd Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 24 Aug 2026 18:25:03 -0700 Subject: [PATCH] refactor(execution): move native dispatch behind a port --- dispatch.go | 148 ++++++++ dispatch_test.go | 385 +++++++++++++++++++++ internal/binding/input.go | 100 ------ internal/binding/input_test.go | 132 -------- internal/binding/output.go | 40 +-- internal/binding/output_test.go | 10 +- internal/execution/doc.go | 6 +- internal/execution/engine.go | 108 +++++- internal/execution/errors.go | 5 +- internal/execution/execute.go | 127 ++----- internal/execution/execute_test.go | 525 +++++++++++++++-------------- internal/execution/namespace.go | 19 +- internal/execution/phase.go | 18 +- server.go | 38 ++- server_test.go | 30 ++ 15 files changed, 1036 insertions(+), 655 deletions(-) create mode 100644 dispatch.go create mode 100644 dispatch_test.go diff --git a/dispatch.go b/dispatch.go new file mode 100644 index 0000000..ad780b3 --- /dev/null +++ b/dispatch.go @@ -0,0 +1,148 @@ +package codemode + +import ( + "context" + "errors" + "fmt" + + "github.com/meigma/codemode/authz" + "github.com/meigma/codemode/internal/catalog" + "github.com/meigma/codemode/internal/execution" +) + +// dispatcher is the unexported authoritative native-call owner. +type dispatcher struct { + // catalog is the immutable statically filtered capability set. + catalog *catalog.Catalog + + // authorizer is called once after parent re-binding succeeds. + authorizer authz.Authorizer +} + +// newDispatcher constructs one request-neutral dispatcher from retained server state. +func newDispatcher(capabilityCatalog *catalog.Catalog, authorizer authz.Authorizer) *dispatcher { + return &dispatcher{ + catalog: capabilityCatalog, + authorizer: authorizer, + } +} + +// dispatch looks up an enabled ID, re-binds, authorizes, invokes, and converts typed output. +func (dispatch *dispatcher) dispatch( + ctx context.Context, + subject authz.Subject, + id string, + args map[string]any, +) (any, error) { + if dispatch == nil || dispatch.catalog == nil || dispatch.authorizer == nil { + return nil, execution.ErrInternal + } + if contextErr := contextFailure(ctx); contextErr != nil { + return nil, contextErr + } + entry, ok := dispatch.catalog.LookupID(id) + if !ok || entry.Plan == nil || entry.Invoke == nil { + return nil, execution.ErrInternal + } + input, canonical, bindingErr := entry.Plan.BindValue(args) + if bindingErr != nil { + return nil, fmt.Errorf("%w: %w", execution.ErrInternal, bindingErr) + } + if contextErr := contextFailure(ctx); contextErr != nil { + return nil, contextErr + } + if policyErr := authorize(ctx, dispatch.authorizer, subject, entry, canonical); policyErr != nil { + return nil, policyErr + } + if contextErr := contextFailure(ctx); contextErr != nil { + return nil, contextErr + } + output, invocationErr := invoke(ctx, subject, entry, input) + if invocationErr != nil { + return nil, invocationErr + } + converted, conversionErr := entry.Plan.ConvertOutput(output) + if conversionErr != nil { + return nil, fmt.Errorf("%w: %w", execution.ErrCapabilityFailure, conversionErr) + } + return converted, nil +} + +// authorize calls policy once and converts denial, error, and panic to safe internal classes. +func authorize( + ctx context.Context, + authorizer authz.Authorizer, + subject authz.Subject, + entry catalog.Entry, + arguments map[string]any, +) (err error) { + defer func() { + if recover() != nil { + err = execution.ErrPolicyFailure + } + }() + err = authorizer.Authorize(ctx, authz.AuthorizationInput{ + Subject: subject, + CapabilityID: entry.ID, + CapabilityName: entry.Name, + Arguments: arguments, + }) + if err == nil { + return nil + } + if errors.Is(err, authz.ErrDenied) { + return fmt.Errorf("%w: %w", execution.ErrPermissionDenied, err) + } + return fmt.Errorf("%w: %w", execution.ErrPolicyFailure, err) +} + +// invocationOutcome carries one recovered native handler result without named returns. +type invocationOutcome struct { + // output is the handler's typed result. + output any + + // err is the handler or recovery failure. + err error +} + +// errRecoveredHandlerPanic distinguishes a recovered panic from an ordinary handler error. +var errRecoveredHandlerPanic = errors.New("recovered handler panic") + +// invoke calls one typed handler and recovers handler panics at the native boundary. +func invoke(ctx context.Context, subject authz.Subject, entry catalog.Entry, input any) (any, error) { + outcome := invocationOutcome{} + func() { + defer func() { + if recover() != nil { + outcome.output = nil + outcome.err = errRecoveredHandlerPanic + } + }() + outcome.output, outcome.err = entry.Invoke(ctx, subject, input) + }() + if outcome.err == nil { + return outcome.output, nil + } + if errors.Is(outcome.err, errRecoveredHandlerPanic) { + return nil, execution.ErrInternal + } + if errors.Is(outcome.err, catalog.ErrInputTypeMismatch) { + return nil, fmt.Errorf("%w: %w", execution.ErrInternal, outcome.err) + } + return nil, fmt.Errorf("%w: %w", execution.ErrCapabilityFailure, outcome.err) +} + +// contextFailure projects request cancellation directly and deadlines as resource exhaustion. +func contextFailure(ctx context.Context) error { + if ctx == nil { + return execution.ErrInternal + } + switch err := ctx.Err(); { + case errors.Is(err, context.Canceled): + return context.Canceled + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("%w: %w", execution.ErrResourceLimit, context.DeadlineExceeded) + default: + return nil + } +} diff --git a/dispatch_test.go b/dispatch_test.go new file mode 100644 index 0000000..c8f69f6 --- /dev/null +++ b/dispatch_test.go @@ -0,0 +1,385 @@ +package codemode + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/authz" + authzmocks "github.com/meigma/codemode/authz/mocks" + "github.com/meigma/codemode/internal/binding" + "github.com/meigma/codemode/internal/catalog" + "github.com/meigma/codemode/internal/execution" +) + +// dispatchInput is the representative dispatcher test input. +type dispatchInput struct { + // Value is one required capability argument. + Value string `json:"value"` +} + +// dispatchOutput is the representative dispatcher test output. +type dispatchOutput struct { + // Value is one handler result field. + Value string `json:"value"` +} + +// dispatchOutcome carries one asynchronous dispatcher result. +type dispatchOutcome struct { + // value is the converted native result. + value any + + // err is the classified dispatcher failure. + err error +} + +// TestDispatchBindsAuthorizesThenInvokes proves parent re-binding precedes policy and handlers. +func TestDispatchBindsAuthorizesThenInvokes(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + events := make([]string, 0, 2) + authorizer.EXPECT().Authorize(mock.Anything, mock.MatchedBy(func(input authz.AuthorizationInput) bool { + return input.Subject.ID == "subject-1" && + input.CapabilityID == "cap.lookup" && + input.CapabilityName == "records.lookup" && + assert.ObjectsAreEqual(map[string]any{"value": "alpha"}, input.Arguments) + })).Run(func(context.Context, authz.AuthorizationInput) { + events = append(events, "authorize") + }).Return(nil).Once() + subject := newDispatchSubject(t, authorizer, func(_ context.Context, _ authz.Subject, input any) (any, error) { + events = append(events, "handler") + typed, ok := input.(dispatchInput) + if !ok { + return nil, catalog.ErrInputTypeMismatch + } + return dispatchOutput(typed), nil + }) + + result, err := subject.dispatch.dispatch( + t.Context(), + authz.Subject{ID: "subject-1"}, + "cap.lookup", + map[string]any{"value": "alpha"}, + ) + + require.NoError(t, err) + assert.Equal(t, []string{"authorize", "handler"}, events) + assert.Equal(t, map[string]any{"value": "alpha"}, result) +} + +// TestDispatchTranslatesEveryBindValueFailureInternally proves decoded maps never reach policy. +func TestDispatchTranslatesEveryBindValueFailureInternally(t *testing.T) { + tests := []struct { + // name identifies the rejected decoded map. + name string + + // arguments is the decoded child map presented to dispatch. + arguments map[string]any + }{ + {name: "missing required", arguments: map[string]any{}}, + {name: "unknown", arguments: map[string]any{"value": "alpha", "other": "extra"}}, + {name: "mistyped required string", arguments: map[string]any{"value": int64(1)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + var handlerCalls atomic.Int64 + subject := newDispatchSubject(t, authorizer, func(context.Context, authz.Subject, any) (any, error) { + handlerCalls.Add(1) + return dispatchOutput{}, nil + }) + + _, err := subject.dispatch.dispatch( + t.Context(), + authz.Subject{ID: "subject-1"}, + "cap.lookup", + tt.arguments, + ) + + require.ErrorIs(t, err, execution.ErrInternal) + require.NotErrorIs(t, err, execution.ErrInvalidArguments) + assert.Zero(t, handlerCalls.Load()) + }) + } +} + +// TestDispatchRejectsUnknownIDsBeforeAuthorization proves disabled and unknown IDs stay internal. +func TestDispatchRejectsUnknownIDsBeforeAuthorization(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + var handlerCalls atomic.Int64 + subject := newDispatchSubject(t, authorizer, func(context.Context, authz.Subject, any) (any, error) { + handlerCalls.Add(1) + return dispatchOutput{}, nil + }) + + _, err := subject.dispatch.dispatch( + t.Context(), + authz.Subject{ID: "subject-1"}, + "cap.missing", + map[string]any{"value": "alpha"}, + ) + + require.ErrorIs(t, err, execution.ErrInternal) + assert.Zero(t, handlerCalls.Load()) +} + +// TestDispatchClassifiesPolicyAndHandlerFailures proves every retained native classification. +func TestDispatchClassifiesPolicyAndHandlerFailures(t *testing.T) { + tests := []struct { + // name identifies the classified failure. + name string + + // configure installs one generated mock expectation. + configure func(*authzmocks.MockAuthorizer) + + // invoke is the native handler under test. + invoke catalog.Invoker + + // target is the expected dispatcher classification. + target error + }{ + { + name: "recognized denial", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Return(authz.ErrDenied).Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return dispatchOutput{}, nil + }, + target: execution.ErrPermissionDenied, + }, + { + name: "policy error", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT(). + Authorize(mock.Anything, mock.Anything). + Return(errors.New("trusted policy detail")). + Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return dispatchOutput{}, nil + }, + target: execution.ErrPolicyFailure, + }, + { + name: "policy panic", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT(). + Authorize(mock.Anything, mock.Anything). + Run(func(context.Context, authz.AuthorizationInput) { + panic("trusted panic detail") + }). + Return(nil). + Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return dispatchOutput{}, nil + }, + target: execution.ErrPolicyFailure, + }, + { + name: "handler error", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Return(nil).Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return dispatchOutput{}, errors.New("trusted handler detail") + }, + target: execution.ErrCapabilityFailure, + }, + { + name: "handler panic", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Return(nil).Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + panic("trusted handler panic") + }, + target: execution.ErrInternal, + }, + { + name: "input type mismatch", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Return(nil).Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return nil, catalog.ErrInputTypeMismatch + }, + target: execution.ErrInternal, + }, + { + name: "output type drift", + configure: func(authorizer *authzmocks.MockAuthorizer) { + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Return(nil).Once() + }, + invoke: func(context.Context, authz.Subject, any) (any, error) { + return struct { + // Name is an intentionally incompatible output field. + Name string + }{Name: "alpha"}, nil + }, + target: execution.ErrCapabilityFailure, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + tt.configure(authorizer) + subject := newDispatchSubject(t, authorizer, tt.invoke) + + _, err := subject.dispatch.dispatch( + t.Context(), + authz.Subject{ID: "subject-1"}, + "cap.lookup", + map[string]any{"value": "alpha"}, + ) + + require.ErrorIs(t, err, tt.target) + }) + } +} + +// TestDispatchReturnsFreshCanonicalMaps proves decoded child maps never become authorization input. +func TestDispatchReturnsFreshCanonicalMaps(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + decoded := map[string]any{"value": "alpha"} + var authorized map[string]any + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Run( + func(_ context.Context, input authz.AuthorizationInput) { + authorized = input.Arguments + input.Arguments["value"] = "mutated-policy" + decoded["value"] = "mutated-decoded" + }, + ).Return(nil).Once() + var received dispatchInput + subject := newDispatchSubject(t, authorizer, func(_ context.Context, _ authz.Subject, input any) (any, error) { + typed, ok := input.(dispatchInput) + if !ok { + return nil, catalog.ErrInputTypeMismatch + } + received = typed + return dispatchOutput(typed), nil + }) + + result, err := subject.dispatch.dispatch( + t.Context(), + authz.Subject{ID: "subject-1"}, + "cap.lookup", + decoded, + ) + + require.NoError(t, err) + require.NotNil(t, authorized) + assert.Equal(t, "mutated-policy", authorized["value"]) + assert.Equal(t, "mutated-decoded", decoded["value"]) + assert.Equal(t, "alpha", received.Value) + assert.Equal(t, map[string]any{"value": "alpha"}, result) +} + +// TestDispatchCancellationAfterAllowPreventsInvoke proves a stale allow cannot reach the handler. +func TestDispatchCancellationAfterAllowPreventsInvoke(t *testing.T) { + tests := []struct { + // name identifies the cancellation source. + name string + + // newContext creates the dispatch context, cleanup, and cancellation trigger. + newContext func(*testing.T) (context.Context, context.CancelFunc, func()) + + // targets are the required dispatcher error classifications. + targets []error + }{ + { + name: "request cancellation", + newContext: func(t *testing.T) (context.Context, context.CancelFunc, func()) { + ctx, cancel := context.WithCancel(t.Context()) + return ctx, cancel, cancel + }, + targets: []error{context.Canceled}, + }, + { + name: "elapsed deadline", + newContext: func(t *testing.T) (context.Context, context.CancelFunc, func()) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + return ctx, cancel, func() { + <-ctx.Done() + } + }, + targets: []error{execution.ErrResourceLimit, context.DeadlineExceeded}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel, trigger := tt.newContext(t) + defer cancel() + authorizationStarted := make(chan struct{}) + releaseAuthorization := make(chan struct{}) + authorizer := authzmocks.NewMockAuthorizer(t) + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Run( + func(context.Context, authz.AuthorizationInput) { + close(authorizationStarted) + <-releaseAuthorization + }, + ).Return(nil).Once() + var handlerCalls atomic.Int64 + subject := newDispatchSubject(t, authorizer, func(context.Context, authz.Subject, any) (any, error) { + handlerCalls.Add(1) + return dispatchOutput{}, nil + }) + result := make(chan dispatchOutcome, 1) + go func() { + value, err := subject.dispatch.dispatch( + ctx, + authz.Subject{ID: "subject-1"}, + "cap.lookup", + map[string]any{"value": "alpha"}, + ) + result <- dispatchOutcome{value: value, err: err} + }() + + <-authorizationStarted + trigger() + close(releaseAuthorization) + outcome := <-result + + assert.Nil(t, outcome.value) + for _, target := range tt.targets { + require.ErrorIs(t, outcome.err, target) + } + assert.Zero(t, handlerCalls.Load()) + }) + } +} + +// dispatchSubject carries one catalog-backed dispatcher under test. +type dispatchSubject struct { + // dispatch is the unexported native-call owner. + dispatch *dispatcher +} + +// newDispatchSubject builds one catalog and dispatcher around a function-fake handler. +func newDispatchSubject(t *testing.T, authorizer authz.Authorizer, invoke catalog.Invoker) *dispatchSubject { + t.Helper() + plan, err := binding.CompileFor[dispatchInput, dispatchOutput]() + require.NoError(t, err) + capabilityCatalog, err := catalog.Build([]catalog.Registration{{ + ID: "cap.lookup", + Name: "records.lookup", + Summary: "Return one record.", + Description: "Returns the supplied record value.", + Plan: plan, + Invoke: invoke, + }}, catalog.Options{ + MaxSearchQueryBytes: 256, + MaxSearchResults: 20, + }) + require.NoError(t, err) + return &dispatchSubject{dispatch: newDispatcher(capabilityCatalog, authorizer)} +} diff --git a/internal/binding/input.go b/internal/binding/input.go index 9a2ce5e..e9f8601 100644 --- a/internal/binding/input.go +++ b/internal/binding/input.go @@ -9,51 +9,6 @@ import ( const keywordTupleSize = 2 -// BindAs binds keyword arguments to the plan's exact generic input type and canonical arguments. -func BindAs[Input any](plan *Plan, args starlark.Tuple, kwargs []starlark.Tuple) (Input, map[string]any, error) { - var zero Input - if plan == nil || plan.inputType != reflect.TypeFor[Input]() { - return zero, nil, fmt.Errorf("%w: requested input type does not match the compiled plan", ErrInvalidPlan) - } - bound, canonical, err := plan.Bind(args, kwargs) - if err != nil { - return zero, nil, err - } - typed, ok := bound.(Input) - if !ok { - return zero, nil, fmt.Errorf("%w: bound input does not match its compiled type", ErrInvalidPlan) - } - return typed, canonical, nil -} - -// Bind validates keyword-only Starlark arguments and constructs an exact Go input plus fresh canonical map. -func (plan *Plan) Bind(args starlark.Tuple, kwargs []starlark.Tuple) (any, map[string]any, error) { - if plan == nil { - return nil, nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) - } - if len(args) != 0 { - return nil, nil, fmt.Errorf("%w: positional arguments are not accepted", ErrInvalidArguments) - } - - input := reflect.New(plan.inputType).Elem() - canonical := make(map[string]any, len(kwargs)) - seen := make(map[string]struct{}, len(kwargs)) - for _, keyword := range kwargs { - name, err := plan.bindKeyword(input, canonical, keyword) - if err != nil { - return nil, nil, err - } - if _, duplicate := seen[name]; duplicate { - return nil, nil, fmt.Errorf("%w: duplicate argument %q", ErrInvalidArguments, name) - } - seen[name] = struct{}{} - } - if err := plan.requireArguments(seen); err != nil { - return nil, nil, err - } - return input.Interface(), canonical, nil -} - // BindShape binds keyword-only Starlark arguments against a validated input shape. // // It returns only a fresh canonical JSON-shaped map. Explicit Starlark None and @@ -162,61 +117,6 @@ func (plan *Plan) BindValue(arguments map[string]any) (any, map[string]any, erro return input.Interface(), canonical, nil } -// bindKeyword validates and converts one keyword into both typed and canonical representations. -func (plan *Plan) bindKeyword(input reflect.Value, canonical map[string]any, keyword starlark.Tuple) (string, error) { - name, value, err := keywordArgument(keyword) - if err != nil { - return "", err - } - fieldIndex, known := plan.inputByName[name] - if !known { - return "", fmt.Errorf("%w: unknown argument %q", ErrInvalidArguments, name) - } - - field := plan.inputFields[fieldIndex] - switch field.kind { - case fieldString: - converted, ok := starlark.AsString(value) - if !ok { - return "", fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, name) - } - input.Field(field.index).SetString(converted) - canonical[name] = converted - case fieldOptionalInt64: - if value == starlark.None { - return name, nil - } - integer, ok := value.(starlark.Int) - if !ok { - return "", fmt.Errorf("%w: argument %q must be an integer or None", ErrInvalidArguments, name) - } - converted, ok := integer.Int64() - if !ok { - return "", fmt.Errorf("%w: argument %q overflows int64", ErrInvalidArguments, name) - } - pointer := reflect.New(input.Field(field.index).Type().Elem()) - pointer.Elem().SetInt(converted) - input.Field(field.index).Set(pointer) - canonical[name] = converted - case fieldInt64, fieldBool, fieldFloat64: - return "", fmt.Errorf("%w: argument %q has an invalid compiled kind", ErrInvalidPlan, name) - } - return name, nil -} - -// requireArguments verifies that every required compiled field was present. -func (plan *Plan) requireArguments(seen map[string]struct{}) error { - for _, field := range plan.inputFields { - if !field.required { - continue - } - if _, ok := seen[field.name]; !ok { - return fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.name) - } - } - return nil -} - // keywordArgument extracts the string name and value from one keyword tuple. func keywordArgument(keyword starlark.Tuple) (string, starlark.Value, error) { if len(keyword) != keywordTupleSize { diff --git a/internal/binding/input_test.go b/internal/binding/input_test.go index 9af59a1..87b58ff 100644 --- a/internal/binding/input_test.go +++ b/internal/binding/input_test.go @@ -10,138 +10,6 @@ import ( "go.starlark.net/starlark" ) -// TestBindAsBuildsTypedInputAndCanonicalArguments proves one plan traversal produces both projections. -func TestBindAsBuildsTypedInputAndCanonicalArguments(t *testing.T) { - plan, err := CompileFor[representativeInput, representativeOutput]() - require.NoError(t, err) - - bound, canonical, err := BindAs[representativeInput](plan, nil, []starlark.Tuple{ - keyword("org", starlark.String("meigma")), - keyword("limit", starlark.MakeInt64(25)), - }) - - require.NoError(t, err) - require.NotNil(t, bound.Limit) - assert.Equal(t, "meigma", bound.Org) - assert.Equal(t, int64(25), *bound.Limit) - assert.Equal(t, map[string]any{"org": "meigma", "limit": int64(25)}, canonical) -} - -// TestBindAsNormalizesOmittedAndExplicitNone proves both optional forms produce nil and omit the canonical key. -func TestBindAsNormalizesOmittedAndExplicitNone(t *testing.T) { - plan, err := CompileFor[representativeInput, representativeOutput]() - require.NoError(t, err) - - tests := []struct { - // name identifies the optional argument form. - name string - - // kwargs contains the Starlark keyword arguments. - kwargs []starlark.Tuple - }{ - {name: "omitted", kwargs: []starlark.Tuple{keyword("org", starlark.String("meigma"))}}, - {name: "explicit None", kwargs: []starlark.Tuple{ - keyword("org", starlark.String("meigma")), - keyword("limit", starlark.None), - }}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bound, canonical, err := BindAs[representativeInput](plan, nil, tt.kwargs) - - require.NoError(t, err) - assert.Nil(t, bound.Limit) - assert.Equal(t, map[string]any{"org": "meigma"}, canonical) - }) - } -} - -// TestBindReturnsFreshCanonicalMaps proves one invocation cannot mutate another authorization input. -func TestBindReturnsFreshCanonicalMaps(t *testing.T) { - plan, err := CompileFor[representativeInput, representativeOutput]() - require.NoError(t, err) - kwargs := []starlark.Tuple{keyword("org", starlark.String("meigma"))} - - _, first, err := plan.Bind(nil, kwargs) - require.NoError(t, err) - first["org"] = "mutated" - _, second, err := plan.Bind(nil, kwargs) - require.NoError(t, err) - - assert.Equal(t, "meigma", second["org"]) -} - -// TestBindRejectsMalformedArguments proves invalid calls fail before authorization can observe them. -func TestBindRejectsMalformedArguments(t *testing.T) { - plan, err := CompileFor[representativeInput, representativeOutput]() - require.NoError(t, err) - overflow := new(big.Int).Lsh(big.NewInt(1), 80) - - tests := []struct { - // name identifies the malformed call. - name string - - // args contains positional arguments. - args starlark.Tuple - - // kwargs contains keyword arguments. - kwargs []starlark.Tuple - - // contains is the expected safe diagnostic fragment. - contains string - }{ - {name: "positional", args: starlark.Tuple{starlark.String("meigma")}, contains: "positional"}, - {name: "missing required", contains: "missing required"}, - {name: "duplicate", kwargs: []starlark.Tuple{ - keyword("org", starlark.String("one")), - keyword("org", starlark.String("two")), - }, contains: "duplicate"}, - {name: "unknown", kwargs: []starlark.Tuple{ - keyword("org", starlark.String("meigma")), - keyword("other", starlark.String("value")), - }, contains: "unknown"}, - {name: "mistyped required string", kwargs: []starlark.Tuple{ - keyword("org", starlark.MakeInt(1)), - }, contains: "must be a string"}, - {name: "mistyped optional integer", kwargs: []starlark.Tuple{ - keyword("org", starlark.String("meigma")), - keyword("limit", starlark.String("many")), - }, contains: "integer or None"}, - {name: "overflowing optional integer", kwargs: []starlark.Tuple{ - keyword("org", starlark.String("meigma")), - keyword("limit", starlark.MakeBigInt(overflow)), - }, contains: "overflows int64"}, - {name: "malformed keyword", kwargs: []starlark.Tuple{{starlark.String("org")}}, contains: "malformed keyword"}, - { - name: "non-string keyword", - kwargs: []starlark.Tuple{{starlark.MakeInt(1), starlark.String("value")}}, - contains: "keyword name", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, _, err := plan.Bind(tt.args, tt.kwargs) - - require.Error(t, err) - require.ErrorIs(t, err, ErrInvalidArguments) - assert.Contains(t, err.Error(), tt.contains) - }) - } -} - -// TestBindAsRejectsTypeDrift proves callers cannot reuse a plan with a different typed input. -func TestBindAsRejectsTypeDrift(t *testing.T) { - plan, err := CompileFor[representativeInput, representativeOutput]() - require.NoError(t, err) - - _, _, err = BindAs[struct{}](plan, nil, nil) - - require.Error(t, err) - require.ErrorIs(t, err, ErrInvalidPlan) -} - // TestBindShapeAndBindValueAgreeOnSupportedCalls proves child shape binding and parent re-binding share one matrix. func TestBindShapeAndBindValueAgreeOnSupportedCalls(t *testing.T) { plan, err := CompileFor[representativeInput, representativeOutput]() diff --git a/internal/binding/output.go b/internal/binding/output.go index efb9bb3..ef610c4 100644 --- a/internal/binding/output.go +++ b/internal/binding/output.go @@ -8,28 +8,8 @@ import ( "go.starlark.net/starlark" ) -// ConvertOutput converts the plan's exact handler output directly to a Starlark object. -func (plan *Plan) ConvertOutput(output any) (starlark.Value, error) { - converted, err := plan.convertTypedOutput(output) - if err != nil { - return nil, err - } - - result := starlark.NewDict(len(plan.outputFields)) - for _, field := range plan.outputFields { - item, err := toStarlarkScalar(converted[field.name]) - if err != nil { - return nil, fmt.Errorf("%w: output field %q: %w", ErrInvalidPlan, field.name, err) - } - if err := result.SetKey(starlark.String(field.name), item); err != nil { - return nil, fmt.Errorf("%w: output field %q: %w", ErrUnsupportedValue, field.name, err) - } - } - return result, nil -} - -// convertTypedOutput converts the plan's exact handler output to a process-neutral object. -func (plan *Plan) convertTypedOutput(output any) (map[string]any, error) { +// ConvertOutput converts the plan's exact handler output to a process-neutral object. +func (plan *Plan) ConvertOutput(output any) (map[string]any, error) { if plan == nil { return nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) } @@ -88,19 +68,3 @@ func convertOutputField(field outputField, value reflect.Value) (any, error) { } return nil, fmt.Errorf("%w: output field %q has an unknown compiled kind", ErrInvalidPlan, field.name) } - -// toStarlarkScalar converts one process-neutral scalar produced by convertTypedOutput. -func toStarlarkScalar(value any) (starlark.Value, error) { - switch typed := value.(type) { - case string: - return starlark.String(typed), nil - case int64: - return starlark.MakeInt64(typed), nil - case bool: - return starlark.Bool(typed), nil - case float64: - return starlark.Float(typed), nil - default: - return nil, fmt.Errorf("unexpected converted output type %T", value) - } -} diff --git a/internal/binding/output_test.go b/internal/binding/output_test.go index e1e243e..d61ab7b 100644 --- a/internal/binding/output_test.go +++ b/internal/binding/output_test.go @@ -19,9 +19,7 @@ func TestConvertOutputUsesCompiledFields(t *testing.T) { require.NoError(t, err) output := representativeOutput{Name: "alpha", Count: 3, Active: true, Score: 1.5} - value, err := plan.ConvertOutput(output) - require.NoError(t, err) - converted, err := ConvertFinal(value, 4, 1024) + converted, err := plan.ConvertOutput(output) require.NoError(t, err) assert.Equal(t, map[string]any{ @@ -32,13 +30,13 @@ func TestConvertOutputUsesCompiledFields(t *testing.T) { }, converted) } -// TestConvertTypedOutputPreservesExactKinds proves handler output becomes process-neutral data without type erasure. -func TestConvertTypedOutputPreservesExactKinds(t *testing.T) { +// TestConvertOutputPreservesExactKinds proves handler output becomes process-neutral data without type erasure. +func TestConvertOutputPreservesExactKinds(t *testing.T) { plan, err := CompileFor[representativeInput, representativeOutput]() require.NoError(t, err) output := representativeOutput{Name: "alpha", Count: 3, Active: true, Score: 1.0} - converted, err := plan.convertTypedOutput(output) + converted, err := plan.ConvertOutput(output) require.NoError(t, err) assert.Equal(t, map[string]any{ diff --git a/internal/execution/doc.go b/internal/execution/doc.go index 9fb2379..36adea9 100644 --- a/internal/execution/doc.go +++ b/internal/execution/doc.go @@ -1,2 +1,6 @@ -// Package execution runs one restricted, authorized, bounded Starlark program at a time. +// Package execution runs one restricted, bounded Starlark program at a time. +// +// Engine is compiled from process-neutral capability bindings. Each Execute +// call receives a request-specific NativeCall and converts only main's final +// value. package execution diff --git a/internal/execution/engine.go b/internal/execution/engine.go index 5e308df..f78947c 100644 --- a/internal/execution/engine.go +++ b/internal/execution/engine.go @@ -2,26 +2,122 @@ package execution import ( "fmt" + "strings" "go.starlark.net/starlark" - "github.com/meigma/codemode/internal/catalog" + "github.com/meigma/codemode/internal/binding" ) +const minimumDottedSegments = 2 + +// CapabilityBinding is one process-neutral capability exposed to the interpreter. +type CapabilityBinding struct { + // ID is the stable capability identity passed to NativeCall. + ID string + + // Name is the dotted Starlark path used to assemble the frozen namespace. + Name string + + // Input is the exact compiled input shape used to bind keyword arguments. + Input []binding.FieldShape +} + +// NativeCall invokes one capability with a fresh canonical argument map. +// +// It returns one normalized process-neutral value or a classified error. +type NativeCall func(id string, arguments map[string]any) (any, error) + // Engine owns the frozen capability namespace shared by fresh execution threads. type Engine struct { // predeclared contains immutable namespaced capability builtins. predeclared starlark.StringDict } -// New compiles one immutable execution engine from a validated filtered catalog. -func New(capabilityCatalog *catalog.Catalog) (*Engine, error) { - if capabilityCatalog == nil { - return nil, fmt.Errorf("%w: nil catalog", ErrInternal) +// New compiles one immutable execution engine from process-neutral capability bindings. +func New(bindings []CapabilityBinding) (*Engine, error) { + copied := copyBindings(bindings) + if err := validateBindings(copied); err != nil { + return nil, err } - predeclared, err := buildPredeclared(capabilityCatalog) + predeclared, err := buildPredeclared(copied) if err != nil { return nil, err } return &Engine{predeclared: predeclared}, nil } + +// copyBindings deep-copies caller-owned binding slices so Engine stays immutable. +func copyBindings(bindings []CapabilityBinding) []CapabilityBinding { + copied := make([]CapabilityBinding, len(bindings)) + for index, capability := range bindings { + input := make([]binding.FieldShape, len(capability.Input)) + copy(input, capability.Input) + copied[index] = CapabilityBinding{ + ID: capability.ID, + Name: capability.Name, + Input: input, + } + } + return copied +} + +// validateBindings rejects empty or colliding identities and invalid input shapes. +func validateBindings(bindings []CapabilityBinding) error { + ids := make(map[string]struct{}, len(bindings)) + names := make(map[string]struct{}, len(bindings)) + for _, capability := range bindings { + if capability.ID == "" || capability.ID != strings.TrimSpace(capability.ID) { + return fmt.Errorf("%w: empty capability ID", ErrInternal) + } + if _, duplicate := ids[capability.ID]; duplicate { + return fmt.Errorf("%w: duplicate capability ID", ErrInternal) + } + if !isDottedName(capability.Name) { + return fmt.Errorf("%w: capability name %q is not dotted", ErrInternal, capability.Name) + } + if _, duplicate := names[capability.Name]; duplicate { + return fmt.Errorf("%w: duplicate namespace function", ErrInternal) + } + if err := binding.ValidateInputShape(capability.Input); err != nil { + return fmt.Errorf("%w: %w", ErrInternal, err) + } + ids[capability.ID] = struct{}{} + names[capability.Name] = struct{}{} + } + return validateNamespaceCollisions(names) +} + +// validateNamespaceCollisions rejects a capability function that is also a namespace. +func validateNamespaceCollisions(names map[string]struct{}) error { + for name := range names { + segments := strings.Split(name, ".") + for end := 1; end < len(segments); end++ { + prefix := strings.Join(segments[:end], ".") + if _, collides := names[prefix]; collides { + return fmt.Errorf("%w: capability %q is also a namespace of %q", ErrInternal, prefix, name) + } + } + } + return nil +} + +// isDottedName reports whether name contains at least one namespace and valid Starlark segments. +func isDottedName(name string) bool { + segments := strings.Split(name, ".") + if len(segments) < minimumDottedSegments { + return false + } + for _, segment := range segments { + if !binding.ValidIdentifier(segment) { + return false + } + } + return true +} + +// splitCapabilityName returns the namespace path and function for a validated dotted name. +func splitCapabilityName(name string) ([]string, string) { + segments := strings.Split(name, ".") + return segments[:len(segments)-1], segments[len(segments)-1] +} diff --git a/internal/execution/errors.go b/internal/execution/errors.go index 4de1ea5..788e457 100644 --- a/internal/execution/errors.go +++ b/internal/execution/errors.go @@ -6,7 +6,7 @@ var ( // ErrInvalidProgram classifies invalid source, entrypoint behavior, or loading-phase native calls. ErrInvalidProgram = errors.New("invalid program") - // ErrInvalidArguments classifies native arguments rejected before authorization. + // ErrInvalidArguments classifies native arguments rejected before the native port. ErrInvalidArguments = errors.New("invalid capability arguments") // ErrPermissionDenied classifies a recognized authorization denial. @@ -23,7 +23,4 @@ var ( // ErrInternal classifies an unexpected runtime or contract failure. ErrInternal = errors.New("internal failure") - - // errRecoveredHandlerPanic distinguishes a recovered panic from an ordinary handler error. - errRecoveredHandlerPanic = errors.New("recovered handler panic") ) diff --git a/internal/execution/execute.go b/internal/execution/execute.go index 9499c6f..83d10c3 100644 --- a/internal/execution/execute.go +++ b/internal/execution/execute.go @@ -8,9 +8,7 @@ import ( "go.starlark.net/starlark" "go.starlark.net/syntax" - "github.com/meigma/codemode/authz" "github.com/meigma/codemode/internal/binding" - "github.com/meigma/codemode/internal/catalog" ) const executionStateLocal = "codemode.execution.state" @@ -27,9 +25,8 @@ type executionOutcome struct { // Execute runs source in a fresh restricted interpreter and converts only main's final value. func (engine *Engine) Execute( ctx context.Context, - subject authz.Subject, source string, - authorizer authz.Authorizer, + nativeCall NativeCall, limits Limits, ) (any, error) { outcome := executionOutcome{} @@ -40,14 +37,7 @@ func (engine *Engine) Execute( outcome.err = ErrInternal } }() - outcome.value, outcome.err = execute( - ctx, - subject, - source, - engine.predeclared, - authorizer, - limits, - ) + outcome.value, outcome.err = execute(ctx, source, engine.predeclared, nativeCall, limits) }() return outcome.value, outcome.err } @@ -55,13 +45,12 @@ func (engine *Engine) Execute( // execute performs one execution after the panic-recovery boundary is installed. func execute( ctx context.Context, - subject authz.Subject, source string, predeclared starlark.StringDict, - authorizer authz.Authorizer, + nativeCall NativeCall, limits Limits, ) (any, error) { - if ctx == nil || predeclared == nil || authorizer == nil { + if ctx == nil || predeclared == nil || nativeCall == nil { return nil, ErrInternal } if len(source) > limits.MaxSourceBytes { @@ -78,13 +67,11 @@ func execute( } state := &executionState{ - ctx: runCtx, - subject: subject, - authorizer: authorizer, - phase: phaseLoading, + phase: phaseLoading, nativeCalls: checkedCounter{ maximum: limits.MaxNativeCalls, }, + call: wrapNativeCall(runCtx, nativeCall, limits), } thread := newThread(state, limits.MaxExecutionSteps) stopCancellation := watchCancellation(runCtx, thread) @@ -133,6 +120,27 @@ func execute( return converted, nil } +// wrapNativeCall captures request context and conversion limits for one execution. +func wrapNativeCall(ctx context.Context, nativeCall NativeCall, limits Limits) nativeInvoker { + return func(id string, arguments map[string]any) (starlark.Value, error) { + if contextErr := contextFailure(ctx); contextErr != nil { + return nil, contextErr + } + result, err := nativeCall(id, arguments) + if err != nil { + return nil, err + } + converted, conversionErr := binding.ToStarlark(result, limits.MaxValueDepth, limits.MaxResultBytes) + if conversionErr != nil { + if errors.Is(conversionErr, binding.ErrValueLimit) { + return nil, fmt.Errorf("%w: %w", ErrResourceLimit, conversionErr) + } + return nil, fmt.Errorf("%w: %w", ErrInternal, conversionErr) + } + return converted, nil + } +} + // newThread constructs one restricted Starlark thread and stores its trusted execution state. func newThread(state *executionState, maxSteps uint64) *starlark.Thread { thread := &starlark.Thread{ @@ -185,10 +193,11 @@ func requireMain(globals starlark.StringDict) (*starlark.Function, error) { return mainFunction, nil } -// callCapability validates, authorizes, dispatches, and converts one native invocation in order. +// callCapability binds one native invocation and forwards a fresh canonical map. func callCapability( thread *starlark.Thread, - entry catalog.Entry, + id string, + input []binding.FieldShape, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { @@ -202,86 +211,14 @@ func callCapability( if counterErr := state.nativeCalls.increment(); counterErr != nil { return nil, counterErr } - input, canonical, bindingErr := entry.Plan.Bind(args, kwargs) + canonical, bindingErr := binding.BindShape(input, args, kwargs) if bindingErr != nil { if errors.Is(bindingErr, binding.ErrInvalidArguments) { return nil, fmt.Errorf("%w: %w", ErrInvalidArguments, bindingErr) } return nil, fmt.Errorf("%w: %w", ErrInternal, bindingErr) } - if contextErr := contextFailure(state.ctx); contextErr != nil { - return nil, contextErr - } - if policyErr := authorize(state, entry, canonical); policyErr != nil { - return nil, policyErr - } - if contextErr := contextFailure(state.ctx); contextErr != nil { - return nil, contextErr - } - output, invocationErr := invoke(state, entry, input) - if invocationErr != nil { - return nil, invocationErr - } - converted, conversionErr := entry.Plan.ConvertOutput(output) - if conversionErr != nil { - return nil, fmt.Errorf("%w: %w", ErrCapabilityFailure, conversionErr) - } - return converted, nil -} - -// authorize calls policy once and converts denial, error, and panic to safe internal classes. -func authorize(state *executionState, entry catalog.Entry, arguments map[string]any) (err error) { - defer func() { - if recover() != nil { - err = ErrPolicyFailure - } - }() - err = state.authorizer.Authorize(state.ctx, authz.AuthorizationInput{ - Subject: state.subject, - CapabilityID: entry.ID, - CapabilityName: entry.Name, - Arguments: arguments, - }) - if err == nil { - return nil - } - if errors.Is(err, authz.ErrDenied) { - return fmt.Errorf("%w: %w", ErrPermissionDenied, err) - } - return fmt.Errorf("%w: %w", ErrPolicyFailure, err) -} - -// invocationOutcome carries one recovered native handler result without named returns. -type invocationOutcome struct { - // output is the handler's typed result. - output any - - // err is the handler or recovery failure. - err error -} - -// invoke calls one typed handler and recovers handler panics at the native boundary. -func invoke(state *executionState, entry catalog.Entry, input any) (any, error) { - outcome := invocationOutcome{} - func() { - defer func() { - if recover() != nil { - outcome.output = nil - outcome.err = errRecoveredHandlerPanic - } - }() - outcome.output, outcome.err = entry.Invoke(state.ctx, state.subject, input) - }() - if outcome.err == nil { - return outcome.output, nil - } - if errors.Is(outcome.err, errRecoveredHandlerPanic) { - return nil, ErrInternal - } - if errors.Is(outcome.err, catalog.ErrInputTypeMismatch) { - return nil, fmt.Errorf("%w: %w", ErrInternal, outcome.err) - } - return nil, fmt.Errorf("%w: %w", ErrCapabilityFailure, outcome.err) + return state.call(id, canonical) } // classifyRuntimeError prefers request and budget state before classifying evaluator causes. diff --git a/internal/execution/execute_test.go b/internal/execution/execute_test.go index d9f4fcd..4064779 100644 --- a/internal/execution/execute_test.go +++ b/internal/execution/execute_test.go @@ -7,28 +7,12 @@ import ( "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/meigma/codemode/authz" - authzmocks "github.com/meigma/codemode/authz/mocks" "github.com/meigma/codemode/internal/binding" - "github.com/meigma/codemode/internal/catalog" "github.com/meigma/codemode/internal/execution" ) -// testInput is the representative native capability input. -type testInput struct { - // Value is one required capability argument. - Value string `json:"value"` -} - -// testOutput is the representative native capability output. -type testOutput struct { - // Value is one capability result field. - Value string `json:"value"` -} - // testExecutionResult carries one asynchronous execution outcome. type testExecutionResult struct { // value is main's converted final value. @@ -38,13 +22,82 @@ type testExecutionResult struct { err error } +// TestNewRejectsInvalidBindings proves Engine construction fails closed. +func TestNewRejectsInvalidBindings(t *testing.T) { + valid := lookupBinding() + tests := []struct { + // name identifies the invalid binding set. + name string + + // bindings are presented to New. + bindings []execution.CapabilityBinding + }{ + {name: "empty ID", bindings: []execution.CapabilityBinding{withID(valid, "")}}, + {name: "whitespace ID", bindings: []execution.CapabilityBinding{withID(valid, " cap.lookup")}}, + {name: "undotted name", bindings: []execution.CapabilityBinding{withName(valid, "records")}}, + {name: "invalid name", bindings: []execution.CapabilityBinding{withName(valid, "records.not-valid")}}, + {name: "reserved name segment", bindings: []execution.CapabilityBinding{withName(valid, "records.import")}}, + {name: "duplicate ID", bindings: []execution.CapabilityBinding{ + valid, + withName(withID(valid, "cap.lookup"), "records.other"), + }}, + {name: "duplicate name", bindings: []execution.CapabilityBinding{ + valid, + withID(valid, "cap.other"), + }}, + {name: "namespace collision", bindings: []execution.CapabilityBinding{ + valid, + { + ID: "cap.detail", + Name: "records.lookup.detail", + Input: []binding.FieldShape{ + {Name: "value", Type: "str", Required: true}, + }, + }, + }}, + {name: "invalid input shape", bindings: []execution.CapabilityBinding{ + { + ID: "cap.lookup", + Name: "records.lookup", + Input: []binding.FieldShape{ + {Name: "value", Type: "unsupported", Required: true}, + }, + }, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := execution.New(tt.bindings) + + require.ErrorIs(t, err, execution.ErrInternal) + }) + } +} + +// TestNewCopiesCallerBindings proves Engine construction does not retain caller slices. +func TestNewCopiesCallerBindings(t *testing.T) { + capability := lookupBinding() + engine, err := execution.New([]execution.CapabilityBinding{capability}) + require.NoError(t, err) + capability.Input[0].Name = "mutated" + + result, err := engine.Execute( + t.Context(), + `def main(): return records.lookup(value="alpha")`, + echoNativeCall(), + defaultExecutionLimits(), + ) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"value": "alpha"}, result) +} + // TestExecuteRequiresExactMainAndRejectsLoadingCalls proves entrypoint and phase validation precede side effects. func TestExecuteRequiresExactMainAndRejectsLoadingCalls(t *testing.T) { - var handlerCalls atomic.Int64 - capabilityCatalog := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - handlerCalls.Add(1) - return testOutput{}, nil - }) + engine := buildEngine(t) + var nativeCalls atomic.Int64 + nativeCall := countingNativeCall(&nativeCalls) tests := []struct { // name identifies the invalid program shape. name string @@ -70,170 +123,88 @@ def main(): for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - tt.source, - authz.AllowAll(), - defaultExecutionLimits(), - ) + _, err := engine.Execute(t.Context(), tt.source, nativeCall, defaultExecutionLimits()) require.ErrorIs(t, err, execution.ErrInvalidProgram) }) } - assert.Zero(t, handlerCalls.Load()) + assert.Zero(t, nativeCalls.Load()) } -// TestExecuteBindsAuthorizesThenDispatches proves canonical validation and policy ordering. -func TestExecuteBindsAuthorizesThenDispatches(t *testing.T) { - authorizer := authzmocks.NewMockAuthorizer(t) - events := make([]string, 0, 2) - authorizer.EXPECT().Authorize(mock.Anything, mock.MatchedBy(func(input authz.AuthorizationInput) bool { - return input.Subject.ID == "subject-1" && - input.CapabilityID == "cap.lookup" && - input.CapabilityName == "records.lookup" && - assert.ObjectsAreEqual(map[string]any{"value": "alpha"}, input.Arguments) - })).Run(func(context.Context, authz.AuthorizationInput) { - events = append(events, "authorize") - }).Return(nil).Once() - capabilityCatalog := buildEngine(t, func(_ context.Context, _ authz.Subject, input any) (any, error) { - events = append(events, "handler") - typed, ok := input.(testInput) - if !ok { - return nil, catalog.ErrInputTypeMismatch - } - return testOutput(typed), nil - }) - - result, err := capabilityCatalog.Execute( +// TestExecuteBindsThenDispatches proves canonical validation precedes the native port. +func TestExecuteBindsThenDispatches(t *testing.T) { + var gotID string + var gotArguments map[string]any + result, err := buildEngine(t).Execute( t.Context(), - authz.Subject{ID: "subject-1"}, `def main(): return records.lookup(value="alpha")`, - authorizer, + func(id string, arguments map[string]any) (any, error) { + gotID = id + gotArguments = arguments + return map[string]any{"value": "alpha"}, nil + }, defaultExecutionLimits(), ) require.NoError(t, err) - assert.Equal(t, []string{"authorize", "handler"}, events) + assert.Equal(t, "cap.lookup", gotID) + assert.Equal(t, map[string]any{"value": "alpha"}, gotArguments) assert.Equal(t, map[string]any{"value": "alpha"}, result) } -// TestExecuteRejectsMalformedArgumentsBeforePolicy proves invalid calls reach neither policy nor handler. -func TestExecuteRejectsMalformedArgumentsBeforePolicy(t *testing.T) { - authorizer := authzmocks.NewMockAuthorizer(t) - var handlerCalls atomic.Int64 - capabilityCatalog := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - handlerCalls.Add(1) - return testOutput{}, nil - }) - - _, err := capabilityCatalog.Execute( +// TestExecuteRejectsMalformedArgumentsBeforeNativeCall proves invalid calls never reach the native port. +func TestExecuteRejectsMalformedArgumentsBeforeNativeCall(t *testing.T) { + var nativeCalls atomic.Int64 + _, err := buildEngine(t).Execute( t.Context(), - authz.Subject{ID: "subject-1"}, `def main(): return records.lookup()`, - authorizer, + countingNativeCall(&nativeCalls), defaultExecutionLimits(), ) require.ErrorIs(t, err, execution.ErrInvalidArguments) - assert.Zero(t, handlerCalls.Load()) + assert.Zero(t, nativeCalls.Load()) } -// TestExecuteRejectsDuplicateKeywordSyntaxAsInvalidProgram proves repeated keywords fail at parse time before policy. +// TestExecuteRejectsDuplicateKeywordSyntaxAsInvalidProgram proves repeated keywords fail at parse time. func TestExecuteRejectsDuplicateKeywordSyntaxAsInvalidProgram(t *testing.T) { - authorizer := authzmocks.NewMockAuthorizer(t) - var handlerCalls atomic.Int64 - capabilityCatalog := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - handlerCalls.Add(1) - return testOutput{}, nil - }) - - _, err := capabilityCatalog.Execute( + var nativeCalls atomic.Int64 + _, err := buildEngine(t).Execute( t.Context(), - authz.Subject{ID: "subject-1"}, `def main(): return records.lookup(value="alpha", value="beta")`, - authorizer, + countingNativeCall(&nativeCalls), defaultExecutionLimits(), ) require.ErrorIs(t, err, execution.ErrInvalidProgram) - assert.Zero(t, handlerCalls.Load()) + assert.Zero(t, nativeCalls.Load()) } -// TestExecuteCancellationAfterAuthorizationPreventsDispatch proves a stale allow cannot cross the handler boundary. -func TestExecuteCancellationAfterAuthorizationPreventsDispatch(t *testing.T) { - tests := []struct { - // name identifies the cancellation source. - name string - - // newContext creates the execution context, cleanup, and cancellation trigger. - newContext func(*testing.T) (context.Context, context.CancelFunc, func()) +// TestExecuteCancellationBetweenNativeCallsPreventsDispatch proves a live cancel cannot start a later native call. +func TestExecuteCancellationBetweenNativeCallsPreventsDispatch(t *testing.T) { + engine := buildEngine(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var nativeCalls atomic.Int64 - // targets are the required execution error classifications. - targets []error - }{ - { - name: "request cancellation", - newContext: func(t *testing.T) (context.Context, context.CancelFunc, func()) { - ctx, cancel := context.WithCancel(t.Context()) - return ctx, cancel, cancel - }, - targets: []error{context.Canceled}, - }, - { - name: "elapsed deadline", - newContext: func(t *testing.T) (context.Context, context.CancelFunc, func()) { - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - return ctx, cancel, func() { - <-ctx.Done() - } - }, - targets: []error{execution.ErrResourceLimit, context.DeadlineExceeded}, + value, err := engine.Execute( + ctx, + ` +def main(): + records.lookup(value="first") + return records.lookup(value="second") +`, + func(string, map[string]any) (any, error) { + nativeCalls.Add(1) + cancel() + return map[string]any{"value": "first"}, nil }, - } + defaultExecutionLimits(), + ) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cancel, trigger := tt.newContext(t) - defer cancel() - authorizationStarted := make(chan struct{}) - releaseAuthorization := make(chan struct{}) - authorizer := authzmocks.NewMockAuthorizer(t) - authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Run( - func(context.Context, authz.AuthorizationInput) { - close(authorizationStarted) - <-releaseAuthorization - }, - ).Return(nil).Once() - var handlerCalls atomic.Int64 - engine := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - handlerCalls.Add(1) - return testOutput{}, nil - }) - result := make(chan testExecutionResult, 1) - go func() { - value, err := engine.Execute( - ctx, - authz.Subject{ID: "subject-1"}, - `def main(): return records.lookup(value="alpha")`, - authorizer, - defaultExecutionLimits(), - ) - result <- testExecutionResult{value: value, err: err} - }() - - <-authorizationStarted - trigger() - close(releaseAuthorization) - outcome := <-result - - assert.Nil(t, outcome.value) - for _, target := range tt.targets { - require.ErrorIs(t, outcome.err, target) - } - assert.Zero(t, handlerCalls.Load()) - }) - } + assert.Nil(t, value) + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, int64(1), nativeCalls.Load()) } // TestExecuteCancelsInFlightStarlark proves the watcher interrupts evaluation after execution begins. @@ -241,12 +212,7 @@ func TestExecuteCancelsInFlightStarlark(t *testing.T) { handlerStarted := make(chan struct{}) releaseHandler := make(chan struct{}) handlerReturned := make(chan struct{}) - engine := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - close(handlerStarted) - <-releaseHandler - close(handlerReturned) - return testOutput{}, nil - }) + engine := buildEngine(t) ctx, cancel := context.WithCancel(t.Context()) defer cancel() limits := defaultExecutionLimits() @@ -255,7 +221,6 @@ func TestExecuteCancelsInFlightStarlark(t *testing.T) { go func() { value, err := engine.Execute( ctx, - authz.Subject{ID: "subject-1"}, ` def main(): records.lookup(value="alpha") @@ -264,7 +229,12 @@ def main(): total += item return total `, - authz.AllowAll(), + func(string, map[string]any) (any, error) { + close(handlerStarted) + <-releaseHandler + close(handlerReturned) + return map[string]any{"value": "alpha"}, nil + }, limits, ) result <- testExecutionResult{value: value, err: err} @@ -282,13 +252,7 @@ def main(): // TestExecuteCreatesFreshStateAndReturnsOnlyMain proves executions share no globals or counters. func TestExecuteCreatesFreshStateAndReturnsOnlyMain(t *testing.T) { - capabilityCatalog := buildEngine(t, func(_ context.Context, _ authz.Subject, input any) (any, error) { - typed, ok := input.(testInput) - if !ok { - return nil, catalog.ErrInputTypeMismatch - } - return testOutput(typed), nil - }) + engine := buildEngine(t) const source = ` hidden = "discarded" print(hidden) @@ -298,21 +262,9 @@ def main(): limits := defaultExecutionLimits() limits.MaxNativeCalls = 1 - first, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - source, - authz.AllowAll(), - limits, - ) + first, err := engine.Execute(t.Context(), source, echoNativeCall(), limits) require.NoError(t, err) - second, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - source, - authz.AllowAll(), - limits, - ) + second, err := engine.Execute(t.Context(), source, echoNativeCall(), limits) require.NoError(t, err) assert.Equal(t, map[string]any{"value": "visible"}, first) @@ -321,65 +273,47 @@ def main(): // TestExecuteEnforcesEveryRuntimeBudget proves source, steps, calls, time, depth, and bytes fail safely. func TestExecuteEnforcesEveryRuntimeBudget(t *testing.T) { - var handlerCalls atomic.Int64 - capabilityCatalog := buildEngine(t, func(_ context.Context, _ authz.Subject, input any) (any, error) { - handlerCalls.Add(1) - typed, ok := input.(testInput) - if !ok { - return nil, catalog.ErrInputTypeMismatch - } - return testOutput(typed), nil - }) + engine := buildEngine(t) + var nativeCalls atomic.Int64 + countingCall := countingNativeCall(&nativeCalls) t.Run("source bytes", func(t *testing.T) { limits := defaultExecutionLimits() limits.MaxSourceBytes = 8 - _, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - `def main(): return None`, - authz.AllowAll(), - limits, - ) + _, err := engine.Execute(t.Context(), `def main(): return None`, countingCall, limits) require.ErrorIs(t, err, execution.ErrResourceLimit) }) t.Run("execution steps", func(t *testing.T) { limits := defaultExecutionLimits() limits.MaxExecutionSteps = 20 - _, err := capabilityCatalog.Execute(t.Context(), authz.Subject{ID: "subject-1"}, ` + _, err := engine.Execute(t.Context(), ` def main(): total = 0 for item in range(1000000): total += item return total -`, authz.AllowAll(), limits) +`, countingCall, limits) require.ErrorIs(t, err, execution.ErrResourceLimit) }) t.Run("native calls", func(t *testing.T) { - handlerCalls.Store(0) + nativeCalls.Store(0) limits := defaultExecutionLimits() limits.MaxNativeCalls = 1 - _, err := capabilityCatalog.Execute(t.Context(), authz.Subject{ID: "subject-1"}, ` + _, err := engine.Execute(t.Context(), ` def main(): records.lookup(value="first") return records.lookup(value="second") -`, authz.AllowAll(), limits) +`, countingCall, limits) require.ErrorIs(t, err, execution.ErrResourceLimit) - assert.Equal(t, int64(1), handlerCalls.Load()) + assert.Equal(t, int64(1), nativeCalls.Load()) }) t.Run("elapsed time", func(t *testing.T) { limits := defaultExecutionLimits() limits.MaxExecutionTime = time.Nanosecond - _, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - `def main(): return None`, - authz.AllowAll(), - limits, - ) + _, err := engine.Execute(t.Context(), `def main(): return None`, countingCall, limits) require.ErrorIs(t, err, execution.ErrResourceLimit) require.ErrorIs(t, err, context.DeadlineExceeded) }) @@ -387,24 +321,26 @@ def main(): t.Run("value depth", func(t *testing.T) { limits := defaultExecutionLimits() limits.MaxValueDepth = 2 - _, err := capabilityCatalog.Execute( - t.Context(), - authz.Subject{ID: "subject-1"}, - `def main(): return [[["deep"]]]`, - authz.AllowAll(), - limits, - ) + _, err := engine.Execute(t.Context(), `def main(): return [[["deep"]]]`, countingCall, limits) require.ErrorIs(t, err, execution.ErrResourceLimit) }) t.Run("result bytes", func(t *testing.T) { limits := defaultExecutionLimits() limits.MaxResultBytes = 4 - _, err := capabilityCatalog.Execute( + _, err := engine.Execute(t.Context(), `def main(): return "oversized"`, countingCall, limits) + require.ErrorIs(t, err, execution.ErrResourceLimit) + }) + + t.Run("native result depth", func(t *testing.T) { + limits := defaultExecutionLimits() + limits.MaxValueDepth = 2 + _, err := engine.Execute( t.Context(), - authz.Subject{ID: "subject-1"}, - `def main(): return "oversized"`, - authz.AllowAll(), + `def main(): return records.lookup(value="alpha")`, + func(string, map[string]any) (any, error) { + return []any{[]any{[]any{"deep"}}}, nil + }, limits, ) require.ErrorIs(t, err, execution.ErrResourceLimit) @@ -413,51 +349,146 @@ def main(): // TestExecutePreservesCancellationAndRejectsUnsupportedFinalValues proves terminal failures remain classified. func TestExecutePreservesCancellationAndRejectsUnsupportedFinalValues(t *testing.T) { - capabilityCatalog := buildEngine(t, func(context.Context, authz.Subject, any) (any, error) { - return testOutput{}, nil - }) + engine := buildEngine(t) ctx, cancel := context.WithCancel(t.Context()) cancel() - _, err := capabilityCatalog.Execute( - ctx, - authz.Subject{ID: "subject-1"}, - `def main(): return None`, - authz.AllowAll(), - defaultExecutionLimits(), - ) + _, err := engine.Execute(ctx, `def main(): return None`, echoNativeCall(), defaultExecutionLimits()) require.ErrorIs(t, err, context.Canceled) - _, err = capabilityCatalog.Execute( + _, err = engine.Execute( t.Context(), - authz.Subject{ID: "subject-1"}, `def main(): return main`, - authz.AllowAll(), + echoNativeCall(), defaultExecutionLimits(), ) require.ErrorIs(t, err, execution.ErrInvalidProgram) } -// buildEngine creates one immutable representative execution engine. -func buildEngine(t *testing.T, invoke catalog.Invoker) *execution.Engine { - t.Helper() - plan, err := binding.CompileFor[testInput, testOutput]() - require.NoError(t, err) - capabilityCatalog, err := catalog.Build([]catalog.Registration{ - { - ID: "cap.lookup", - Name: "records.lookup", - Summary: "Look up one record.", - Description: "Returns one record by value.", - Plan: plan, - Invoke: invoke, +// TestExecuteClassifiesNativePortErrors proves request-specific failures keep their public classes. +func TestExecuteClassifiesNativePortErrors(t *testing.T) { + tests := []struct { + // name identifies the classified native failure. + name string + + // result is the successful native value when err is nil. + result any + + // err is returned by the native port. + err error + + // target is the expected classified execution error. + target error + }{ + {name: "permission denied", err: execution.ErrPermissionDenied, target: execution.ErrPermissionDenied}, + {name: "policy failure", err: execution.ErrPolicyFailure, target: execution.ErrPolicyFailure}, + {name: "capability failure", err: execution.ErrCapabilityFailure, target: execution.ErrCapabilityFailure}, + {name: "internal failure", err: execution.ErrInternal, target: execution.ErrInternal}, + {name: "unsupported result", result: struct{ Value string }{Value: "alpha"}, target: execution.ErrInternal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildEngine(t).Execute( + t.Context(), + `def main(): return records.lookup(value="alpha")`, + func(string, map[string]any) (any, error) { + return tt.result, tt.err + }, + defaultExecutionLimits(), + ) + + require.ErrorIs(t, err, tt.target) + }) + } +} + +// TestExecuteRecoversNativeCallPanic proves a request-port panic stays inside the interpreter boundary. +func TestExecuteRecoversNativeCallPanic(t *testing.T) { + _, err := buildEngine(t).Execute( + t.Context(), + `def main(): return records.lookup(value="alpha")`, + func(string, map[string]any) (any, error) { + panic("native boom") + }, + defaultExecutionLimits(), + ) + + require.ErrorIs(t, err, execution.ErrInternal) +} + +// TestExecutePreservesSortedNativeDictionaryKeys proves ToStarlark insertion order is observable. +func TestExecutePreservesSortedNativeDictionaryKeys(t *testing.T) { + result, err := buildEngine(t).Execute( + t.Context(), + ` +def main(): + result = records.lookup(value="alpha") + keys = [] + for key in result: + keys.append(key) + return keys +`, + func(string, map[string]any) (any, error) { + return map[string]any{ + "zeta": int64(1), + "alpha": int64(2), + "mu": int64(3), + }, nil }, - }, catalog.Options{MaxSearchQueryBytes: 1024, MaxSearchResults: 10}) + defaultExecutionLimits(), + ) + require.NoError(t, err) - engine, err := execution.New(capabilityCatalog) + assert.Equal(t, []any{"alpha", "mu", "zeta"}, result) +} + +// buildEngine creates one immutable representative execution engine. +func buildEngine(t *testing.T) *execution.Engine { + t.Helper() + engine, err := execution.New([]execution.CapabilityBinding{lookupBinding()}) require.NoError(t, err) return engine } +// lookupBinding returns the representative records.lookup capability. +func lookupBinding() execution.CapabilityBinding { + return execution.CapabilityBinding{ + ID: "cap.lookup", + Name: "records.lookup", + Input: []binding.FieldShape{ + {Name: "value", Type: "str", Required: true}, + }, + } +} + +// withID returns a copy of capability with a replacement ID. +func withID(capability execution.CapabilityBinding, id string) execution.CapabilityBinding { + capability.ID = id + return capability +} + +// withName returns a copy of capability with a replacement dotted name. +func withName(capability execution.CapabilityBinding, name string) execution.CapabilityBinding { + capability.Name = name + return capability +} + +// echoNativeCall returns the bound value field as a normalized object. +func echoNativeCall() execution.NativeCall { + return func(_ string, arguments map[string]any) (any, error) { + return map[string]any{"value": arguments["value"]}, nil + } +} + +// countingNativeCall increments counter then echoes the bound value field. +func countingNativeCall(counter *atomic.Int64) execution.NativeCall { + echo := echoNativeCall() + return func(id string, arguments map[string]any) (any, error) { + counter.Add(1) + return echo(id, arguments) + } +} + // defaultExecutionLimits returns positive representative execution budgets. func defaultExecutionLimits() execution.Limits { return execution.Limits{ diff --git a/internal/execution/namespace.go b/internal/execution/namespace.go index 6a3b0c1..2a6bfe7 100644 --- a/internal/execution/namespace.go +++ b/internal/execution/namespace.go @@ -6,8 +6,6 @@ import ( "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" - - "github.com/meigma/codemode/internal/catalog" ) // namespaceNode is one temporary registration-time path used to assemble frozen modules. @@ -20,11 +18,12 @@ type namespaceNode struct { } // buildPredeclared creates the frozen capability namespace for one immutable Engine. -func buildPredeclared(capabilityCatalog *catalog.Catalog) (starlark.StringDict, error) { +func buildPredeclared(bindings []CapabilityBinding) (starlark.StringDict, error) { root := newNamespaceNode() - for _, namespaceBinding := range capabilityCatalog.NamespaceBindings() { + for _, capability := range bindings { + segments, function := splitCapabilityName(capability.Name) node := root - for _, segment := range namespaceBinding.Segments { + for _, segment := range segments { child, ok := node.children[segment] if !ok { child = newNamespaceNode() @@ -32,17 +31,19 @@ func buildPredeclared(capabilityCatalog *catalog.Catalog) (starlark.StringDict, } node = child } - if _, duplicate := node.functions[namespaceBinding.Function]; duplicate { + if _, duplicate := node.functions[function]; duplicate { return nil, fmt.Errorf("%w: duplicate namespace function", ErrInternal) } - entry := namespaceBinding.Capability - node.functions[namespaceBinding.Function] = starlark.NewBuiltin(entry.Name, func( + id := capability.ID + input := capability.Input + name := capability.Name + node.functions[function] = starlark.NewBuiltin(name, func( thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { - return callCapability(thread, entry, args, kwargs) + return callCapability(thread, id, input, args, kwargs) }) } diff --git a/internal/execution/phase.go b/internal/execution/phase.go index f2ecda7..210990f 100644 --- a/internal/execution/phase.go +++ b/internal/execution/phase.go @@ -1,10 +1,9 @@ package execution import ( - "context" "fmt" - "github.com/meigma/codemode/authz" + "go.starlark.net/starlark" ) // executionPhase identifies whether native capability calls are currently permitted. @@ -39,17 +38,11 @@ func (counter *checkedCounter) increment() error { return nil } +// nativeInvoker is a Starlark-ready native call that already converted a successful result. +type nativeInvoker func(id string, arguments map[string]any) (starlark.Value, error) + // executionState contains request-local trusted state unavailable to Starlark programs. type executionState struct { - // ctx carries request cancellation and the derived elapsed deadline. - ctx context.Context - - // subject is the trusted authenticated identity for every native call. - subject authz.Subject - - // authorizer decides each validated invocation immediately before dispatch. - authorizer authz.Authorizer - // phase guards the side-effectful native-call boundary. phase executionPhase @@ -58,6 +51,9 @@ type executionState struct { // stepLimited records interpreter cancellation caused by the configured step budget. stepLimited bool + + // call forwards a bound canonical map through the request-specific native port. + call nativeInvoker } // beginMain transitions exactly once from loading to running. diff --git a/server.go b/server.go index f5b74c0..f91089a 100644 --- a/server.go +++ b/server.go @@ -31,8 +31,8 @@ type Server struct { // engine owns the immutable precompiled native capability namespace. engine *execution.Engine - // authorizer is called once before each valid native handler dispatch. - authorizer authz.Authorizer + // dispatcher is the authoritative native-call owner for this server. + dispatcher *dispatcher // limits is the immutable execution budget configuration. limits execution.Limits @@ -44,14 +44,14 @@ func newServer( authorizer authz.Authorizer, limits Limits, ) (*Server, error) { - engine, err := execution.New(capabilityCatalog) + engine, err := execution.New(capabilityBindings(capabilityCatalog)) if err != nil { return nil, err } return &Server{ catalog: capabilityCatalog, engine: engine, - authorizer: authorizer, + dispatcher: newDispatcher(capabilityCatalog, authorizer), limits: execution.Limits{ MaxSourceBytes: limits.MaxSourceBytes, MaxExecutionSteps: limits.MaxExecutionSteps, @@ -63,6 +63,20 @@ func newServer( }, nil } +// capabilityBindings derives process-neutral engine bindings from enabled catalog entries. +func capabilityBindings(capabilityCatalog *catalog.Catalog) []execution.CapabilityBinding { + entries := capabilityCatalog.Entries() + bindings := make([]execution.CapabilityBinding, len(entries)) + for index, entry := range entries { + bindings[index] = execution.CapabilityBinding{ + ID: entry.ID, + Name: entry.Name, + Input: entry.Plan.InputShape(), + } + } + return bindings +} + // Search returns a bounded name-sorted scan of enabled capability names and summaries. func (server *Server) Search(query string) ([]SearchResult, error) { if server == nil || server.catalog == nil { @@ -95,7 +109,7 @@ func (server *Server) Describe(name CapabilityName) (Description, error) { // The elapsed budget cancels Starlark evaluation. Authorizer and Handler implementations must // honor ctx and return promptly; CodeMode does not detach or forcibly interrupt blocking Go code. func (server *Server) Execute(ctx context.Context, subject authz.Subject, program Program) (any, error) { - if server == nil || server.engine == nil || server.authorizer == nil { + if server == nil || server.engine == nil || server.dispatcher == nil { return nil, ErrInternal } if ctx == nil { @@ -104,7 +118,19 @@ func (server *Server) Execute(ctx context.Context, subject authz.Subject, progra if subject.ID == "" { return nil, ErrUnauthenticated } - result, err := server.engine.Execute(ctx, subject, string(program), server.authorizer, server.limits) + runCtx, cancel := context.WithTimeout(ctx, server.limits.MaxExecutionTime) + defer cancel() + if contextErr := contextFailure(runCtx); contextErr != nil { + return nil, projectExecutionError(contextErr) + } + result, err := server.engine.Execute( + runCtx, + string(program), + func(id string, args map[string]any) (any, error) { + return server.dispatcher.dispatch(runCtx, subject, id, args) + }, + server.limits, + ) if err != nil { return nil, projectExecutionError(err) } diff --git a/server_test.go b/server_test.go index 8b1a4c0..6ca564f 100644 --- a/server_test.go +++ b/server_test.go @@ -6,6 +6,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -411,6 +412,35 @@ func TestServerExecuteRequiresTrustedInvocationInputs(t *testing.T) { require.ErrorIs(t, err, context.DeadlineExceeded) } +// TestServerExecuteElapsedBudgetAfterAllowPreventsHandler proves only MaxExecutionTime +// can expire a blocked allow before the handler starts. +func TestServerExecuteElapsedBudgetAfterAllowPreventsHandler(t *testing.T) { + authorizer := authzmocks.NewMockAuthorizer(t) + var handlerCalls atomic.Int64 + authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Run( + func(ctx context.Context, _ authz.AuthorizationInput) { + <-ctx.Done() + }, + ).Return(nil).Once() + capability := validBuilderCapability("cap.lookup", "records.lookup") + capability.Handler = func(context.Context, authz.Subject, builderInput) (builderOutput, error) { + handlerCalls.Add(1) + return builderOutput{}, nil + } + limits := codemode.DefaultLimits() + limits.MaxExecutionTime = 20 * time.Millisecond + server := buildTestServer(t, authorizer, limits, capability) + + _, err := server.Execute(t.Context(), authz.Subject{ID: "subject-1"}, ` +def main(): + return records.lookup(value="alpha") +`) + + require.ErrorIs(t, err, codemode.ErrResourceLimit) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Zero(t, handlerCalls.Load()) +} + // TestServerSupportsConcurrentDiscoveryAndExecution proves immutable server state is race-safe for parallel reads. func TestServerSupportsConcurrentDiscoveryAndExecution(t *testing.T) { var handlerCalls atomic.Int64