diff --git a/internal/binding/doc.go b/internal/binding/doc.go index 9206d0d..67e2be4 100644 --- a/internal/binding/doc.go +++ b/internal/binding/doc.go @@ -1,2 +1,10 @@ -// Package binding compiles restricted Go input and output types into immutable Starlark conversion plans. +// Package binding compiles restricted Go input and output types into immutable +// conversion plans and owns process-neutral value conversion. +// +// Typed handler values, Starlark values, and JSON-shaped maps share one allowed +// matrix: nil, bool, string, int64, finite float64, []any, and map[string]any. +// ValidateValue, FromStarlark, and ToStarlark enforce that matrix plus positive +// depth and materialization limits. [json.Number] and other numeric types are +// rejected. Plan.InputShape remains the only descriptor source for compiled +// input fields. package binding diff --git a/internal/binding/input.go b/internal/binding/input.go index 3c2ce8d..9a2ce5e 100644 --- a/internal/binding/input.go +++ b/internal/binding/input.go @@ -54,14 +54,119 @@ func (plan *Plan) Bind(args starlark.Tuple, kwargs []starlark.Tuple) (any, map[s 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 +// omission both leave an optional key absent. Callers must not treat the result +// as the authoritative authorization map; parent re-binding constructs that. +func BindShape(fields []FieldShape, args starlark.Tuple, kwargs []starlark.Tuple) (map[string]any, error) { + if err := ValidateInputShape(fields); err != nil { + return nil, err + } + if len(args) != 0 { + return nil, fmt.Errorf("%w: positional arguments are not accepted", ErrInvalidArguments) + } + + byName := make(map[string]FieldShape, len(fields)) + for _, field := range fields { + byName[field.Name] = field + } + canonical := make(map[string]any, len(kwargs)) + seen := make(map[string]struct{}, len(kwargs)) + for _, keyword := range kwargs { + name, value, err := keywordArgument(keyword) + if err != nil { + return nil, err + } + field, known := byName[name] + if !known { + return nil, fmt.Errorf("%w: unknown argument %q", ErrInvalidArguments, name) + } + if _, duplicate := seen[name]; duplicate { + return nil, fmt.Errorf("%w: duplicate argument %q", ErrInvalidArguments, name) + } + seen[name] = struct{}{} + converted, omitted, err := convertShapeArgument(field, name, value) + if err != nil { + return nil, err + } + if !omitted { + canonical[name] = converted + } + } + for _, field := range fields { + if !field.Required { + continue + } + if _, ok := seen[field.Name]; !ok { + return nil, fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.Name) + } + } + return canonical, nil +} + +// BindValue reconstructs the exact registered Go input and a fresh canonical map. +// +// The decoded child map is never returned or retained. A present optional key +// with a nil value is treated as explicit None and omitted from the canonical +// map. Invalid normalized maps are classified with ErrInvalidArguments so +// Increment 2 can translate every BindValue failure to an internal/protocol +// error rather than a caller argument error. +func (plan *Plan) BindValue(arguments map[string]any) (any, map[string]any, error) { + if plan == nil { + return nil, nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) + } + + input := reflect.New(plan.inputType).Elem() + canonical := make(map[string]any, len(plan.inputFields)) + for _, field := range plan.inputFields { + value, present := arguments[field.name] + switch field.kind { + case fieldString: + if !present { + return nil, nil, fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.name) + } + converted, ok := value.(string) + if !ok { + return nil, nil, fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, field.name) + } + input.Field(field.index).SetString(converted) + canonical[field.name] = converted + case fieldOptionalInt64: + if !present || value == nil { + continue + } + converted, ok := value.(int64) + if !ok { + return nil, nil, fmt.Errorf( + "%w: argument %q must be an integer or None", + ErrInvalidArguments, + field.name, + ) + } + pointer := reflect.New(input.Field(field.index).Type().Elem()) + pointer.Elem().SetInt(converted) + input.Field(field.index).Set(pointer) + canonical[field.name] = converted + case fieldInt64, fieldBool, fieldFloat64: + return nil, nil, fmt.Errorf("%w: argument %q has an invalid compiled kind", ErrInvalidPlan, field.name) + default: + return nil, nil, fmt.Errorf("%w: argument %q has an unknown compiled kind", ErrInvalidPlan, field.name) + } + } + for name := range arguments { + if _, known := plan.inputByName[name]; !known { + return nil, nil, fmt.Errorf("%w: unknown argument %q", ErrInvalidArguments, name) + } + } + 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) { - if len(keyword) != keywordTupleSize { - return "", fmt.Errorf("%w: malformed keyword argument", ErrInvalidArguments) - } - name, ok := starlark.AsString(keyword[0]) - if !ok { - return "", fmt.Errorf("%w: keyword name must be a string", ErrInvalidArguments) + name, value, err := keywordArgument(keyword) + if err != nil { + return "", err } fieldIndex, known := plan.inputByName[name] if !known { @@ -69,7 +174,6 @@ func (plan *Plan) bindKeyword(input reflect.Value, canonical map[string]any, key } field := plan.inputFields[fieldIndex] - value := keyword[1] switch field.kind { case fieldString: converted, ok := starlark.AsString(value) @@ -112,3 +216,42 @@ func (plan *Plan) requireArguments(seen map[string]struct{}) error { } 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 { + return "", nil, fmt.Errorf("%w: malformed keyword argument", ErrInvalidArguments) + } + name, ok := starlark.AsString(keyword[0]) + if !ok { + return "", nil, fmt.Errorf("%w: keyword name must be a string", ErrInvalidArguments) + } + return name, keyword[1], nil +} + +// convertShapeArgument converts one Starlark argument against an exact field shape. +func convertShapeArgument(field FieldShape, name string, value starlark.Value) (any, bool, error) { + switch { + case isRequiredStringShape(field): + converted, ok := starlark.AsString(value) + if !ok { + return nil, false, fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, name) + } + return converted, false, nil + case isOptionalIntegerShape(field): + if value == starlark.None { + return nil, true, nil + } + integer, ok := value.(starlark.Int) + if !ok { + return nil, false, fmt.Errorf("%w: argument %q must be an integer or None", ErrInvalidArguments, name) + } + converted, ok := integer.Int64() + if !ok { + return nil, false, fmt.Errorf("%w: argument %q overflows int64", ErrInvalidArguments, name) + } + return converted, false, nil + default: + return nil, false, fmt.Errorf("%w: argument %q has an unsupported shape", ErrInvalidPlan, name) + } +} diff --git a/internal/binding/input_test.go b/internal/binding/input_test.go index c39935f..9af59a1 100644 --- a/internal/binding/input_test.go +++ b/internal/binding/input_test.go @@ -1,6 +1,7 @@ package binding import ( + "encoding/json" "math/big" "testing" @@ -141,6 +142,217 @@ func TestBindAsRejectsTypeDrift(t *testing.T) { 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]() + require.NoError(t, err) + limit := int64(25) + + tests := []struct { + // name identifies the supported call shape. + name string + + // kwargs contains the Starlark keyword arguments. + kwargs []starlark.Tuple + + // want is the exact registered input reconstructed by the parent. + want representativeInput + + // canonical is the fresh JSON-shaped authorization map. + canonical map[string]any + }{ + { + name: "required string and optional integer", + kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.MakeInt64(limit)), + }, + want: representativeInput{Org: "meigma", Limit: &limit}, + canonical: map[string]any{"org": "meigma", "limit": int64(25)}, + }, + { + name: "omitted optional integer", + kwargs: []starlark.Tuple{keyword("org", starlark.String("meigma"))}, + want: representativeInput{Org: "meigma"}, + canonical: map[string]any{"org": "meigma"}, + }, + { + name: "explicit None optional integer", + kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("limit", starlark.None), + }, + want: representativeInput{Org: "meigma"}, + canonical: map[string]any{"org": "meigma"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child, err := BindShape(plan.InputShape(), nil, tt.kwargs) + require.NoError(t, err) + assert.Equal(t, tt.canonical, child) + + bound, canonical, err := plan.BindValue(child) + require.NoError(t, err) + typed, ok := bound.(representativeInput) + require.True(t, ok, "parent must reconstruct the exact registered input type") + assert.Equal(t, tt.want, typed) + assert.Equal(t, tt.canonical, canonical) + child["org"] = "mutated-child" + assert.Equal(t, tt.canonical, canonical) + }) + } +} + +// TestBindShapeHandlesAnEmptyInputShape proves a zero-field manifest binds no arguments. +func TestBindShapeHandlesAnEmptyInputShape(t *testing.T) { + plan, err := CompileFor[struct{}, representativeOutput]() + require.NoError(t, err) + + child, err := BindShape(plan.InputShape(), nil, nil) + require.NoError(t, err) + assert.Empty(t, child) + + bound, canonical, err := plan.BindValue(child) + require.NoError(t, err) + assert.Equal(t, struct{}{}, bound) + assert.Empty(t, canonical) +} + +// TestBindValueReturnsFreshCanonicalMaps proves mutating the decoded child map cannot change parent results. +func TestBindValueReturnsFreshCanonicalMaps(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + decoded := map[string]any{"org": "meigma", "limit": int64(25)} + + bound, canonical, err := plan.BindValue(decoded) + require.NoError(t, err) + decoded["org"] = "mutated" + decoded["limit"] = int64(99) + canonical["org"] = "mutated-canonical" + + typed, ok := bound.(representativeInput) + require.True(t, ok) + require.NotNil(t, typed.Limit) + assert.Equal(t, "meigma", typed.Org) + assert.Equal(t, int64(25), *typed.Limit) + + _, second, err := plan.BindValue(map[string]any{"org": "meigma", "limit": int64(25)}) + require.NoError(t, err) + assert.Equal(t, map[string]any{"org": "meigma", "limit": int64(25)}, second) +} + +// TestBindShapeRejectsMalformedArguments proves child-side validation stays a caller argument error. +func TestBindShapeRejectsMalformedArguments(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 := BindShape(plan.InputShape(), tt.args, tt.kwargs) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidArguments) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// TestBindValueRejectsNormalizedWireMismatches proves parent re-binding never accepts a decoded child map as-is. +func TestBindValueRejectsNormalizedWireMismatches(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + + tests := []struct { + // name identifies the rejected decoded map. + name string + + // arguments is the decoded child map presented to the parent. + arguments map[string]any + + // contains is the expected safe diagnostic fragment. + contains string + }{ + {name: "missing required", arguments: map[string]any{}, contains: "missing required"}, + {name: "unknown", arguments: map[string]any{"org": "meigma", "other": "value"}, contains: "unknown"}, + {name: "mistyped required string", arguments: map[string]any{"org": int64(1)}, contains: "must be a string"}, + { + name: "float64 where int64 is required", + arguments: map[string]any{"org": "meigma", "limit": float64(25)}, + contains: "integer or None", + }, + { + name: "json.Number", + arguments: map[string]any{"org": "meigma", "limit": json.Number("25")}, + contains: "integer or None", + }, + { + name: "unsupported Go integer", + arguments: map[string]any{"org": "meigma", "limit": int(25)}, + contains: "integer or None", + }, + { + name: "unsupported Go unsigned integer", + arguments: map[string]any{"org": "meigma", "limit": uint64(25)}, + contains: "integer or None", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := plan.BindValue(tt.arguments) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidArguments) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + // keyword constructs one Starlark keyword tuple. func keyword(name string, value starlark.Value) starlark.Tuple { return starlark.Tuple{starlark.String(name), value} diff --git a/internal/binding/output.go b/internal/binding/output.go index 1c0a83f..efb9bb3 100644 --- a/internal/binding/output.go +++ b/internal/binding/output.go @@ -3,7 +3,6 @@ package binding import ( "encoding/json" "fmt" - "math" "reflect" "go.starlark.net/starlark" @@ -11,6 +10,26 @@ import ( // 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) { if plan == nil { return nil, fmt.Errorf("%w: nil plan", ErrInvalidPlan) } @@ -19,30 +38,23 @@ func (plan *Plan) ConvertOutput(output any) (starlark.Value, error) { return nil, fmt.Errorf("%w: handler output type does not match the compiled plan", ErrUnsupportedValue) } - converted := starlark.NewDict(len(plan.outputFields)) + converted := make(map[string]any, len(plan.outputFields)) for _, field := range plan.outputFields { item, err := convertOutputField(field, value.Field(field.index)) if err != nil { return nil, err } - if err := converted.SetKey(starlark.String(field.name), item); err != nil { - return nil, fmt.Errorf("%w: output field %q: %w", ErrUnsupportedValue, field.name, err) - } + converted[field.name] = item } return converted, nil } // ConvertFinal converts a final Starlark value to MCP-safe JSON data under depth and byte limits. +// +// Exact JSON byte measurement stays in this compatibility wrapper until the +// worker codec becomes the only encoded-size owner. func ConvertFinal(value starlark.Value, maxDepth int, maxBytes int) (any, error) { - if maxDepth <= 0 || maxBytes <= 0 { - return nil, fmt.Errorf("%w: conversion limits must be positive", ErrValueLimit) - } - converter := finalConverter{ - maxDepth: maxDepth, - remainingNodes: maxBytes, - active: make(map[visitKey]struct{}), - } - converted, err := converter.convert(value, 1) + converted, err := FromStarlark(value, maxDepth, maxBytes) if err != nil { return nil, err } @@ -56,209 +68,39 @@ func ConvertFinal(value starlark.Value, maxDepth int, maxBytes int) (any, error) return converted, nil } -// visitKey identifies an active pointer container during cycle detection. -type visitKey struct { - // kind distinguishes list and dictionary pointer identities. - kind byte - - // pointer is the active container's Go pointer identity. - pointer uintptr -} - -// finalConverter carries immutable depth configuration and path-local conversion state. -type finalConverter struct { - // maxDepth is the maximum accepted nesting depth. - maxDepth int - - // remainingNodes bounds materialization before exact JSON byte measurement. - remainingNodes int - - // active contains containers on the current recursive path. - active map[visitKey]struct{} -} - -// convert recursively converts one supported Starlark value. -func (converter *finalConverter) convert(value starlark.Value, depth int) (any, error) { - if isNilStarlarkValue(value) { - return nil, fmt.Errorf("%w: nil Starlark value", ErrUnsupportedValue) - } - if depth > converter.maxDepth { - return nil, fmt.Errorf("%w: value exceeds depth %d", ErrValueLimit, converter.maxDepth) - } - if converter.remainingNodes == 0 { - return nil, fmt.Errorf("%w: value exceeds byte-derived node budget", ErrValueLimit) - } - converter.remainingNodes-- - - switch value := value.(type) { - case starlark.NoneType: - return nil, nil //nolint:nilnil // Starlark None intentionally maps to JSON null. - case starlark.Bool: - return bool(value), nil - case starlark.String: - return string(value), nil - case starlark.Int: - return convertInteger(value) - case starlark.Float: - return convertFloat(value) - case starlark.Tuple: - return converter.convertTuple(value, depth) - case *starlark.List: - return converter.convertList(value, depth) - case *starlark.Dict: - return converter.convertDict(value, depth) - default: - return nil, fmt.Errorf("%w: Starlark type %s", ErrUnsupportedValue, value.Type()) - } -} - -// isNilStarlarkValue reports whether an interface contains no value or a typed nil pointer. -func isNilStarlarkValue(value starlark.Value) bool { - if value == nil { - return true - } - reflected := reflect.ValueOf(value) - return reflected.Kind() == reflect.Pointer && reflected.IsNil() -} - // convertOutputField converts one field according to its compiled output kind. -func convertOutputField(field outputField, value reflect.Value) (starlark.Value, error) { +func convertOutputField(field outputField, value reflect.Value) (any, error) { switch field.kind { case fieldString: - return starlark.String(value.String()), nil + return value.String(), nil case fieldInt64: - return starlark.MakeInt64(value.Int()), nil + return value.Int(), nil case fieldBool: - return starlark.Bool(value.Bool()), nil + return value.Bool(), nil case fieldFloat64: float := value.Float() - if math.IsNaN(float) || math.IsInf(float, 0) { + if !isFiniteFloat(float) { return nil, fmt.Errorf("%w: output field %q is not finite", ErrUnsupportedValue, field.name) } - return starlark.Float(float), nil + return float, nil case fieldOptionalInt64: return nil, fmt.Errorf("%w: output field %q has an invalid compiled kind", ErrInvalidPlan, field.name) } return nil, fmt.Errorf("%w: output field %q has an unknown compiled kind", ErrInvalidPlan, field.name) } -// convertInteger converts a Starlark integer when it fits the MCP signed integer surface. -func convertInteger(value starlark.Int) (any, error) { - integer, ok := value.Int64() - if !ok { - return nil, fmt.Errorf("%w: integer overflows int64", ErrUnsupportedValue) - } - return integer, nil -} - -// convertFloat converts a Starlark float when it is finite. -func convertFloat(value starlark.Float) (any, error) { - float := float64(value) - if math.IsNaN(float) || math.IsInf(float, 0) { - return nil, fmt.Errorf("%w: float is not finite", ErrUnsupportedValue) - } - return float, nil -} - -// containerLength returns length when that many child nodes fit the remaining budget. -func (converter *finalConverter) containerLength(length int) (int, error) { - if length > converter.remainingNodes { - return 0, fmt.Errorf("%w: value exceeds byte-derived node budget", ErrValueLimit) - } - return length, nil -} - -// convertTuple recursively converts an immutable sequence. -func (converter *finalConverter) convertTuple(value starlark.Tuple, depth int) ([]any, error) { - length, err := converter.containerLength(len(value)) - if err != nil { - return nil, err - } - items := make([]any, length) - for index, item := range value { - converted, err := converter.convert(item, depth+1) - if err != nil { - return nil, err - } - items[index] = converted - } - return items, nil -} - -// convertList recursively converts a mutable sequence while rejecting active cycles. -func (converter *finalConverter) convertList(value *starlark.List, depth int) ([]any, error) { - key, err := converter.enter('l', value) - if err != nil { - return nil, err - } - defer converter.leave(key) - - length, err := converter.containerLength(value.Len()) - if err != nil { - return nil, err - } - items := make([]any, 0, length) - iterator := value.Iterate() - defer iterator.Done() - var item starlark.Value - for iterator.Next(&item) { - converted, err := converter.convert(item, depth+1) - if err != nil { - return nil, err - } - items = append(items, converted) - } - return items, nil -} - -// convertDict recursively converts a mutable dictionary with string keys while rejecting active cycles. -func (converter *finalConverter) convertDict(value *starlark.Dict, depth int) (map[string]any, error) { - key, err := converter.enter('d', value) - if err != nil { - return nil, err - } - defer converter.leave(key) - - length, err := converter.containerLength(value.Len()) - if err != nil { - return nil, err - } - object := make(map[string]any, length) - iterator := value.Iterate() - defer iterator.Done() - var dictKey starlark.Value - for iterator.Next(&dictKey) { - name, ok := starlark.AsString(dictKey) - if !ok { - return nil, fmt.Errorf("%w: dictionary key must be a string", ErrUnsupportedValue) - } - item, found, err := value.Get(dictKey) - if err != nil { - return nil, fmt.Errorf("%w: dictionary lookup: %w", ErrUnsupportedValue, err) - } - if !found { - return nil, fmt.Errorf("%w: missing dictionary value", ErrUnsupportedValue) - } - converted, err := converter.convert(item, depth+1) - if err != nil { - return nil, err - } - object[name] = converted - } - return object, nil -} - -// enter adds a pointer container to the active recursion path. -func (converter *finalConverter) enter(kind byte, value any) (visitKey, error) { - key := visitKey{kind: kind, pointer: reflect.ValueOf(value).Pointer()} - if _, exists := converter.active[key]; exists { - return visitKey{}, fmt.Errorf("%w: cyclic value", ErrUnsupportedValue) +// 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) } - converter.active[key] = struct{}{} - return key, nil -} - -// leave removes a pointer container from the active recursion path. -func (converter *finalConverter) leave(key visitKey) { - delete(converter.active, key) } diff --git a/internal/binding/output_test.go b/internal/binding/output_test.go index ed6e04b..e1e243e 100644 --- a/internal/binding/output_test.go +++ b/internal/binding/output_test.go @@ -32,6 +32,25 @@ func TestConvertOutputUsesCompiledFields(t *testing.T) { }, converted) } +// TestConvertTypedOutputPreservesExactKinds proves handler output becomes process-neutral data without type erasure. +func TestConvertTypedOutputPreservesExactKinds(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) + require.NoError(t, err) + + assert.Equal(t, map[string]any{ + "name": "alpha", + "count": int64(3), + "active": true, + "score": 1.0, + }, converted) + assert.IsType(t, int64(0), converted["count"]) + assert.IsType(t, float64(0), converted["score"]) +} + // TestConvertOutputRejectsTypeDriftAndNonFiniteFloats proves outputs cannot bypass their compiled shape. func TestConvertOutputRejectsTypeDriftAndNonFiniteFloats(t *testing.T) { plan, err := CompileFor[representativeInput, representativeOutput]() diff --git a/internal/binding/signature.go b/internal/binding/signature.go index 164e21f..6bf47d5 100644 --- a/internal/binding/signature.go +++ b/internal/binding/signature.go @@ -1,8 +1,22 @@ package binding -import "strings" +import ( + "fmt" + "strings" +) -const unsupportedTypeSignature = "unsupported" +const ( + unsupportedTypeSignature = "unsupported" + + // stringType is the compact Starlark-facing notation for a string value. + stringType = "str" + + // integerType is the compact Starlark-facing notation for a signed integer. + integerType = "int" + + // optionalIntegerType is the exact InputShape notation for an optional integer. + optionalIntegerType = integerType + " | None" +) // FieldShape is one model-facing field in a supported capability input or output structure. type FieldShape struct { @@ -62,13 +76,51 @@ func (plan *Plan) OutputShape() []FieldShape { return shape } +// ValidateInputShape reports whether fields is a combination Plan.InputShape can produce. +// +// A required string is exactly Type "str" and Required true. An optional integer +// is exactly Type "int | None" and Required false. An empty shape is valid. +// Names must be unique Starlark identifiers. Any other pair, including the +// literal "unsupported" notation, is rejected. +func ValidateInputShape(fields []FieldShape) error { + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + if !ValidIdentifier(field.Name) { + return fmt.Errorf("%w: input field %q is not a Starlark identifier", ErrInvalidPlan, field.Name) + } + if _, exists := seen[field.Name]; exists { + return fmt.Errorf("%w: duplicate input name %q", ErrInvalidPlan, field.Name) + } + if !isSupportedInputShape(field) { + return fmt.Errorf("%w: input field %q has an unsupported shape", ErrInvalidPlan, field.Name) + } + seen[field.Name] = struct{}{} + } + return nil +} + +// isSupportedInputShape reports whether field is an exact compiled input pair. +func isSupportedInputShape(field FieldShape) bool { + return isRequiredStringShape(field) || isOptionalIntegerShape(field) +} + +// isRequiredStringShape reports whether field is a required string descriptor. +func isRequiredStringShape(field FieldShape) bool { + return field.Type == stringType && field.Required +} + +// isOptionalIntegerShape reports whether field is an optional integer descriptor. +func isOptionalIntegerShape(field FieldShape) bool { + return field.Type == optionalIntegerType && !field.Required +} + // inputKindSignature returns the model-facing notation for one supported input conversion. func inputKindSignature(kind fieldKind) string { switch kind { case fieldString: - return "str" + return stringType case fieldOptionalInt64: - return "int | None" + return optionalIntegerType case fieldInt64, fieldBool, fieldFloat64: return unsupportedTypeSignature } @@ -79,9 +131,9 @@ func inputKindSignature(kind fieldKind) string { func outputKindSignature(kind fieldKind) string { switch kind { case fieldString: - return "str" + return stringType case fieldInt64: - return "int" + return integerType case fieldBool: return "bool" case fieldFloat64: diff --git a/internal/binding/signature_test.go b/internal/binding/signature_test.go index c065c90..2871821 100644 --- a/internal/binding/signature_test.go +++ b/internal/binding/signature_test.go @@ -70,3 +70,123 @@ func TestSignatureOmitsHostOutputTypeNames(t *testing.T) { }) } } + +// TestValidateInputShapeAcceptsCompiledAndEmptyShapes proves only InputShape combinations are valid. +func TestValidateInputShapeAcceptsCompiledAndEmptyShapes(t *testing.T) { + plan, err := CompileFor[representativeInput, representativeOutput]() + require.NoError(t, err) + empty, err := CompileFor[struct{}, representativeOutput]() + require.NoError(t, err) + + tests := []struct { + // name identifies the accepted descriptor. + name string + + // fields is the candidate input shape. + fields []FieldShape + }{ + {name: "compiled representative", fields: plan.InputShape()}, + {name: "empty compiled shape", fields: empty.InputShape()}, + {name: "nil empty shape", fields: nil}, + { + name: "required string", + fields: []FieldShape{{ + Name: "org", + Type: "str", + Required: true, + }}, + }, + { + name: "optional integer", + fields: []FieldShape{{ + Name: "limit", + Type: "int | None", + Required: false, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, ValidateInputShape(tt.fields)) + }) + } +} + +// TestValidateInputShapeRejectsNonCompiledPairs proves the child cannot invent another descriptor matrix. +func TestValidateInputShapeRejectsNonCompiledPairs(t *testing.T) { + tests := []struct { + // name identifies the rejected descriptor. + name string + + // fields is the invalid input shape. + fields []FieldShape + + // contains is the expected diagnostic fragment. + contains string + }{ + { + name: "optional string Type/Required mismatch", + fields: []FieldShape{{ + Name: "org", + Type: "str", + Required: false, + }}, + contains: "unsupported shape", + }, + { + name: "required integer Type/Required mismatch", + fields: []FieldShape{{ + Name: "limit", + Type: "int | None", + Required: true, + }}, + contains: "unsupported shape", + }, + { + name: "output integer notation", + fields: []FieldShape{{ + Name: "count", + Type: "int", + Required: true, + }}, + contains: "unsupported shape", + }, + { + name: "unsupported notation", + fields: []FieldShape{{ + Name: "value", + Type: "unsupported", + Required: true, + }}, + contains: "unsupported shape", + }, + { + name: "duplicate name", + fields: []FieldShape{ + {Name: "org", Type: "str", Required: true}, + {Name: "org", Type: "int | None", Required: false}, + }, + contains: "duplicate input name", + }, + { + name: "invalid identifier", + fields: []FieldShape{{ + Name: "not-valid", + Type: "str", + Required: true, + }}, + contains: "Starlark identifier", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateInputShape(tt.fields) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPlan) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} diff --git a/internal/binding/value.go b/internal/binding/value.go new file mode 100644 index 0000000..d2715c2 --- /dev/null +++ b/internal/binding/value.go @@ -0,0 +1,446 @@ +package binding + +import ( + "encoding/json" + "fmt" + "maps" + "math" + "reflect" + "slices" + + "go.starlark.net/starlark" +) + +// ValidateValue reports whether value is a process-neutral MCP value within the supplied limits. +// +// Accepted values are nil, bool, string, int64, finite float64, []any, and +// map[string]any, recursively. [json.Number] and other numeric types are rejected. +// maxDepth and maxNodes must be positive. Active cycles are rejected. +func ValidateValue(value any, maxDepth int, maxNodes int) error { + converter, err := newValueConverter(maxDepth, maxNodes) + if err != nil { + return err + } + return converter.validate(value, 1) +} + +// FromStarlark converts a Starlark value into a process-neutral MCP value. +// +// maxDepth and maxNodes must be positive. Active cycles are rejected, and +// destination containers are allocated only after their child counts fit the +// remaining materialization budget. +func FromStarlark(value starlark.Value, maxDepth int, maxNodes int) (any, error) { + converter, err := newValueConverter(maxDepth, maxNodes) + if err != nil { + return nil, err + } + return converter.fromStarlark(value, 1) +} + +// ToStarlark converts a process-neutral MCP value into a Starlark value. +// +// maxDepth and maxNodes must be positive. Active cycles are rejected, and +// destination containers are allocated only after their child counts fit the +// remaining materialization budget. +func ToStarlark(value any, maxDepth int, maxNodes int) (starlark.Value, error) { + converter, err := newValueConverter(maxDepth, maxNodes) + if err != nil { + return nil, err + } + return converter.toStarlark(value, 1) +} + +// visitKey identifies an active pointer container during cycle detection. +type visitKey struct { + // kind distinguishes list, dictionary, slice, and map pointer identities. + kind byte + + // pointer is the active container's Go pointer identity. + pointer uintptr + + // length distinguishes visible slice ranges that share a backing array. + length int +} + +// valueConverter carries immutable depth configuration and path-local conversion state. +type valueConverter struct { + // maxDepth is the maximum accepted nesting depth. + maxDepth int + + // remainingNodes bounds materialization before exact JSON byte measurement. + remainingNodes int + + // active contains containers on the current recursive path. + active map[visitKey]struct{} +} + +// newValueConverter constructs conversion state after rejecting non-positive limits. +func newValueConverter(maxDepth int, maxNodes int) (valueConverter, error) { + if maxDepth <= 0 || maxNodes <= 0 { + return valueConverter{}, fmt.Errorf("%w: conversion limits must be positive", ErrValueLimit) + } + return valueConverter{ + maxDepth: maxDepth, + remainingNodes: maxNodes, + active: make(map[visitKey]struct{}), + }, nil +} + +// consumeNode accounts for one visited value against depth and materialization limits. +func (converter *valueConverter) consumeNode(depth int) error { + if depth > converter.maxDepth { + return fmt.Errorf("%w: value exceeds depth %d", ErrValueLimit, converter.maxDepth) + } + if converter.remainingNodes == 0 { + return fmt.Errorf("%w: value exceeds byte-derived node budget", ErrValueLimit) + } + converter.remainingNodes-- + return nil +} + +// containerLength returns length when that many child nodes fit the remaining budget. +func (converter *valueConverter) containerLength(length int) (int, error) { + if length > converter.remainingNodes { + return 0, fmt.Errorf("%w: value exceeds byte-derived node budget", ErrValueLimit) + } + return length, nil +} + +// enter adds a pointer container to the active recursion path. +func (converter *valueConverter) enter(kind byte, value any) (visitKey, error) { + return converter.enterKey(visitKey{kind: kind, pointer: reflect.ValueOf(value).Pointer()}) +} + +// leave removes a pointer container from the active recursion path. +func (converter *valueConverter) leave(key visitKey) { + delete(converter.active, key) +} + +// fromStarlark recursively converts one supported Starlark value. +func (converter *valueConverter) fromStarlark(value starlark.Value, depth int) (any, error) { + if isNilStarlarkValue(value) { + return nil, fmt.Errorf("%w: nil Starlark value", ErrUnsupportedValue) + } + if err := converter.consumeNode(depth); err != nil { + return nil, err + } + + switch value := value.(type) { + case starlark.NoneType: + return nil, nil //nolint:nilnil // Starlark None intentionally maps to JSON null. + case starlark.Bool: + return bool(value), nil + case starlark.String: + return string(value), nil + case starlark.Int: + return convertInteger(value) + case starlark.Float: + return convertFloat(value) + case starlark.Tuple: + return converter.fromStarlarkTuple(value, depth) + case *starlark.List: + return converter.fromStarlarkList(value, depth) + case *starlark.Dict: + return converter.fromStarlarkDict(value, depth) + default: + return nil, fmt.Errorf("%w: Starlark type %s", ErrUnsupportedValue, value.Type()) + } +} + +// isNilStarlarkValue reports whether an interface contains no value or a typed nil pointer. +func isNilStarlarkValue(value starlark.Value) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} + +// convertInteger converts a Starlark integer when it fits the MCP signed integer surface. +func convertInteger(value starlark.Int) (any, error) { + integer, ok := value.Int64() + if !ok { + return nil, fmt.Errorf("%w: integer overflows int64", ErrUnsupportedValue) + } + return integer, nil +} + +// convertFloat converts a Starlark float when it is finite. +func convertFloat(value starlark.Float) (any, error) { + float := float64(value) + if !isFiniteFloat(float) { + return nil, fmt.Errorf("%w: float is not finite", ErrUnsupportedValue) + } + return float, nil +} + +// isFiniteFloat reports whether value is a finite floating-point number. +func isFiniteFloat(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + +// fromStarlarkTuple recursively converts an immutable sequence. +func (converter *valueConverter) fromStarlarkTuple(value starlark.Tuple, depth int) ([]any, error) { + length, err := converter.containerLength(len(value)) + if err != nil { + return nil, err + } + items := make([]any, length) + for index, item := range value { + converted, err := converter.fromStarlark(item, depth+1) + if err != nil { + return nil, err + } + items[index] = converted + } + return items, nil +} + +// fromStarlarkList recursively converts a mutable sequence while rejecting active cycles. +func (converter *valueConverter) fromStarlarkList(value *starlark.List, depth int) ([]any, error) { + key, err := converter.enter('l', value) + if err != nil { + return nil, err + } + defer converter.leave(key) + + length, err := converter.containerLength(value.Len()) + if err != nil { + return nil, err + } + items := make([]any, 0, length) + iterator := value.Iterate() + defer iterator.Done() + var item starlark.Value + for iterator.Next(&item) { + converted, err := converter.fromStarlark(item, depth+1) + if err != nil { + return nil, err + } + items = append(items, converted) + } + return items, nil +} + +// fromStarlarkDict recursively converts a mutable dictionary with string keys while rejecting active cycles. +func (converter *valueConverter) fromStarlarkDict(value *starlark.Dict, depth int) (map[string]any, error) { + key, err := converter.enter('d', value) + if err != nil { + return nil, err + } + defer converter.leave(key) + + length, err := converter.containerLength(value.Len()) + if err != nil { + return nil, err + } + object := make(map[string]any, length) + iterator := value.Iterate() + defer iterator.Done() + var dictKey starlark.Value + for iterator.Next(&dictKey) { + name, ok := starlark.AsString(dictKey) + if !ok { + return nil, fmt.Errorf("%w: dictionary key must be a string", ErrUnsupportedValue) + } + item, found, err := value.Get(dictKey) + if err != nil { + return nil, fmt.Errorf("%w: dictionary lookup: %w", ErrUnsupportedValue, err) + } + if !found { + return nil, fmt.Errorf("%w: missing dictionary value", ErrUnsupportedValue) + } + converted, err := converter.fromStarlark(item, depth+1) + if err != nil { + return nil, err + } + object[name] = converted + } + return object, nil +} + +// validate recursively checks one process-neutral value. +func (converter *valueConverter) validate(value any, depth int) error { + if err := converter.consumeNode(depth); err != nil { + return err + } + switch typed := value.(type) { + case nil, bool, string, int64: + return nil + case float64: + if !isFiniteFloat(typed) { + return fmt.Errorf("%w: float is not finite", ErrUnsupportedValue) + } + return nil + case []any: + return converter.validateSlice(typed, depth) + case map[string]any: + return converter.validateMap(typed, depth) + case json.Number: + return fmt.Errorf("%w: Go type %T", ErrUnsupportedValue, typed) + default: + return fmt.Errorf("%w: Go type %T", ErrUnsupportedValue, value) + } +} + +// validateSlice recursively checks a process-neutral list while rejecting active cycles. +func (converter *valueConverter) validateSlice(value []any, depth int) error { + if err := converter.enterGoContainer('s', value); err != nil { + return err + } + defer converter.leaveGoContainer('s', value) + + if _, err := converter.containerLength(len(value)); err != nil { + return err + } + for _, item := range value { + if err := converter.validate(item, depth+1); err != nil { + return err + } + } + return nil +} + +// validateMap recursively checks a process-neutral object while rejecting active cycles. +func (converter *valueConverter) validateMap(value map[string]any, depth int) error { + if err := converter.enterGoContainer('m', value); err != nil { + return err + } + defer converter.leaveGoContainer('m', value) + + if _, err := converter.containerLength(len(value)); err != nil { + return err + } + for _, item := range value { + if err := converter.validate(item, depth+1); err != nil { + return err + } + } + return nil +} + +// toStarlark recursively converts one process-neutral value. +func (converter *valueConverter) toStarlark(value any, depth int) (starlark.Value, error) { + if err := converter.consumeNode(depth); err != nil { + return nil, err + } + switch typed := value.(type) { + case nil: + return starlark.None, nil + case bool: + return starlark.Bool(typed), nil + case string: + return starlark.String(typed), nil + case int64: + return starlark.MakeInt64(typed), nil + case float64: + if !isFiniteFloat(typed) { + return nil, fmt.Errorf("%w: float is not finite", ErrUnsupportedValue) + } + return starlark.Float(typed), nil + case []any: + return converter.toStarlarkList(typed, depth) + case map[string]any: + return converter.toStarlarkDict(typed, depth) + case json.Number: + return nil, fmt.Errorf("%w: Go type %T", ErrUnsupportedValue, typed) + default: + return nil, fmt.Errorf("%w: Go type %T", ErrUnsupportedValue, value) + } +} + +// toStarlarkList recursively converts a process-neutral list while rejecting active cycles. +func (converter *valueConverter) toStarlarkList(value []any, depth int) (starlark.Value, error) { + if err := converter.enterGoContainer('s', value); err != nil { + return nil, err + } + defer converter.leaveGoContainer('s', value) + + length, err := converter.containerLength(len(value)) + if err != nil { + return nil, err + } + items := make([]starlark.Value, length) + for index, item := range value { + converted, err := converter.toStarlark(item, depth+1) + if err != nil { + return nil, err + } + items[index] = converted + } + return starlark.NewList(items), nil +} + +// toStarlarkDict recursively converts a process-neutral object while rejecting active cycles. +func (converter *valueConverter) toStarlarkDict(value map[string]any, depth int) (starlark.Value, error) { + if err := converter.enterGoContainer('m', value); err != nil { + return nil, err + } + defer converter.leaveGoContainer('m', value) + + length, err := converter.containerLength(len(value)) + if err != nil { + return nil, err + } + object := starlark.NewDict(length) + for _, name := range slices.Sorted(maps.Keys(value)) { + converted, err := converter.toStarlark(value[name], depth+1) + if err != nil { + return nil, err + } + if err := object.SetKey(starlark.String(name), converted); err != nil { + return nil, fmt.Errorf("%w: dictionary key %q: %w", ErrUnsupportedValue, name, err) + } + } + return object, nil +} + +// enterGoContainer records a Go container on the active recursion path. +func (converter *valueConverter) enterGoContainer(kind byte, value any) error { + key, ok := goContainerKey(kind, value) + if !ok { + return nil + } + _, err := converter.enterKey(key) + return err +} + +// leaveGoContainer removes a Go container from the active recursion path. +func (converter *valueConverter) leaveGoContainer(kind byte, value any) { + key, ok := goContainerKey(kind, value) + if !ok { + return + } + converter.leave(key) +} + +// enterKey adds a prepared container identity to the active recursion path. +func (converter *valueConverter) enterKey(key visitKey) (visitKey, error) { + if _, exists := converter.active[key]; exists { + return visitKey{}, fmt.Errorf("%w: cyclic value", ErrUnsupportedValue) + } + converter.active[key] = struct{}{} + return key, nil +} + +// goContainerKey reports the active-path identity for a non-empty Go container. +func goContainerKey(kind byte, value any) (visitKey, bool) { + if value == nil { + return visitKey{}, false + } + reflected := reflect.ValueOf(value) + switch reflected.Kind() { //nolint:exhaustive // Only Slice and Map have supported container identity. + case reflect.Slice: + if reflected.IsNil() || reflected.Len() == 0 { + return visitKey{}, false + } + return visitKey{kind: kind, pointer: reflected.Pointer(), length: reflected.Len()}, true + case reflect.Map: + if reflected.IsNil() { + return visitKey{}, false + } + return visitKey{kind: kind, pointer: reflected.Pointer()}, true + default: + return visitKey{}, false + } +} diff --git a/internal/binding/value_test.go b/internal/binding/value_test.go new file mode 100644 index 0000000..2046a6f --- /dev/null +++ b/internal/binding/value_test.go @@ -0,0 +1,470 @@ +package binding + +import ( + "encoding/json" + "math" + "math/big" + "runtime" + "runtime/debug" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" +) + +// TestFromStarlarkAcceptsTheNeutralValueSurface proves every allowed Starlark category becomes a process-neutral value. +func TestFromStarlarkAcceptsTheNeutralValueSurface(t *testing.T) { + dictionary := starlark.NewDict(2) + require.NoError(t, dictionary.SetKey(starlark.String("flag"), starlark.True)) + require.NoError(t, dictionary.SetKey(starlark.String("none"), starlark.None)) + value := starlark.Tuple{ + starlark.String("text"), + starlark.MakeInt64(7), + starlark.Float(1.25), + starlark.NewList([]starlark.Value{starlark.False}), + dictionary, + } + + converted, err := FromStarlark(value, 4, 1024) + + require.NoError(t, err) + assert.Equal(t, []any{ + "text", + int64(7), + 1.25, + []any{false}, + map[string]any{"flag": true, "none": nil}, + }, converted) +} + +// TestToStarlarkPreservesNumericIdentity proves int64 and finite float64 survive both conversion directions. +func TestToStarlarkPreservesNumericIdentity(t *testing.T) { + tests := []struct { + // name identifies the numeric identity under test. + name string + + // value is the process-neutral source. + value any + }{ + {name: "minimum int64", value: int64(math.MinInt64)}, + {name: "maximum int64", value: int64(math.MaxInt64)}, + {name: "integral float", value: 1.0}, + {name: "fractional float", value: 1.25}, + {name: "nested numeric object", value: map[string]any{ + "count": int64(3), + "score": 1.0, + "items": []any{int64(0), 2.5}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + starlarkValue, err := ToStarlark(tt.value, 4, 1024) + require.NoError(t, err) + + converted, err := FromStarlark(starlarkValue, 4, 1024) + require.NoError(t, err) + assert.Equal(t, tt.value, converted) + assertNeutralTypes(t, converted) + }) + } +} + +// TestValidateValueAcceptsNeutralValues proves validation accepts the shared process-neutral matrix. +func TestValidateValueAcceptsNeutralValues(t *testing.T) { + tests := []struct { + // name identifies the accepted value. + name string + + // value is the process-neutral source. + value any + }{ + {name: "nil", value: nil}, + {name: "bool", value: true}, + {name: "string", value: "text"}, + {name: "int64", value: int64(7)}, + {name: "float64", value: 1.25}, + {name: "empty list", value: []any{}}, + {name: "empty object", value: map[string]any{}}, + {name: "nested values", value: []any{ + nil, + false, + "text", + int64(1), + 1.5, + []any{int64(2)}, + map[string]any{"inner": true}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, ValidateValue(tt.value, 4, 1024)) + }) + } +} + +// TestValueConversionRejectsUnsupportedValues proves executable, numeric, and unsafe values stay classified. +func TestValueConversionRejectsUnsupportedValues(t *testing.T) { + overflow := new(big.Int).Lsh(big.NewInt(1), 80) + nonStringKey := starlark.NewDict(1) + require.NoError(t, nonStringKey.SetKey(starlark.MakeInt(1), starlark.String("value"))) + var nilList *starlark.List + var nilDict *starlark.Dict + + starlarkTests := []struct { + // name identifies the unsafe Starlark value. + name string + + // value is the Starlark source presented for conversion. + value starlark.Value + }{ + {name: "integer overflow", value: starlark.MakeBigInt(overflow)}, + {name: "NaN", value: starlark.Float(math.NaN())}, + {name: "infinity", value: starlark.Float(math.Inf(1))}, + {name: "non-string dictionary key", value: nonStringKey}, + {name: "builtin", value: starlark.NewBuiltin("run", nil)}, + {name: "bytes", value: starlark.Bytes("secret")}, + {name: "nil interface", value: nil}, + {name: "typed nil list", value: nilList}, + {name: "typed nil dictionary", value: nilDict}, + } + + for _, tt := range starlarkTests { + t.Run("from "+tt.name, func(t *testing.T) { + _, err := FromStarlark(tt.value, 4, 1024) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + }) + } + + goTests := []struct { + // name identifies the unsafe Go value. + name string + + // value is the process-neutral impostor presented for conversion. + value any + }{ + {name: "json.Number", value: json.Number("1")}, + {name: "int", value: 1}, + {name: "uint64", value: uint64(1)}, + {name: "float32", value: float32(1)}, + {name: "NaN", value: math.NaN()}, + {name: "infinity", value: math.Inf(1)}, + {name: "string map", value: map[string]string{"key": "value"}}, + {name: "typed slice", value: []string{"value"}}, + } + + for _, tt := range goTests { + t.Run("validate "+tt.name, func(t *testing.T) { + err := ValidateValue(tt.value, 4, 1024) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + }) + t.Run("to "+tt.name, func(t *testing.T) { + _, err := ToStarlark(tt.value, 4, 1024) + + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + }) + } +} + +// TestValueConversionEnforcesPositiveDepthAndNodeLimits proves inclusive depth and node budgets are hard limits. +func TestValueConversionEnforcesPositiveDepthAndNodeLimits(t *testing.T) { + converted, err := FromStarlark(starlark.String("value"), 1, 1024) + require.NoError(t, err) + assert.Equal(t, "value", converted) + require.NoError(t, ValidateValue("value", 1, 1024)) + + converted, err = FromStarlark(starlark.None, 1, 1024) + require.NoError(t, err) + assert.Nil(t, converted) + require.NoError(t, ValidateValue(nil, 1, 1024)) + + shallow := starlark.NewList([]starlark.Value{starlark.String("value")}) + converted, err = FromStarlark(shallow, 2, 1024) + require.NoError(t, err) + assert.Equal(t, []any{"value"}, converted) + require.NoError(t, ValidateValue([]any{"value"}, 2, 1024)) + + deepStarlark := starlark.NewList([]starlark.Value{ + starlark.NewList([]starlark.Value{starlark.String("value")}), + }) + deepNeutral := []any{[]any{"value"}} + + _, err = FromStarlark(deepStarlark, 2, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "depth") + + err = ValidateValue(deepNeutral, 2, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "depth") + + _, err = ToStarlark(deepNeutral, 2, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "depth") + + _, err = FromStarlark(starlark.Tuple{starlark.String("a"), starlark.String("b")}, 2, 2) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "node budget") + + _, err = FromStarlark(starlark.None, 0, 10) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + + _, err = ToStarlark(nil, 1, 0) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) +} + +// TestValueConversionRejectsCycles proves recursive containers fail before destination materialization. +func TestValueConversionRejectsCycles(t *testing.T) { + list := starlark.NewList(nil) + require.NoError(t, list.Append(list)) + + _, err := FromStarlark(list, 10, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + assert.Contains(t, err.Error(), "cyclic") + + cyclicList := []any{nil} + cyclicList[0] = cyclicList + err = ValidateValue(cyclicList, 10, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + assert.Contains(t, err.Error(), "cyclic") + + _, err = ToStarlark(cyclicList, 10, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + assert.Contains(t, err.Error(), "cyclic") + + cyclicMap := map[string]any{} + cyclicMap["self"] = cyclicMap + err = ValidateValue(cyclicMap, 10, 1024) + require.Error(t, err) + require.ErrorIs(t, err, ErrUnsupportedValue) + assert.Contains(t, err.Error(), "cyclic") +} + +// TestValueConversionAcceptsNestedReslices proves shared backing arrays with different lengths are not cycles. +func TestValueConversionAcceptsNestedReslices(t *testing.T) { + parent := []any{"alpha", "beta", "gamma"} + nested := []any{parent[1:], parent[:2], []any{}} + + require.NoError(t, ValidateValue(nested, 4, 16)) + + converted, err := ToStarlark(nested, 4, 16) + require.NoError(t, err) + roundTrip, err := FromStarlark(converted, 4, 16) + require.NoError(t, err) + assert.Equal(t, []any{[]any{"beta", "gamma"}, []any{"alpha", "beta"}, []any{}}, roundTrip) +} + +// TestValueConversionBoundsSharedSubstructure proves a compact DAG cannot expand beyond the node budget. +func TestValueConversionBoundsSharedSubstructure(t *testing.T) { + var value starlark.Value = starlark.String("x") + for range 10 { + value = starlark.Tuple{value, value} + } + + _, err := FromStarlark(value, 16, 64) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "node budget") + + var nested any = "x" + for range 10 { + nested = []any{nested, nested} + } + err = ValidateValue(nested, 16, 64) + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "node budget") +} + +// TestToStarlarkInsertsDictionaryKeysInSortedOrder proves object conversion is deterministic. +func TestToStarlarkInsertsDictionaryKeysInSortedOrder(t *testing.T) { + converted, err := ToStarlark(map[string]any{ + "zeta": int64(1), + "alpha": int64(2), + "mu": int64(3), + }, 2, 8) + require.NoError(t, err) + + dictionary, ok := converted.(*starlark.Dict) + require.True(t, ok) + assert.Equal(t, []string{"alpha", "mu", "zeta"}, dictionaryKeys(t, dictionary)) +} + +// TestValueConversionRejectsOversizedContainersBeforeAllocation proves oversized +// sources are rejected before destination materialization. +func TestValueConversionRejectsOversizedContainersBeforeAllocation(t *testing.T) { + const ( + sourceLen = 128_000 + maxDepth = 4 + maxNodes = 1024 + maxAllocBytes = 1 << 20 + ) + + item := starlark.String("x") + tuple := make(starlark.Tuple, sourceLen) + listItems := make([]starlark.Value, sourceLen) + neutralList := make([]any, sourceLen) + neutralMap := make(map[string]any, sourceLen) + for index := range sourceLen { + tuple[index] = item + listItems[index] = item + neutralList[index] = "x" + neutralMap[strconv.Itoa(index)] = "x" + } + list := starlark.NewList(listItems) + + dictionary := starlark.NewDict(sourceLen) + for index := range sourceLen { + require.NoError(t, dictionary.SetKey(starlark.String(strconv.Itoa(index)), item)) + } + + fromTests := []struct { + // name identifies the oversized Starlark container kind. + name string + + // value is the prebuilt source presented for conversion. + value starlark.Value + }{ + {name: "tuple", value: tuple}, + {name: "list", value: list}, + {name: "dictionary", value: dictionary}, + } + + for _, tt := range fromTests { + t.Run("from "+tt.name, func(t *testing.T) { + allocated, err := measureFromStarlarkGrowth(t, tt.value, maxDepth, maxNodes) + + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "value exceeds byte-derived node budget") + assert.LessOrEqual( + t, + allocated, + uint64(maxAllocBytes), + "FromStarlark allocated %d bytes converting an oversized %s; destination materialization must not scale with source length %d", + allocated, + tt.name, + sourceLen, + ) + runtime.KeepAlive(tt.value) + }) + } + + toTests := []struct { + // name identifies the oversized Go container kind. + name string + + // value is the prebuilt source presented for conversion. + value any + }{ + {name: "list", value: neutralList}, + {name: "dictionary", value: neutralMap}, + } + + for _, tt := range toTests { + t.Run("to "+tt.name, func(t *testing.T) { + allocated, err := measureToStarlarkGrowth(t, tt.value, maxDepth, maxNodes) + + require.Error(t, err) + require.ErrorIs(t, err, ErrValueLimit) + assert.Contains(t, err.Error(), "value exceeds byte-derived node budget") + assert.LessOrEqual( + t, + allocated, + uint64(maxAllocBytes), + "ToStarlark allocated %d bytes converting an oversized %s; destination materialization must not scale with source length %d", + allocated, + tt.name, + sourceLen, + ) + runtime.KeepAlive(tt.value) + }) + } + + runtime.KeepAlive(fromTests) + runtime.KeepAlive(toTests) +} + +// measureFromStarlarkGrowth reports heap bytes allocated by FromStarlark. +func measureFromStarlarkGrowth(t *testing.T, value starlark.Value, maxDepth int, maxNodes int) (uint64, error) { + t.Helper() + + previousGCPercent := debug.SetGCPercent(-1) + defer debug.SetGCPercent(previousGCPercent) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + _, err := FromStarlark(value, maxDepth, maxNodes) + runtime.ReadMemStats(&after) + runtime.KeepAlive(value) + return after.TotalAlloc - before.TotalAlloc, err +} + +// measureToStarlarkGrowth reports heap bytes allocated by ToStarlark. +func measureToStarlarkGrowth(t *testing.T, value any, maxDepth int, maxNodes int) (uint64, error) { + t.Helper() + + previousGCPercent := debug.SetGCPercent(-1) + defer debug.SetGCPercent(previousGCPercent) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + _, err := ToStarlark(value, maxDepth, maxNodes) + runtime.ReadMemStats(&after) + runtime.KeepAlive(value) + return after.TotalAlloc - before.TotalAlloc, err +} + +// dictionaryKeys returns the Starlark dictionary's insertion-ordered keys. +func dictionaryKeys(t *testing.T, dictionary *starlark.Dict) []string { + t.Helper() + + keys := make([]string, 0, dictionary.Len()) + iterator := dictionary.Iterate() + defer iterator.Done() + var key starlark.Value + for iterator.Next(&key) { + name, ok := starlark.AsString(key) + require.True(t, ok) + keys = append(keys, name) + } + return keys +} + +// assertNeutralTypes proves converted values keep the shared process-neutral Go types. +func assertNeutralTypes(t *testing.T, value any) { + t.Helper() + + switch typed := value.(type) { + case nil, bool, string, int64: + return + case float64: + require.False(t, math.IsNaN(typed) || math.IsInf(typed, 0)) + case []any: + for _, item := range typed { + assertNeutralTypes(t, item) + } + case map[string]any: + for _, item := range typed { + assertNeutralTypes(t, item) + } + default: + require.Failf(t, "unexpected process-neutral type", "got %T", value) + } +}