From 430dd60b838f49c6056fbb2211e73a5919894ba0 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 20 Mar 2026 23:20:54 -0400 Subject: [PATCH] Harden runtime type validation system - Fix table? validation rejecting valid tables (Ref("table") unresolved from manifest, builtin type fallback added to resolveRuntimeType) - Add structured validation errors via *Error with Kind=Invalid and details map (field, expected, got, constraint) accessible from Lua through err:kind(), err:details(), err:message() - Fast-path :is() using zero-alloc validateValue on success, only computing error details on failure (2.6x speedup, 0 allocs on pass) - Handle all 32 typ.Type implementations in runtime validation: Recursive, Sum, Platform, Intersection, Ref, Annotated nil-inner - Nil-safe String()/Kind() on all compound types (Record, Map, Array, Tuple, Union, Intersection, Optional, Annotated) to prevent segfaults from corrupted manifests - Guard all reflection methods (elem, key, val, inner, ret, fields, variants, params) and type comparison against nil internals - Recursion depth limit (64) on validation to prevent stack overflow from malformed recursive types - Map validation checks Array part for number-keyed maps - Record validation checks MapComponent for records with map components - Type IO decoder hardening: maxSliceLen reduced to 64, depth limit 32, node budget 1024, missing checkSliceLen in readCondition - CI: add fuzz, race, and benchmark jobs 512 tests, 115M+ fuzz executions across 4 fuzzers (Lua source types, manifest types, corrupted type bytes, corrupted manifests), zero crashes. --- .github/workflows/ci.yml | 54 +- ltype.go | 525 ++++-- ltype_adversarial_test.go | 1668 ++++++++++++++++ ltype_bench_test.go | 517 +++++ ltype_edge_test.go | 3772 +++++++++++++++++++++++++++++++++++++ ltype_fuzz_test.go | 299 +++ ltype_validate.go | 34 +- types/io/predicates.go | 3 + types/io/reader.go | 12 + types/io/serialize.go | 2 +- types/typ/annotated.go | 12 +- types/typ/container.go | 24 +- types/typ/intersection.go | 6 +- types/typ/optional.go | 3 + types/typ/record.go | 18 +- types/typ/union.go | 6 +- value.go | 6 +- 17 files changed, 6832 insertions(+), 129 deletions(-) create mode 100644 ltype_adversarial_test.go create mode 100644 ltype_bench_test.go create mode 100644 ltype_edge_test.go create mode 100644 ltype_fuzz_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb8b613..d8664f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: cache: true - name: Run tests - run: go test ./... + run: go test -timeout 120s ./... race: name: Race Tests @@ -79,4 +79,54 @@ jobs: - name: Run race tests env: GORACE: "halt_on_error=1" - run: go test -race ./... + run: go test -race -timeout 120s ./... + + fuzz: + name: Fuzz + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GOCACHE: ${{ runner.temp }}/go-build + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.23.x" + cache: true + + - name: Fuzz type decode + run: go test -fuzz=FuzzTypeDecodeToValidation -fuzztime=60s -timeout=120s + + - name: Fuzz Lua source types + run: go test -fuzz=FuzzLuaTypeValidation -fuzztime=60s -timeout=120s + + - name: Fuzz Lua with manifest + run: go test -fuzz=FuzzLuaWithManifestTypes -fuzztime=60s -timeout=120s + + bench: + name: Benchmarks + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + GOCACHE: ${{ runner.temp }}/go-build + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.23.x" + cache: true + + - name: Run benchmarks + run: go test -bench="Benchmark(Validate|Is)" -benchmem -count=1 -timeout=60s | tee bench.txt + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: bench-results + path: bench.txt diff --git a/ltype.go b/ltype.go index f1586dc3..ae7eec20 100644 --- a/ltype.go +++ b/ltype.go @@ -115,11 +115,31 @@ func (lt *LType) Validate(L *LState, val LValue) bool { return validateValue(val, lt.inner, lt.resolver) } +// builtinRuntimeTypes maps well-known type names to their runtime type +// representations. Used as a fallback when the module's type resolver +// doesn't contain the referenced type (builtins are not in manifests). +var builtinRuntimeTypes = map[string]typ.Type{ + "nil": typ.Nil, + "boolean": typ.Boolean, + "number": typ.Number, + "integer": typ.Integer, + "string": typ.String, + "any": typ.Any, + "unknown": typ.Unknown, + "never": typ.Never, + "table": typ.NewInterface("table", nil), +} + func resolveRuntimeType(t typ.Type, resolver *typeResolver, depth int) typ.Type { if t == nil { return nil } - for t != nil && depth < 32 { + // Fast path: most types are not Alias or Ref + k := t.Kind() + if k != kind.Alias && k != kind.Ref { + return t + } + for t != nil && depth < 128 { switch tt := t.(type) { case *typ.Alias: if tt.Target == nil { @@ -127,17 +147,20 @@ func resolveRuntimeType(t typ.Type, resolver *typeResolver, depth int) typ.Type } t = tt.Target case *typ.Ref: - if resolver == nil { - return t - } - if tt.Module != "" && tt.Module != resolver.path { - return t + if resolver != nil && (tt.Module == "" || tt.Module == resolver.path) { + if target, ok := resolver.types[tt.Name]; ok && target != nil { + t = target + break + } } - if target, ok := resolver.types[tt.Name]; ok && target != nil { - t = target - } else { - return t + // Fallback to builtin types for well-known names + if tt.Module == "" { + if builtin, ok := builtinRuntimeTypes[tt.Name]; ok { + t = builtin + break + } } + return t default: return t } @@ -146,8 +169,17 @@ func resolveRuntimeType(t typ.Type, resolver *typeResolver, depth int) typ.Type return t } -// validateValue recursively checks if a Lua value matches a type. +// maxValidationDepth limits recursion for Recursive types and deeply nested structures. +const maxValidationDepth = 64 + func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { + return validateValueDepth(val, t, resolver, 0) +} + +func validateValueDepth(val LValue, t typ.Type, resolver *typeResolver, depth int) bool { + if depth > maxValidationDepth { + return false + } if t == nil { return false } @@ -156,7 +188,10 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { return false } if ann, ok := t.(*typ.Annotated); ok { - if !validateValue(val, ann.Inner, resolver) { + if ann.Inner == nil { + return false + } + if !validateValueDepth(val, ann.Inner, resolver, depth+1) { return false } return validateAnnotations(val, ann.Annotations, "") == nil @@ -192,10 +227,8 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { _, ok := val.(LString) return ok - case kind.Any: - return true - - case kind.Unknown: + case kind.Any, kind.Unknown, kind.Self: + // Self is a compile-time placeholder; at runtime treat as permissive return true case kind.Never: @@ -208,11 +241,11 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { if val == LNil { return true } - return validateValue(val, tt.Inner, resolver) + return validateValueDepth(val, tt.Inner, resolver, depth+1) case *typ.Union: for _, ut := range tt.Members { - if validateValue(val, ut, resolver) { + if validateValueDepth(val, ut, resolver, depth+1) { return true } } @@ -225,7 +258,7 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { } // Check array part for _, v := range tbl.Array { - if v != LNil && !validateValue(v, tt.Element, resolver) { + if v != LNil && !validateValueDepth(v, tt.Element, resolver, depth+1) { return false } } @@ -236,19 +269,28 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { if !ok { return false } - // Check dict part + if tt.Key == nil || tt.Value == nil { + return false + } for k, v := range tbl.Dict { - if !validateValue(k, tt.Key, resolver) { + if !validateValueDepth(k, tt.Key, resolver, depth+1) { return false } - if !validateValue(v, tt.Value, resolver) { + if !validateValueDepth(v, tt.Value, resolver, depth+1) { return false } } - // Check strdict part - if tt.Key.Kind() == kind.String { + if tt.Key != nil && tt.Key.Kind() == kind.String { for _, v := range tbl.Strdict { - if !validateValue(v, tt.Value, resolver) { + if !validateValueDepth(v, tt.Value, resolver, depth+1) { + return false + } + } + } + keyIsNum := tt.Key != nil && (tt.Key.Kind() == kind.Number || tt.Key.Kind() == kind.Integer || tt.Key.Kind() == kind.Any) + if keyIsNum && len(tbl.Array) > 0 { + for _, v := range tbl.Array { + if v != LNil && !validateValueDepth(v, tt.Value, resolver, depth+1) { return false } } @@ -271,10 +313,37 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { if fieldVal == LNil && field.Optional { continue } - if !validateValue(fieldVal, field.Type, resolver) { + if !validateValueDepth(fieldVal, field.Type, resolver, depth+1) { return false } } + if tt.HasMapComponent() { + for k, v := range tbl.Dict { + if !validateValueDepth(k, tt.MapKey, resolver, depth+1) { + return false + } + if !validateValueDepth(v, tt.MapValue, resolver, depth+1) { + return false + } + } + if tt.MapKey.Kind() == kind.String { + for k, v := range tbl.Strdict { + isField := false + for _, f := range tt.Fields { + if f.Name == k { + isField = true + break + } + } + if isField { + continue + } + if !validateValueDepth(v, tt.MapValue, resolver, depth+1) { + return false + } + } + } + } return true case *typ.Function: @@ -297,7 +366,7 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { } else { elemVal = LNil } - if !validateValue(elemVal, elemType, resolver) { + if !validateValueDepth(elemVal, elemType, resolver, depth+1) { return false } } @@ -331,143 +400,269 @@ func validateValue(val LValue, t typ.Type, resolver *typeResolver) bool { _, ok := val.(*LTable) return ok + case *typ.Intersection: + for _, member := range tt.Members { + if !validateValueDepth(val, member, resolver, depth+1) { + return false + } + } + return true + + case *typ.Recursive: + if tt.Body == nil { + return false + } + return validateValueDepth(val, tt.Body, resolver, depth+1) + + case *typ.Sum: + // Sum types are tagged unions. At runtime, a value is a table + // with a discriminant field. Accept any table. + _, ok := val.(*LTable) + return ok + + case *typ.Platform: + // Platform types represent opaque host types (userdata). + _, ok := val.(*LUserData) + return ok + case *typ.Generic: - // Generic types need to be instantiated before validation return false case *typ.Instantiated: - // Expand and validate expanded := subst.ExpandInstantiated(tt) if expanded != nil && expanded != tt { - return validateValue(val, expanded, resolver) + return validateValueDepth(val, expanded, resolver, depth+1) } return false + + case *typ.Ref: + return false } return false } -func validateWithErrorResolver(val LValue, t typ.Type, resolver *typeResolver, path string) (bool, string) { +// validationError holds structured validation failure information. +type validationError struct { + Field string // dot-separated field path (empty for root) + Expected string // expected type description + Got string // actual type description + Message string // human-readable error + Constraint string // annotation constraint name (empty for type mismatches) +} + +func (e *validationError) String() string { + return e.Message +} + +func newTypeError(path, expected, got string) *validationError { + msg := "expected " + expected + ", got " + got + if path != "" { + msg = path + ": " + msg + } + return &validationError{ + Field: path, + Expected: expected, + Got: got, + Message: msg, + } +} + +func newMissingFieldError(path, fieldName, expected string) *validationError { + fieldPath := fieldName + if path != "" { + fieldPath = path + "." + fieldName + } + msg := fieldPath + ": required field missing" + return &validationError{ + Field: fieldPath, + Expected: expected, + Got: "nil", + Message: msg, + } +} + +func newConstraintError(path, constraint, message string, got, expected string) *validationError { + msg := message + if path != "" { + msg = path + ": " + msg + } + return &validationError{ + Field: path, + Expected: expected, + Got: got, + Message: msg, + Constraint: constraint, + } +} + +func newUnresolvedError(path, typeName string) *validationError { + msg := "unresolved type " + typeName + if path != "" { + msg = path + ": " + msg + } + return &validationError{ + Field: path, + Expected: typeName, + Message: msg, + } +} + +func validateWithError(val LValue, t typ.Type, resolver *typeResolver, path string) (bool, *validationError) { + return validateWithErrorDepth(val, t, resolver, path, 0) +} + +func validateWithErrorDepth(val LValue, t typ.Type, resolver *typeResolver, path string, depth int) (bool, *validationError) { + if depth > maxValidationDepth { + return false, newTypeError(path, "type", "recursion limit exceeded") + } if t == nil { - return false, formatValidationError(path, "unknown", luaTypeName(val)) + return false, newTypeError(path, "unknown", luaTypeName(val)) } t = resolveRuntimeType(t, resolver, 0) if t == nil { - return false, formatValidationError(path, "unknown", luaTypeName(val)) + return false, newTypeError(path, "unknown", luaTypeName(val)) } if ann, ok := t.(*typ.Annotated); ok { - if ok, err := validateWithErrorResolver(val, ann.Inner, resolver, path); !ok { - return false, err + if ann.Inner == nil { + return false, newTypeError(path, "unknown", luaTypeName(val)) + } + if ok, verr := validateWithErrorDepth(val, ann.Inner, resolver, path, depth+1); !ok { + return false, verr } if annErr := validateAnnotations(val, ann.Annotations, path); annErr != nil { + p := path if annErr.Field != "" { - return false, annErr.Error() + p = annErr.Field + } + got := "" + if annErr.Got != nil { + got = fmt.Sprint(annErr.Got) } - return false, annErr.Message + expected := "" + if annErr.Expected != nil { + expected = fmt.Sprint(annErr.Expected) + } + return false, newConstraintError(p, annErr.Constraint, annErr.Message, got, expected) } - return true, "" + return true, nil } - typeName := t.String() switch t.Kind() { case kind.Nil: if val == LNil { - return true, "" + return true, nil } - return false, formatValidationError(path, "nil", luaTypeName(val)) + return false, newTypeError(path, "nil", luaTypeName(val)) case kind.Boolean: if _, ok := val.(LBool); ok { - return true, "" + return true, nil } - return false, formatValidationError(path, "boolean", luaTypeName(val)) + return false, newTypeError(path, "boolean", luaTypeName(val)) case kind.Number: switch val.(type) { case LNumber, LInteger: - return true, "" + return true, nil } - return false, formatValidationError(path, "number", luaTypeName(val)) + return false, newTypeError(path, "number", luaTypeName(val)) case kind.Integer: if _, ok := val.(LInteger); ok { - return true, "" + return true, nil } if n, ok := val.(LNumber); ok && IsIntegerValue(n) { - return true, "" + return true, nil } - return false, formatValidationError(path, "integer", luaTypeName(val)) + return false, newTypeError(path, "integer", luaTypeName(val)) case kind.String: if _, ok := val.(LString); ok { - return true, "" + return true, nil } - return false, formatValidationError(path, "string", luaTypeName(val)) + return false, newTypeError(path, "string", luaTypeName(val)) - case kind.Any, kind.Unknown: - return true, "" + case kind.Any, kind.Unknown, kind.Self: + return true, nil case kind.Never: - return false, formatValidationError(path, "never", luaTypeName(val)) + return false, newTypeError(path, "never", luaTypeName(val)) } + typeName := t.String() + switch tt := t.(type) { case *typ.Optional: if val == LNil { - return true, "" + return true, nil } - return validateWithErrorResolver(val, tt.Inner, resolver, path) + return validateWithErrorDepth(val, tt.Inner, resolver, path, depth+1) case *typ.Union: for _, ut := range tt.Members { - if ok, _ := validateWithErrorResolver(val, ut, resolver, path); ok { - return true, "" + if ok, _ := validateWithErrorDepth(val, ut, resolver, path, depth+1); ok { + return true, nil } } - return false, formatValidationError(path, typeName, luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) case *typ.Array: tbl, ok := val.(*LTable) if !ok { - return false, formatValidationError(path, "table", luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) } for i, v := range tbl.Array { if v != LNil { elemPath := formatPath(path, i+1) - if ok, err := validateWithErrorResolver(v, tt.Element, resolver, elemPath); !ok { - return false, err + if ok, verr := validateWithErrorDepth(v, tt.Element, resolver, elemPath, depth+1); !ok { + return false, verr } } } - return true, "" + return true, nil case *typ.Map: tbl, ok := val.(*LTable) if !ok { - return false, formatValidationError(path, "table", luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) + } + if tt.Key == nil || tt.Value == nil { + return false, newTypeError(path, typeName, luaTypeName(val)) } for k, v := range tbl.Dict { - if ok, _ := validateWithErrorResolver(k, tt.Key, resolver, path+"[key]"); !ok { - return false, formatValidationError(path+"[key]", tt.Key.String(), luaTypeName(k)) + if ok, _ := validateWithErrorDepth(k, tt.Key, resolver, path+"[key]", depth+1); !ok { + return false, newTypeError(path+"[key]", tt.Key.String(), luaTypeName(k)) } keyPath := formatPath(path, k) - if ok, err := validateWithErrorResolver(v, tt.Value, resolver, keyPath); !ok { - return false, err + if ok, verr := validateWithErrorDepth(v, tt.Value, resolver, keyPath, depth+1); !ok { + return false, verr } } - if tt.Key.Kind() == kind.String { + if tt.Key != nil && tt.Key.Kind() == kind.String { for k, v := range tbl.Strdict { keyPath := path + "." + k - if ok, err := validateWithErrorResolver(v, tt.Value, resolver, keyPath); !ok { - return false, err + if ok, verr := validateWithErrorDepth(v, tt.Value, resolver, keyPath, depth+1); !ok { + return false, verr + } + } + } + keyIsNum := tt.Key != nil && (tt.Key.Kind() == kind.Number || tt.Key.Kind() == kind.Integer || tt.Key.Kind() == kind.Any) + if keyIsNum && len(tbl.Array) > 0 { + for i, v := range tbl.Array { + if v != LNil { + elemPath := formatPath(path, i+1) + if ok, verr := validateWithErrorDepth(v, tt.Value, resolver, elemPath, depth+1); !ok { + return false, verr + } } } } - return true, "" + return true, nil case *typ.Record: tbl, ok := val.(*LTable) if !ok { - return false, formatValidationError(path, "table", luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) } for _, field := range tt.Fields { var fieldVal LValue @@ -484,23 +679,60 @@ func validateWithErrorResolver(val LValue, t typ.Type, resolver *typeResolver, p if path == "" { fieldPath = field.Name } - if ok, err := validateWithErrorResolver(fieldVal, field.Type, resolver, fieldPath); !ok { - return false, err + if fieldVal == LNil { + ft := "unknown" + if field.Type != nil { + ft = field.Type.String() + } + return false, newMissingFieldError(path, field.Name, ft) + } + if ok, verr := validateWithErrorDepth(fieldVal, field.Type, resolver, fieldPath, depth+1); !ok { + return false, verr + } + } + if tt.HasMapComponent() { + for k, v := range tbl.Dict { + if ok, _ := validateWithErrorDepth(k, tt.MapKey, resolver, path+"[key]", depth+1); !ok { + return false, newTypeError(path+"[key]", tt.MapKey.String(), luaTypeName(k)) + } + keyPath := formatPath(path, k) + if ok, verr := validateWithErrorDepth(v, tt.MapValue, resolver, keyPath, depth+1); !ok { + return false, verr + } + } + if tt.MapKey.Kind() == kind.String { + for k, v := range tbl.Strdict { + // Skip known record fields + isField := false + for _, f := range tt.Fields { + if f.Name == k { + isField = true + break + } + } + if isField { + continue + } + keyPath := path + "." + k + if ok, verr := validateWithErrorDepth(v, tt.MapValue, resolver, keyPath, depth+1); !ok { + return false, verr + } + } } } - return true, "" + return true, nil case *typ.Function: switch val.(type) { case *LFunction, LGoFunc: - return true, "" + return true, nil } - return false, formatValidationError(path, "function", luaTypeName(val)) + return false, newTypeError(path, "function", luaTypeName(val)) case *typ.Tuple: tbl, ok := val.(*LTable) if !ok { - return false, formatValidationError(path, "table", luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) } for i, elemType := range tt.Elements { var elemVal LValue @@ -510,51 +742,101 @@ func validateWithErrorResolver(val LValue, t typ.Type, resolver *typeResolver, p elemVal = LNil } elemPath := formatPath(path, i+1) - if ok, err := validateWithErrorResolver(elemVal, elemType, resolver, elemPath); !ok { - return false, err + if ok, verr := validateWithErrorDepth(elemVal, elemType, resolver, elemPath, depth+1); !ok { + return false, verr } } - return true, "" + return true, nil case *typ.Literal: switch lit := tt.Value.(type) { case string: if s, ok := val.(LString); ok && string(s) == lit { - return true, "" + return true, nil } case float64: if n, ok := val.(LNumber); ok && float64(n) == lit { - return true, "" + return true, nil } case int64: if i, ok := val.(LInteger); ok && int64(i) == lit { - return true, "" + return true, nil } if n, ok := val.(LNumber); ok && float64(n) == float64(lit) { - return true, "" + return true, nil } case bool: if b, ok := val.(LBool); ok && bool(b) == lit { - return true, "" + return true, nil } } - return false, formatValidationError(path, typeName, luaTypeName(val)) + return false, newTypeError(path, t.String(), luaTypeName(val)) case *typ.Interface: if _, ok := val.(*LTable); ok { - return true, "" + return true, nil } - return false, formatValidationError(path, "table", luaTypeName(val)) + return false, newTypeError(path, "table", luaTypeName(val)) + + case *typ.Intersection: + for _, member := range tt.Members { + if ok, verr := validateWithErrorDepth(val, member, resolver, path, depth+1); !ok { + return false, verr + } + } + return true, nil + + case *typ.Recursive: + if tt.Body == nil { + return false, newTypeError(path, typeName, luaTypeName(val)) + } + return validateWithErrorDepth(val, tt.Body, resolver, path, depth+1) + + case *typ.Sum: + if _, ok := val.(*LTable); ok { + return true, nil + } + return false, newTypeError(path, typeName, luaTypeName(val)) + + case *typ.Platform: + if _, ok := val.(*LUserData); ok { + return true, nil + } + return false, newTypeError(path, typeName, luaTypeName(val)) case *typ.Instantiated: expanded := subst.ExpandInstantiated(tt) if expanded != nil && expanded != tt { - return validateWithErrorResolver(val, expanded, resolver, path) + return validateWithErrorDepth(val, expanded, resolver, path, depth+1) } - return false, formatValidationError(path, typeName, luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) + + case *typ.Ref: + return false, newUnresolvedError(path, tt.Name) } - return false, formatValidationError(path, typeName, luaTypeName(val)) + return false, newTypeError(path, typeName, luaTypeName(val)) +} + +// toValidationLuaError converts a validationError into the proper *Error +// type with Kind=Invalid, structured details, and error metatable. +func toValidationLuaError(L *LState, verr *validationError) *Error { + details := make(map[string]any, 4) + if verr.Field != "" { + details["field"] = verr.Field + } + if verr.Expected != "" { + details["expected"] = verr.Expected + } + if verr.Got != "" { + details["got"] = verr.Got + } + if verr.Constraint != "" { + details["constraint"] = verr.Constraint + } + e := NewError(verr.Message).WithKind(Invalid).WithDetails(details) + SetErrorMetatable(L, e) + return e } func validateAnnotations(val LValue, annotations []typ.Annotation, path string) *validate.Error { @@ -574,13 +856,6 @@ func validateAnnotations(val LValue, annotations []typ.Annotation, path string) return nil } -func formatValidationError(path, expected, got string) string { - if path == "" { - return "expected " + expected + ", got " + got - } - return path + ": expected " + expected + ", got " + got -} - func formatPath(base string, key interface{}) string { switch k := key.(type) { case int: @@ -666,6 +941,9 @@ func (ls *LState) typeGetField(lt *LType, key string) LValue { if rec, ok := lt.inner.(*typ.Record); ok { for _, f := range rec.Fields { if f.Name == key { + if f.Type == nil { + return LNil + } return <ype{inner: f.Type} } } @@ -698,18 +976,22 @@ func typeMethodIs(L *LState, lt *LType) int { // Support both colon syntax Type:is(val) and dot syntax Type.is(val) idx := 1 if L.GetTop() >= 2 { - // Colon syntax: self at 1, value at 2 idx = 2 } val := L.Get(idx) - if ok, errMsg := validateWithErrorResolver(val, lt.inner, lt.resolver, ""); ok { - // Success: return (value, nil) + // Fast path: zero-alloc boolean check handles the common success case. + // Error details are only computed on failure. + if validateValue(val, lt.inner, lt.resolver) { L.Push(val) L.Push(LNil) } else { - // Failure: return (nil, error) + _, verr := validateWithError(val, lt.inner, lt.resolver, "") L.Push(LNil) - L.Push(LString(errMsg)) + if verr != nil { + L.Push(toValidationLuaError(L, verr)) + } else { + L.Push(toValidationLuaError(L, newTypeError("", lt.String(), luaTypeName(val)))) + } } return 2 } @@ -729,7 +1011,7 @@ func typeMethodName(L *LState, lt *LType) int { } func typeMethodElem(L *LState, lt *LType) int { - if arr, ok := lt.inner.(*typ.Array); ok { + if arr, ok := lt.inner.(*typ.Array); ok && arr.Element != nil { L.Push(<ype{inner: arr.Element}) return 1 } @@ -738,7 +1020,7 @@ func typeMethodElem(L *LState, lt *LType) int { } func typeMethodKey(L *LState, lt *LType) int { - if m, ok := lt.inner.(*typ.Map); ok { + if m, ok := lt.inner.(*typ.Map); ok && m.Key != nil { L.Push(<ype{inner: m.Key}) return 1 } @@ -747,7 +1029,7 @@ func typeMethodKey(L *LState, lt *LType) int { } func typeMethodVal(L *LState, lt *LType) int { - if m, ok := lt.inner.(*typ.Map); ok { + if m, ok := lt.inner.(*typ.Map); ok && m.Value != nil { L.Push(<ype{inner: m.Value}) return 1 } @@ -756,7 +1038,7 @@ func typeMethodVal(L *LState, lt *LType) int { } func typeMethodInner(L *LState, lt *LType) int { - if opt, ok := lt.inner.(*typ.Optional); ok { + if opt, ok := lt.inner.(*typ.Optional); ok && opt.Inner != nil { L.Push(<ype{inner: opt.Inner}) return 1 } @@ -766,12 +1048,11 @@ func typeMethodInner(L *LState, lt *LType) int { func typeMethodRet(L *LState, lt *LType) int { if fn, ok := lt.inner.(*typ.Function); ok { - if len(fn.Returns) == 1 { + if len(fn.Returns) == 1 && fn.Returns[0] != nil { L.Push(<ype{inner: fn.Returns[0]}) return 1 } if len(fn.Returns) > 1 { - // Return tuple type L.Push(<ype{inner: typ.NewTuple(fn.Returns...)}) return 1 } @@ -796,7 +1077,11 @@ func typeMethodFields(L *LState, lt *LType) int { field := rec.Fields[idx] idx++ L.Push(LString(field.Name)) - L.Push(<ype{inner: field.Type}) + if field.Type != nil { + L.Push(<ype{inner: field.Type}) + } else { + L.Push(LNil) + } return 2 }) @@ -819,7 +1104,11 @@ func typeMethodVariants(L *LState, lt *LType) int { } variant := union.Members[idx] idx++ - L.Push(<ype{inner: variant}) + if variant != nil { + L.Push(<ype{inner: variant}) + } else { + L.Push(LNil) + } return 1 }) @@ -842,7 +1131,11 @@ func typeMethodParams(L *LState, lt *LType) int { } param := fn.Params[idx] idx++ - L.Push(<ype{inner: param.Type}) + if param.Type != nil { + L.Push(<ype{inner: param.Type}) + } else { + L.Push(LNil) + } return 1 }) @@ -960,11 +1253,17 @@ func (ls *LState) typeCall(lt *LType, base, nargs, nret int) { // TypeEquals checks structural equality of two types. func TypeEquals(a, b *LType) bool { + if a.inner == nil || b.inner == nil { + return a.inner == nil && b.inner == nil + } return a.inner.Equals(b.inner) } // TypeIsSubtype checks if a is a subtype of b. func TypeIsSubtype(a, b *LType) bool { + if a.inner == nil || b.inner == nil { + return false + } return subtype.IsSubtype(a.inner, b.inner) } diff --git a/ltype_adversarial_test.go b/ltype_adversarial_test.go new file mode 100644 index 00000000..15926c41 --- /dev/null +++ b/ltype_adversarial_test.go @@ -0,0 +1,1668 @@ +package lua + +import ( + "fmt" + "math" + "strings" + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +// --------------------------------------------------------------------------- +// Adversarial: NaN, Inf, negative zero +// --------------------------------------------------------------------------- + +func TestAdversarial_NaN(t *testing.T) { + L := NewState() + defer L.Close() + + nan := LNumber(math.NaN()) + + // NaN should pass number type check (it IS a number) + if !LTypeNumber.Validate(L, nan) { + t.Error("NaN is a number") + } + + // NaN should fail integer check (NaN is not an integer) + if LTypeInteger.Validate(L, nan) { + t.Error("NaN should not be an integer") + } + + // NaN in @min/@max: NaN < anything is false, NaN > anything is false + // So @min(0) should pass for NaN (n < minVal is false) + // This is a known IEEE 754 behavior — NaN comparisons are always false + minType := <ype{inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + })} + // NaN is a number, and `NaN < 0` is false so min validator passes + if !minType.Validate(L, nan) { + t.Error("NaN passes @min(0) due to IEEE 754 NaN comparison semantics") + } + + // NaN literal matching — NaN != NaN by IEEE 754 + nanLiteral := <ype{inner: typ.LiteralNumber(math.NaN())} + if nanLiteral.Validate(L, nan) { + t.Error("NaN literal should not match NaN value (NaN != NaN)") + } +} + +func TestAdversarial_Inf(t *testing.T) { + L := NewState() + defer L.Close() + + posInf := LNumber(math.Inf(1)) + negInf := LNumber(math.Inf(-1)) + + // Inf is a number + if !LTypeNumber.Validate(L, posInf) { + t.Error("+Inf is a number") + } + if !LTypeNumber.Validate(L, negInf) { + t.Error("-Inf is a number") + } + + // Inf is not an integer + if LTypeInteger.Validate(L, posInf) { + t.Error("+Inf should not be an integer") + } + + // @max(100) should reject +Inf + maxType := <ype{inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "max", Arg: float64(100)}, + })} + if maxType.Validate(L, posInf) { + t.Error("+Inf should fail @max(100)") + } + + // @min(0) should reject -Inf + minType := <ype{inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + })} + if minType.Validate(L, negInf) { + t.Error("-Inf should fail @min(0)") + } +} + +func TestAdversarial_NegativeZero(t *testing.T) { + L := NewState() + defer L.Close() + + negZero := LNumber(math.Copysign(0, -1)) + + if !LTypeNumber.Validate(L, negZero) { + t.Error("-0 is a number") + } + + // -0 == 0 in IEEE 754, so integer check should pass + if !LTypeInteger.Validate(L, negZero) { + t.Error("-0 should pass integer check (IsIntegerValue)") + } + + // Literal 0 should match -0 (they're equal in IEEE 754) + zeroLit := <ype{inner: typ.LiteralNumber(0)} + if !zeroLit.Validate(L, negZero) { + t.Error("literal 0 should match -0") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: huge tables +// --------------------------------------------------------------------------- + +func TestAdversarial_HugeArray(t *testing.T) { + L := NewState() + defer L.Close() + + arrType := <ype{inner: typ.NewArray(typ.Number)} + + tbl := <able{Array: make([]LValue, 10000)} + for i := range tbl.Array { + tbl.Array[i] = LNumber(float64(i)) + } + + if !arrType.Validate(L, tbl) { + t.Error("10k element array should pass") + } + + // Corrupt one element + tbl.Array[9999] = LString("bad") + if arrType.Validate(L, tbl) { + t.Error("array with bad last element should fail") + } +} + +func TestAdversarial_HugeRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // Record with 100 fields + rb := typ.NewRecord() + for i := 0; i < 100; i++ { + rb.Field(fmt.Sprintf("f%d", i), typ.Number) + } + rec := <ype{inner: rb.Build()} + + tbl := <able{Strdict: make(map[string]LValue, 100)} + for i := 0; i < 100; i++ { + tbl.Strdict[fmt.Sprintf("f%d", i)] = LNumber(float64(i)) + } + + if !rec.Validate(L, tbl) { + t.Error("100-field record should pass") + } + + // Miss one field + delete(tbl.Strdict, "f99") + if rec.Validate(L, tbl) { + t.Error("record missing f99 should fail") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: deeply nested types +// --------------------------------------------------------------------------- + +func TestAdversarial_DeeplyNestedOptional(t *testing.T) { + L := NewState() + defer L.Close() + + // Optional(Optional(Optional(... number ...))) 50 levels deep + // NewOptional normalizes Optional(Optional(X)) -> Optional(X) + // So this should all collapse to just Optional(Number) + inner := typ.Type(typ.Number) + for i := 0; i < 50; i++ { + inner = typ.NewOptional(inner) + } + + deepOpt := <ype{inner: inner} + if !deepOpt.Validate(L, LNumber(42)) { + t.Error("deeply nested optional should pass for number") + } + if !deepOpt.Validate(L, LNil) { + t.Error("deeply nested optional should pass for nil") + } +} + +func TestAdversarial_DeeplyNestedRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // type A = {inner: {inner: {inner: ... {value: number} ...}}} 20 levels + var inner typ.Type = typ.NewRecord().Field("value", typ.Number).Build() + for i := 0; i < 20; i++ { + inner = typ.NewRecord().Field("inner", inner).Build() + } + + deepRec := <ype{inner: inner} + + // Build matching table 20 levels deep + var tbl LValue = <able{Strdict: map[string]LValue{"value": LNumber(42)}} + for i := 0; i < 20; i++ { + tbl = <able{Strdict: map[string]LValue{"inner": tbl}} + } + + if !deepRec.Validate(L, tbl) { + t.Error("20-level nested record should pass") + } +} + +func TestAdversarial_DeeplyNestedUnion(t *testing.T) { + L := NewState() + defer L.Close() + + // Union(Union(Union(... number | string ...))) + // NewUnion flattens, so this collapses to Union(number, string) + inner := typ.Type(typ.NewUnion(typ.Number, typ.String)) + for i := 0; i < 30; i++ { + inner = typ.NewUnion(inner, typ.Boolean) + } + + deepUnion := <ype{inner: inner} + if !deepUnion.Validate(L, LNumber(42)) { + t.Error("deep union should accept number") + } + if !deepUnion.Validate(L, LString("hi")) { + t.Error("deep union should accept string") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: circular / self-referential types +// --------------------------------------------------------------------------- + +func TestAdversarial_MutualRecursion(t *testing.T) { + L := NewState() + defer L.Close() + + // type A = { b: B? } + // type B = { a: A? } + a := typ.NewRecursivePlaceholder("A") + b := typ.NewRecursivePlaceholder("B") + a.SetBody(typ.NewRecord().OptField("b", b).Build()) + b.SetBody(typ.NewRecord().OptField("a", a).Build()) + + aType := <ype{inner: a} + + // Simple value — no cycle in data + tbl := L.NewTable() + if !aType.Validate(L, tbl) { + t.Error("empty table should pass A (all fields optional)") + } + + // One level of nesting + inner := L.NewTable() + tbl2 := L.NewTable() + tbl2.RawSetString("b", inner) + if !aType.Validate(L, tbl2) { + t.Error("A{b: {}} should pass") + } + + // Two levels: A -> B -> A + innerA := L.NewTable() + innerB := L.NewTable() + innerB.RawSetString("a", innerA) + tbl3 := L.NewTable() + tbl3.RawSetString("b", innerB) + if !aType.Validate(L, tbl3) { + t.Error("A{b: B{a: A{}}} should pass") + } +} + +func TestAdversarial_DirectSelfReference(t *testing.T) { + L := NewState() + defer L.Close() + + // Malformed: Body = itself (no Optional to break recursion) + rec := typ.NewRecursivePlaceholder("Loop") + rec.SetBody(rec) + + loopType := <ype{inner: rec} + + // Must not hang — depth limit kicks in + loopType.Validate(L, L.NewTable()) +} + +func TestAdversarial_SelfReferenceIs(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + rec := typ.NewRecursivePlaceholder("Loop") + rec.SetBody(rec) + + loopType := <ype{inner: rec} + isMethod := L.typeGetField(loopType, "is") + L.Push(isMethod) + L.Push(L.NewTable()) + L.Call(1, 2) + // Just verify it doesn't hang — result doesn't matter + L.Pop(2) +} + +// --------------------------------------------------------------------------- +// Adversarial: nil / empty in every position +// --------------------------------------------------------------------------- + +func TestAdversarial_NilInEveryPosition(t *testing.T) { + L := NewState() + defer L.Close() + + types := []*LType{ + LTypeNumber, + LTypeString, + LTypeBoolean, + LTypeInteger, + LTypeNil, + LTypeAny, + LTypeNever, + {inner: typ.NewOptional(typ.Number)}, + {inner: typ.NewArray(typ.Number)}, + {inner: typ.NewMap(typ.String, typ.Number)}, + {inner: typ.NewRecord().Field("x", typ.Number).Build()}, + {inner: typ.NewInterface("table", nil)}, + {inner: typ.NewUnion(typ.Number, typ.String)}, + {inner: typ.LiteralString("x")}, + {inner: typ.NewTuple(typ.Number)}, + } + + for _, lt := range types { + name := lt.String() + // Should not panic on nil value + _ = lt.Validate(L, LNil) + + // Should not panic via :is() + isMethod := L.typeGetField(lt, "is") + L.Push(isMethod) + L.Push(LNil) + L.Call(1, 2) + L.Pop(2) + + _ = name // used for debugging if needed + } +} + +func TestAdversarial_EmptyTableAgainstAll(t *testing.T) { + L := NewState() + defer L.Close() + + empty := L.NewTable() + + tests := []struct { + name string + typ *LType + ok bool + }{ + {"number", LTypeNumber, false}, + {"string", LTypeString, false}, + {"boolean", LTypeBoolean, false}, + {"nil", LTypeNil, false}, + {"any", LTypeAny, true}, + {"table", <ype{inner: typ.NewInterface("table", nil)}, true}, + {"empty record", <ype{inner: typ.NewRecord().Build()}, true}, + {"record with required field", <ype{inner: typ.NewRecord().Field("x", typ.Number).Build()}, false}, + {"record all optional", <ype{inner: typ.NewRecord().OptField("x", typ.Number).Build()}, true}, + {"empty array", <ype{inner: typ.NewArray(typ.Number)}, true}, + {"empty map", <ype{inner: typ.NewMap(typ.String, typ.Number)}, true}, + {"function", <ype{inner: typ.Func().Returns(typ.Number).Build()}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.typ.Validate(L, empty); got != tt.ok { + t.Errorf("Validate(empty table) = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Adversarial: type confusion / mixed table shapes +// --------------------------------------------------------------------------- + +func TestAdversarial_TableWithBothArrayAndDict(t *testing.T) { + L := NewState() + defer L.Close() + + // A table that has BOTH array entries and string keys + // This is valid Lua — tables can be mixed + mixed := L.NewTable() + mixed.Append(LNumber(1)) // Array[0] = 1 + mixed.Append(LNumber(2)) // Array[1] = 2 + mixed.RawSetString("name", LString("test")) // Strdict["name"] = "test" + + // Record should see "name" field + rec := <ype{inner: typ.NewRecord().Field("name", typ.String).Build()} + if !rec.Validate(L, mixed) { + t.Error("record should find 'name' in mixed table") + } + + // Array should see array part + arr := <ype{inner: typ.NewArray(typ.Number)} + if !arr.Validate(L, mixed) { + t.Error("array should validate array part of mixed table") + } + + // Map {[string]: string} only checks Strdict — Strdict has name="test" (valid) + // Array part is not checked for string-keyed maps (correct behavior) + strMap := <ype{inner: typ.NewMap(typ.String, typ.String)} + if !strMap.Validate(L, mixed) { + t.Error("string map should pass — only Strdict is checked, and name=test is valid") + } + + // Map {[string]: number} should reject because Strdict has name="test" (string, not number) + strNumMap := <ype{inner: typ.NewMap(typ.String, typ.Number)} + if strNumMap.Validate(L, mixed) { + t.Error("string->number map should reject string value in Strdict") + } +} + +func TestAdversarial_TableWithNonStringKeys(t *testing.T) { + L := NewState() + defer L.Close() + + // Table keyed by booleans — goes into Dict + tbl := L.NewTable() + tbl.RawSet(LTrue, LString("yes")) + tbl.RawSet(LFalse, LString("no")) + + // Map {[boolean]: string} — check Dict + boolMap := <ype{inner: typ.NewMap(typ.Boolean, typ.String)} + if !boolMap.Validate(L, tbl) { + t.Error("boolean-keyed map should pass") + } + + // Map {[string]: string} — boolean keys should fail + strMap := <ype{inner: typ.NewMap(typ.String, typ.String)} + if strMap.Validate(L, tbl) { + t.Error("string-keyed map should reject boolean keys") + } +} + +func TestAdversarial_TableWithTableKeys(t *testing.T) { + L := NewState() + defer L.Close() + + // Table keyed by other tables — goes into Dict + key1 := L.NewTable() + key2 := L.NewTable() + tbl := L.NewTable() + tbl.RawSet(key1, LNumber(1)) + tbl.RawSet(key2, LNumber(2)) + + // Map {[table]: number} + tableMap := <ype{inner: typ.NewMap(typ.NewInterface("table", nil), typ.Number)} + if !tableMap.Validate(L, tbl) { + t.Error("table-keyed map should pass") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: very long strings and patterns +// --------------------------------------------------------------------------- + +func TestAdversarial_LongString(t *testing.T) { + L := NewState() + defer L.Close() + + longStr := LString(strings.Repeat("a", 100000)) + + if !LTypeString.Validate(L, longStr) { + t.Error("100k char string should pass string validation") + } + + // @max_len(100) should reject + maxLen := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "max_len", Arg: float64(100)}, + })} + if maxLen.Validate(L, longStr) { + t.Error("100k string should fail @max_len(100)") + } + + // @pattern should work on long strings + pattern := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "pattern", Arg: "^a+$"}, + })} + if !pattern.Validate(L, longStr) { + t.Error("100k 'a' string should match ^a+$") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: union with overlapping types +// --------------------------------------------------------------------------- + +func TestAdversarial_UnionOverlap(t *testing.T) { + L := NewState() + defer L.Close() + + // number | integer — integer is a subtype of number, both should match + u := <ype{inner: typ.NewUnion(typ.Number, typ.Integer)} + + if !u.Validate(L, LNumber(42.5)) { + t.Error("float should pass number|integer") + } + if !u.Validate(L, LInteger(42)) { + t.Error("integer should pass number|integer") + } +} + +func TestAdversarial_UnionWithNever(t *testing.T) { + L := NewState() + defer L.Close() + + // number | never — never contributes nothing + u := <ype{inner: typ.NewUnion(typ.Number, typ.Never)} + if !u.Validate(L, LNumber(42)) { + t.Error("number should pass number|never") + } + if u.Validate(L, LString("x")) { + t.Error("string should fail number|never") + } +} + +func TestAdversarial_UnionWithAny(t *testing.T) { + L := NewState() + defer L.Close() + + // string | any — any swallows everything + u := <ype{inner: typ.NewUnion(typ.String, typ.Any)} + if !u.Validate(L, LNumber(42)) { + t.Error("number should pass string|any") + } + if !u.Validate(L, LNil) { + t.Error("nil should pass string|any") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: resolver attacks +// --------------------------------------------------------------------------- + +func TestAdversarial_ResolverCircularRef(t *testing.T) { + L := NewState() + defer L.Close() + + // A resolves to Ref(B), B resolves to Ref(A) — circular + resolver := &typeResolver{ + types: map[string]typ.Type{ + "A": typ.NewRef("", "B"), + "B": typ.NewRef("", "A"), + }, + } + + refType := <ype{inner: typ.NewRef("", "A"), resolver: resolver} + + // Should not hang — resolveRuntimeType has depth limit of 32 + result := refType.Validate(L, LNumber(42)) + _ = result // just verify termination +} + +func TestAdversarial_ResolverDeepChain(t *testing.T) { + L := NewState() + defer L.Close() + + // A -> B -> C -> ... -> Z -> number (26-level alias chain) + types := make(map[string]typ.Type) + for i := 0; i < 25; i++ { + name := fmt.Sprintf("T%d", i) + next := fmt.Sprintf("T%d", i+1) + types[name] = typ.NewAlias(name, typ.NewRef("", next)) + } + types["T25"] = typ.Number + + resolver := &typeResolver{types: types} + refType := <ype{inner: typ.NewRef("", "T0"), resolver: resolver} + + if !refType.Validate(L, LNumber(42)) { + t.Error("26-level alias chain should resolve to number") + } +} + +func TestAdversarial_ResolverChainExceedsDepth(t *testing.T) { + L := NewState() + defer L.Close() + + // 40-level chain — exceeds the 32-depth limit + types := make(map[string]typ.Type) + for i := 0; i < 39; i++ { + name := fmt.Sprintf("T%d", i) + next := fmt.Sprintf("T%d", i+1) + types[name] = typ.NewAlias(name, typ.NewRef("", next)) + } + types["T39"] = typ.Number + + resolver := &typeResolver{types: types} + refType := <ype{inner: typ.NewRef("", "T0"), resolver: resolver} + + // Should not hang, but may not resolve fully + _ = refType.Validate(L, LNumber(42)) +} + +// --------------------------------------------------------------------------- +// Adversarial: record field name edge cases +// --------------------------------------------------------------------------- + +func TestAdversarial_RecordEmptyFieldName(t *testing.T) { + L := NewState() + defer L.Close() + + // Field with empty string name + rec := <ype{inner: typ.NewRecord().Field("", typ.Number).Build()} + + tbl := L.NewTable() + tbl.RawSetString("", LNumber(42)) + + if !rec.Validate(L, tbl) { + t.Error("record with empty-string field should work") + } +} + +func TestAdversarial_RecordSpecialFieldNames(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{inner: typ.NewRecord(). + Field("__index", typ.String). + Field("__tostring", typ.String). + Field("is", typ.Number). // shadows the method name + Build(), + } + + tbl := L.NewTable() + tbl.RawSetString("__index", LString("test")) + tbl.RawSetString("__tostring", LString("test")) + tbl.RawSetString("is", LNumber(42)) + + if !rec.Validate(L, tbl) { + t.Error("record with metamethod-like field names should work") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: annotation with extreme values +// --------------------------------------------------------------------------- + +func TestAdversarial_AnnotationExtremeValues(t *testing.T) { + L := NewState() + defer L.Close() + + tests := []struct { + name string + annotation typ.Annotation + value LValue + ok bool + }{ + {"min MaxFloat64", typ.Annotation{Name: "min", Arg: math.MaxFloat64}, LNumber(math.MaxFloat64), true}, + {"min MaxFloat64 rejects less", typ.Annotation{Name: "min", Arg: math.MaxFloat64}, LNumber(0), false}, + {"max -MaxFloat64", typ.Annotation{Name: "max", Arg: -math.MaxFloat64}, LNumber(-math.MaxFloat64), true}, + {"max -MaxFloat64 rejects more", typ.Annotation{Name: "max", Arg: -math.MaxFloat64}, LNumber(0), false}, + {"min_len very large", typ.Annotation{Name: "min_len", Arg: float64(999999)}, LString("short"), false}, + {"max_len negative", typ.Annotation{Name: "max_len", Arg: float64(-1)}, LString("anything"), true}, // negative max_len returns nil + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ann := <ype{inner: typ.NewAnnotated(typ.Number, []typ.Annotation{tt.annotation})} + // For string annotations, use string type + if _, ok := tt.value.(LString); ok { + ann = <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{tt.annotation})} + } + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Adversarial: :is() with wrong number of arguments +// --------------------------------------------------------------------------- + +func TestAdversarial_IsNoArgs(t *testing.T) { + L := NewState() + defer L.Close() + + isMethod := L.typeGetField(LTypeNumber, "is") + L.Push(isMethod) + L.Call(0, 2) + val := L.Get(-2) + L.Pop(2) + + // With 0 args, idx=1, L.Get(1) returns LNil + // Should validate LNil against Number type — fails + if val != LNil { + t.Error("is() with no args should return nil (number doesn't accept nil)") + } +} + +func TestAdversarial_IsManyArgs(t *testing.T) { + L := NewState() + defer L.Close() + + // :is() with 5 args — should only look at arg 2 (colon syntax) + isMethod := L.typeGetField(LTypeNumber, "is") + L.Push(isMethod) + L.Push(LString("self")) // arg 1 (self in colon syntax) + L.Push(LNumber(42)) // arg 2 (the value) + L.Push(LString("extra")) // arg 3 (ignored) + L.Push(LTrue) // arg 4 (ignored) + L.Push(LNil) // arg 5 (ignored) + L.Call(5, 2) + val := L.Get(-2) + L.Pop(2) + + if val == LNil { + t.Error("is() should validate arg 2 (the number)") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: record with many optional fields, one required at the end +// --------------------------------------------------------------------------- + +func TestAdversarial_RecordLastFieldRequired(t *testing.T) { + L := NewState() + defer L.Close() + + // 20 optional fields, then one required at the end + rb := typ.NewRecord() + for i := 0; i < 20; i++ { + rb.OptField(fmt.Sprintf("opt%d", i), typ.String) + } + rb.Field("required", typ.Number) + rec := <ype{inner: rb.Build()} + + // Empty table — missing required field + if rec.Validate(L, L.NewTable()) { + t.Error("should fail — required field missing") + } + + // Table with only the required field + tbl := L.NewTable() + tbl.RawSetString("required", LNumber(42)) + if !rec.Validate(L, tbl) { + t.Error("should pass — only required field present") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: Validate vs :is() consistency under adversarial input +// --------------------------------------------------------------------------- + +func TestAdversarial_ValidateIsConsistency(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("tags", typ.NewArray(typ.String)). + OptField("meta", typ.NewInterface("table", nil)). + Build(), + } + + adversarialValues := []LValue{ + LNil, + LTrue, + LFalse, + LNumber(0), + LNumber(math.NaN()), + LNumber(math.Inf(1)), + LString(""), + LString("not a table"), + L.NewTable(), // empty table + &LUserData{Value: "x"}, // userdata + <able{Array: []LValue{}}, // empty array table + func() LValue { // table with wrong types + t := L.NewTable() + t.RawSetString("id", LNumber(123)) + return t + }(), + func() LValue { // valid table + t := L.NewTable() + t.RawSetString("id", LString("abc")) + return t + }(), + func() LValue { // valid with optionals + t := L.NewTable() + t.RawSetString("id", LString("abc")) + tags := L.NewTable() + tags.Append(LString("x")) + t.RawSetString("tags", tags) + t.RawSetString("meta", L.NewTable()) + return t + }(), + } + + isMethod := L.typeGetField(rec, "is") + for i, v := range adversarialValues { + validateResult := rec.Validate(L, v) + + L.Push(isMethod) + L.Push(v) + L.Call(1, 2) + isVal := L.Get(-2) + isErr := L.Get(-1) + L.Pop(2) + + // Determine :is() result + isSuccess := isVal != LNil || isErr == LNil + + if validateResult != isSuccess { + t.Errorf("case %d (%T): Validate()=%v but is() val=%v err=%v", + i, v, validateResult, isVal, errMessage(isErr)) + } + } +} + +// --------------------------------------------------------------------------- +// Adversarial: empty/nil type internals +// --------------------------------------------------------------------------- + +func TestAdversarial_NilTypeInner(t *testing.T) { + L := NewState() + defer L.Close() + + // LType with nil inner — should not panic + lt := <ype{inner: nil} + if lt.Validate(L, LNumber(42)) { + t.Error("nil inner should fail") + } + if lt.Validate(L, LNil) { + t.Error("nil inner should fail for nil too") + } +} + +func TestAdversarial_NilTypeInnerIs(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + lt := <ype{inner: nil} + isMethod := L.typeGetField(lt, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + val := L.Get(-2) + L.Pop(2) + + if val != LNil { + t.Error("nil inner type should reject everything") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: Annotated wrapping Annotated +// --------------------------------------------------------------------------- + +func TestAdversarial_DoubleAnnotated(t *testing.T) { + L := NewState() + defer L.Close() + + // number @min(0) @max(100) — then wrap that in another @pattern (nonsensical but shouldn't crash) + inner := typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(100)}, + }) + outer := <ype{inner: typ.NewAnnotated(inner, []typ.Annotation{ + {Name: "min", Arg: float64(10)}, // stricter min on top + })} + + if !outer.Validate(L, LNumber(50)) { + t.Error("50 should pass both annotation layers") + } + if outer.Validate(L, LNumber(5)) { + t.Error("5 should fail outer @min(10)") + } + if outer.Validate(L, LNumber(105)) { + t.Error("105 should fail inner @max(100)") + } +} + +// --------------------------------------------------------------------------- +// Adversarial: LType as a value being validated +// --------------------------------------------------------------------------- + +// =========================================================================== +// EXPLOIT: nil pointer dereference attacks +// These would cause segfaults in JIT if not handled +// =========================================================================== + +func TestExploit_NilFieldType_MissingField(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + // Record with nil field type — corrupted manifest + rec := <ype{inner: &typ.Record{Fields: []typ.Field{ + {Name: "x", Type: nil, Optional: false}, + }}} + + // Validate path — fieldVal is LNil, field not optional, field.Type is nil + // validateValueDepth: nil type → returns false. No crash. + if rec.Validate(L, L.NewTable()) { + t.Error("should fail") + } + + // :is() path — the danger zone. validateValue returns false, + // then validateWithError calls field.Type.String() → nil deref → PANIC + // This must not crash. + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(L.NewTable()) + L.Call(1, 2) + val := L.Get(-2) + L.Pop(2) + if val != LNil { + t.Error("should fail") + } +} + +func TestExploit_NilMapKey(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + // Map with nil Key — corrupted manifest + m := <ype{inner: &typ.Map{Key: nil, Value: typ.Number}} + + tbl := L.NewTable() + tbl.RawSetString("a", LNumber(1)) + + // Validate path — nil key type → returns false. Safe. + if m.Validate(L, tbl) { + t.Error("should fail") + } + + // :is() path — t.String() calls m.Key.String() → nil deref + isMethod := L.typeGetField(m, "is") + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + val := L.Get(-2) + L.Pop(2) + if val != LNil { + t.Error("should fail") + } +} + +func TestExploit_NilMapValue(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + m := <ype{inner: &typ.Map{Key: typ.String, Value: nil}} + + tbl := L.NewTable() + tbl.RawSetString("a", LNumber(1)) + + isMethod := L.typeGetField(m, "is") + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) + // Must not crash +} + +func TestExploit_NilArrayElement(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + arr := <ype{inner: &typ.Array{Element: nil}} + + tbl := L.NewTable() + tbl.Append(LNumber(1)) + + isMethod := L.typeGetField(arr, "is") + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_NilOptionalInner(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + opt := <ype{inner: &typ.Optional{Inner: nil}} + + isMethod := L.typeGetField(opt, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_NilAnnotatedInner(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + ann := <ype{inner: &typ.Annotated{Inner: nil, Annotations: []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + }}} + + isMethod := L.typeGetField(ann, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_NilUnionMember(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + u := <ype{inner: &typ.Union{Members: []typ.Type{nil, typ.Number}}} + + isMethod := L.typeGetField(u, "is") + L.Push(isMethod) + L.Push(LString("bad")) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_NilIntersectionMember(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + inter := <ype{inner: &typ.Intersection{Members: []typ.Type{nil, typ.Number}}} + + isMethod := L.typeGetField(inter, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_NilTupleElement(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + tuple := <ype{inner: &typ.Tuple{Elements: []typ.Type{typ.Number, nil}}} + + tbl := L.NewTable() + tbl.Append(LNumber(1)) + tbl.Append(LString("x")) + + isMethod := L.typeGetField(tuple, "is") + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) +} + +func TestExploit_ValidateIsDisagreement(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + // If validateValue returns false but validateWithError returns true, + // verr would be nil → toValidationLuaError panics. + // Verify these never disagree. + types := []typ.Type{ + typ.Number, typ.String, typ.Boolean, typ.Integer, + typ.NewOptional(typ.Number), + typ.NewArray(typ.Number), + typ.NewMap(typ.String, typ.Number), + typ.NewRecord().Field("x", typ.Number).Build(), + typ.NewUnion(typ.Number, typ.String), + typ.NewInterface("table", nil), + typ.LiteralString("hello"), + typ.LiteralInt(42), + typ.NewIntersection( + typ.NewRecord().Field("x", typ.Number).Build(), + typ.NewRecord().Field("y", typ.String).Build(), + ), + } + + values := []LValue{ + LNil, LTrue, LFalse, LNumber(42), LNumber(42.5), LInteger(42), + LString("hello"), LString(""), <able{}, &LUserData{}, + } + + for ti, tp := range types { + _ = <ype{inner: tp} + for vi, v := range values { + boolResult := validateValue(v, tp, nil) + _, verr := validateWithError(v, tp, nil, "") + errResult := verr == nil + + if boolResult != errResult { + t.Errorf("DISAGREEMENT type[%d] val[%d]: validateValue=%v validateWithError=%v (verr=%v)", + ti, vi, boolResult, errResult, verr) + } + } + } +} + +func TestAdversarial_TypeAsValue(t *testing.T) { + L := NewState() + defer L.Close() + + typeVal := LTypeNumber + + if LTypeNumber.Validate(L, typeVal) { + t.Error("LType should not pass as number") + } + if LTypeString.Validate(L, typeVal) { + t.Error("LType should not pass as string") + } + if LTypeAny.Validate(L, typeVal) != true { + t.Error("LType should pass as any") + } + + tblType := <ype{inner: typ.NewInterface("table", nil)} + if tblType.Validate(L, typeVal) { + t.Error("LType should not pass as table") + } +} + +// =========================================================================== +// LITERAL ADVERSARIAL +// =========================================================================== + +func TestAdversarial_LiteralNumberVsInteger(t *testing.T) { + L := NewState() + defer L.Close() + + // Literal int64(42) should match both LInteger(42) and LNumber(42.0) + intLit := <ype{inner: typ.LiteralInt(42)} + if !intLit.Validate(L, LInteger(42)) { + t.Error("int64 literal should match LInteger") + } + if !intLit.Validate(L, LNumber(42.0)) { + t.Error("int64 literal should match whole LNumber") + } + if intLit.Validate(L, LNumber(42.5)) { + t.Error("int64 literal should NOT match fractional LNumber") + } + if intLit.Validate(L, LNumber(42.0000001)) { + t.Error("int64 literal should NOT match near-miss LNumber") + } + + // Literal float64(42.0) should match LNumber(42.0) but NOT LInteger(42) + floatLit := <ype{inner: typ.LiteralNumber(42.0)} + if !floatLit.Validate(L, LNumber(42.0)) { + t.Error("float64 literal should match LNumber") + } + if floatLit.Validate(L, LInteger(42)) { + t.Error("float64 literal should NOT match LInteger (different Go type)") + } + + // Literal float64(42.1) should not match LNumber(42.2) — different values + diffLit := <ype{inner: typ.LiteralNumber(42.1)} + if diffLit.Validate(L, LNumber(42.2)) { + t.Error("42.1 literal should NOT match 42.2") + } +} + +func TestAdversarial_LiteralBoolInUnion(t *testing.T) { + L := NewState() + defer L.Close() + + // true | "yes" | 1 — mixed literal union + u := <ype{inner: typ.NewUnion( + typ.LiteralBool(true), + typ.LiteralString("yes"), + typ.LiteralInt(1), + )} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"true matches", LTrue, true}, + {"false fails", LFalse, false}, + {"yes matches", LString("yes"), true}, + {"no fails", LString("no"), false}, + {"1 as integer matches", LInteger(1), true}, + {"1 as number matches", LNumber(1.0), true}, + {"2 fails", LInteger(2), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := u.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate(%v) = %v, want %v", tt.value, got, tt.ok) + } + }) + } +} + +func TestAdversarial_LiteralEmptyString(t *testing.T) { + L := NewState() + defer L.Close() + + // "" literal — must match empty string, reject everything else + emptyLit := <ype{inner: typ.LiteralString("")} + + if !emptyLit.Validate(L, LString("")) { + t.Error("empty string literal should match empty string") + } + if emptyLit.Validate(L, LString(" ")) { + t.Error("empty string literal should reject space") + } + if emptyLit.Validate(L, LNil) { + t.Error("empty string literal should reject nil") + } +} + +// =========================================================================== +// UNION ADVERSARIAL +// =========================================================================== + +func TestAdversarial_UnionOfRecordsDiscriminant(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + // Discriminated union pattern: + // {type: "circle", radius: number} | {type: "rect", width: number, height: number} + circle := typ.NewRecord(). + Field("type", typ.LiteralString("circle")). + Field("radius", typ.Number). + Build() + rect := typ.NewRecord(). + Field("type", typ.LiteralString("rect")). + Field("width", typ.Number). + Field("height", typ.Number). + Build() + shape := <ype{inner: typ.NewUnion(circle, rect), name: "Shape"} + L.SetGlobal("Shape", shape) + + err := L.DoString(` + local c, e = Shape:is({type = "circle", radius = 5}) + assert(c ~= nil, "circle should pass: " .. tostring(e)) + + local r, e = Shape:is({type = "rect", width = 10, height = 20}) + assert(r ~= nil, "rect should pass: " .. tostring(e)) + + -- Wrong discriminant + local x, e = Shape:is({type = "triangle", sides = 3}) + assert(x == nil, "triangle should fail") + + -- Missing discriminant + local y, e = Shape:is({radius = 5}) + assert(y == nil, "missing type field should fail") + + -- Right discriminant, wrong payload + local z, e = Shape:is({type = "circle", radius = "big"}) + assert(z == nil, "circle with string radius should fail") + `) + if err != nil { + t.Fatalf("discriminated union test failed: %v", err) + } +} + +func TestAdversarial_UnionRecordVsPrimitive(t *testing.T) { + L := NewState() + defer L.Close() + + // {x: number} | string — table or string + u := <ype{inner: typ.NewUnion( + typ.NewRecord().Field("x", typ.Number).Build(), + typ.String, + )} + + tbl := L.NewTable() + tbl.RawSetString("x", LNumber(42)) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"record passes", tbl, true}, + {"string passes", LString("hello"), true}, + {"number fails", LNumber(42), false}, + {"empty table fails (missing x)", L.NewTable(), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := u.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAdversarial_UnionSingleMember(t *testing.T) { + L := NewState() + defer L.Close() + + // Single-member union normalizes to the member itself + u := typ.NewUnion(typ.Number) + lt := <ype{inner: u} + + if !lt.Validate(L, LNumber(42)) { + t.Error("single-member union should pass for member type") + } + if lt.Validate(L, LString("x")) { + t.Error("single-member union should fail for non-member") + } +} + +func TestAdversarial_UnionEmpty(t *testing.T) { + L := NewState() + defer L.Close() + + // Empty union normalizes to Never + u := typ.NewUnion() + lt := <ype{inner: u} + + // Never rejects everything + if lt.Validate(L, LNumber(42)) { + t.Error("empty union (Never) should reject number") + } + if lt.Validate(L, LNil) { + t.Error("empty union (Never) should reject nil") + } +} + +// =========================================================================== +// REFLECTION METHOD TESTS +// =========================================================================== + +func TestReflection_KindMethod(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + types := map[string]*LType{ + "number": LTypeNumber, + "string": LTypeString, + "boolean": LTypeBoolean, + "integer": LTypeInteger, + "any": LTypeAny, + "never": LTypeNever, + "record": {inner: typ.NewRecord().Field("x", typ.Number).Build()}, + "array": {inner: typ.NewArray(typ.Number)}, + "map": {inner: typ.NewMap(typ.String, typ.Number)}, + "optional": {inner: typ.NewOptional(typ.Number)}, + "union": {inner: typ.NewUnion(typ.Number, typ.String)}, + "function": {inner: typ.Func().Returns(typ.Number).Build()}, + } + + for expected, lt := range types { + k := lt.KindString() + if k != expected { + t.Errorf("KindString() for %s = %q, want %q", lt.String(), k, expected) + } + } +} + +func TestReflection_FieldsIterator(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + OptField("age", typ.Number). + Field("active", typ.Boolean). + Build(), + name: "User", + } + L.SetGlobal("User", rec) + + err := L.DoString(` + local names = {} + local types = {} + for name, fieldType in User:fields() do + names[#names+1] = name + types[name] = fieldType:kind() + end + -- Fields should be present (order may vary due to sorting) + assert(types["name"] == "string", "name should be string") + assert(types["age"] == "number", "age should be number") + assert(types["active"] == "boolean", "active should be boolean") + assert(#names == 3, "should have 3 fields, got " .. #names) + `) + if err != nil { + t.Fatalf("fields iterator test failed: %v", err) + } +} + +func TestReflection_VariantsIterator(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + u := <ype{inner: typ.NewUnion(typ.Number, typ.String, typ.Boolean)} + L.SetGlobal("MyUnion", u) + + err := L.DoString(` + local count = 0 + for variant in MyUnion:variants() do + count = count + 1 + assert(variant:kind() ~= nil, "variant should have a kind") + end + assert(count == 3, "should have 3 variants, got " .. count) + `) + if err != nil { + t.Fatalf("variants iterator test failed: %v", err) + } +} + +func TestReflection_InnerMethod(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + opt := <ype{inner: typ.NewOptional(typ.Number)} + L.SetGlobal("OptNum", opt) + + err := L.DoString(` + local inner = OptNum:inner() + assert(inner ~= nil, "inner should not be nil") + assert(inner:kind() == "number", "inner kind should be 'number', got: " .. inner:kind()) + `) + if err != nil { + t.Fatalf("inner method test failed: %v", err) + } +} + +func TestReflection_ElemKeyVal(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + arr := <ype{inner: typ.NewArray(typ.String)} + m := <ype{inner: typ.NewMap(typ.String, typ.Number)} + L.SetGlobal("StringArray", arr) + L.SetGlobal("StringNumMap", m) + + err := L.DoString(` + local elem = StringArray:elem() + assert(elem:kind() == "string", "array elem should be string") + + local key = StringNumMap:key() + assert(key:kind() == "string", "map key should be string") + + local val = StringNumMap:val() + assert(val:kind() == "number", "map val should be number") + `) + if err != nil { + t.Fatalf("elem/key/val test failed: %v", err) + } +} + +func TestReflection_ParamsAndRet(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + fn := <ype{inner: typ.Func(). + Param("x", typ.Number). + Param("y", typ.String). + Returns(typ.Boolean). + Build()} + L.SetGlobal("MyFunc", fn) + + err := L.DoString(` + local count = 0 + for param in MyFunc:params() do + count = count + 1 + end + assert(count == 2, "should have 2 params, got " .. count) + + local ret = MyFunc:ret() + assert(ret:kind() == "boolean", "return type should be boolean") + `) + if err != nil { + t.Fatalf("params/ret test failed: %v", err) + } +} + +// =========================================================================== +// JIT-CRITICAL: concurrent validation (data races) +// =========================================================================== + +func TestAdversarial_ConcurrentValidation(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("name", typ.String). + OptField("count", typ.Number). + Build(), + } + + // Validate from multiple goroutines against the same type + // Types are immutable so this should be safe + done := make(chan bool, 100) + for i := 0; i < 100; i++ { + go func(n int) { + ls := NewState() + defer ls.Close() + + tbl := ls.NewTable() + tbl.RawSetString("id", LString(fmt.Sprintf("id-%d", n))) + if n%2 == 0 { + tbl.RawSetString("name", LString("test")) + } + if n%3 == 0 { + tbl.RawSetString("count", LNumber(float64(n))) + } + + result := rec.Validate(ls, tbl) + if !result { + panic(fmt.Sprintf("goroutine %d: valid table rejected", n)) + } + done <- true + }(i) + } + + for i := 0; i < 100; i++ { + <-done + } +} + +// =========================================================================== +// Record field types that are themselves complex +// =========================================================================== + +func TestAdversarial_RecordFieldIsUnionOfRecords(t *testing.T) { + L := NewState() + defer L.Close() + + // {payload: ({kind: "text", body: string} | {kind: "image", url: string})} + textRec := typ.NewRecord(). + Field("kind", typ.LiteralString("text")). + Field("body", typ.String). + Build() + imageRec := typ.NewRecord(). + Field("kind", typ.LiteralString("image")). + Field("url", typ.String). + Build() + outer := <ype{inner: typ.NewRecord(). + Field("payload", typ.NewUnion(textRec, imageRec)). + Build(), + } + + validText := L.NewTable() + payload := L.NewTable() + payload.RawSetString("kind", LString("text")) + payload.RawSetString("body", LString("hello")) + validText.RawSetString("payload", payload) + + validImage := L.NewTable() + imgPayload := L.NewTable() + imgPayload.RawSetString("kind", LString("image")) + imgPayload.RawSetString("url", LString("https://example.com/img.png")) + validImage.RawSetString("payload", imgPayload) + + invalidPayload := L.NewTable() + badPayload := L.NewTable() + badPayload.RawSetString("kind", LString("video")) + invalidPayload.RawSetString("payload", badPayload) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"text payload", validText, true}, + {"image payload", validImage, true}, + {"video payload (unknown kind)", invalidPayload, false}, + {"payload is string", func() LValue { + t := L.NewTable() + t.RawSetString("payload", LString("not a record")) + return t + }(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := outer.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAdversarial_RecordFieldIsArrayOfRecords(t *testing.T) { + L := NewState() + defer L.Close() + + // {items: {name: string, price: number}[]} + itemRec := typ.NewRecord(). + Field("name", typ.String). + Field("price", typ.Number). + Build() + outer := <ype{inner: typ.NewRecord(). + Field("items", typ.NewArray(itemRec)). + Build(), + } + + items := L.NewTable() + item1 := L.NewTable() + item1.RawSetString("name", LString("Widget")) + item1.RawSetString("price", LNumber(9.99)) + item2 := L.NewTable() + item2.RawSetString("name", LString("Gadget")) + item2.RawSetString("price", LNumber(19.99)) + items.Append(item1) + items.Append(item2) + + valid := L.NewTable() + valid.RawSetString("items", items) + + if !outer.Validate(L, valid) { + t.Error("array of records should pass") + } + + // Corrupt one item + item2.RawSetString("price", LString("expensive")) + if outer.Validate(L, valid) { + t.Error("array with bad record should fail") + } +} + +func TestAdversarial_RecordFieldIsMapOfArrays(t *testing.T) { + L := NewState() + defer L.Close() + + // {groups: {[string]: {number}[]}} — map of string to array of numbers + outer := <ype{inner: typ.NewRecord(). + Field("groups", typ.NewMap(typ.String, typ.NewArray(typ.Number))). + Build(), + } + + groups := L.NewTable() + g1 := L.NewTable() + g1.Append(LNumber(1)) + g1.Append(LNumber(2)) + groups.RawSetString("alpha", g1) + g2 := L.NewTable() + g2.Append(LNumber(3)) + groups.RawSetString("beta", g2) + + valid := L.NewTable() + valid.RawSetString("groups", groups) + + if !outer.Validate(L, valid) { + t.Error("map of arrays should pass") + } + + // Corrupt: put string in number array + g2.Append(LString("bad")) + if outer.Validate(L, valid) { + t.Error("map of arrays with bad element should fail") + } +} diff --git a/ltype_bench_test.go b/ltype_bench_test.go new file mode 100644 index 00000000..1c9bad39 --- /dev/null +++ b/ltype_bench_test.go @@ -0,0 +1,517 @@ +package lua + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +// --------------------------------------------------------------------------- +// Primitive validation benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Number(b *testing.B) { + L := NewState() + defer L.Close() + for i := 0; i < b.N; i++ { + LTypeNumber.Validate(L, LNumber(42)) + } +} + +func BenchmarkValidate_String(b *testing.B) { + L := NewState() + defer L.Close() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeString.Validate(L, LString("hello")) + } +} + +func BenchmarkValidate_Boolean(b *testing.B) { + L := NewState() + defer L.Close() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeBoolean.Validate(L, LTrue) + } +} + +func BenchmarkValidate_Integer(b *testing.B) { + L := NewState() + defer L.Close() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeInteger.Validate(L, LInteger(42)) + } +} + +func BenchmarkValidate_IntegerFromNumber(b *testing.B) { + L := NewState() + defer L.Close() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeInteger.Validate(L, LNumber(42.0)) + } +} + +func BenchmarkValidate_Any(b *testing.B) { + L := NewState() + defer L.Close() + tbl := L.NewTable() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeAny.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Optional type benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_OptionalNumber_Hit(b *testing.B) { + L := NewState() + defer L.Close() + optNum := <ype{inner: typ.NewOptional(typ.Number)} + for b.ResetTimer(); b.N > 0; b.N-- { + optNum.Validate(L, LNumber(42)) + } +} + +func BenchmarkValidate_OptionalNumber_Nil(b *testing.B) { + L := NewState() + defer L.Close() + optNum := <ype{inner: typ.NewOptional(typ.Number)} + for b.ResetTimer(); b.N > 0; b.N-- { + optNum.Validate(L, LNil) + } +} + +func BenchmarkValidate_OptionalTable_Hit(b *testing.B) { + L := NewState() + defer L.Close() + optTable := <ype{inner: typ.NewOptional(typ.NewInterface("table", nil))} + tbl := L.NewTable() + for b.ResetTimer(); b.N > 0; b.N-- { + optTable.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Record validation benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Record_Small(b *testing.B) { + L := NewState() + defer L.Close() + rec := <ype{ + inner: typ.NewRecord(). + Field("x", typ.Number). + Field("y", typ.Number). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("x", LNumber(1)) + tbl.RawSetString("y", LNumber(2)) + for b.ResetTimer(); b.N > 0; b.N-- { + rec.Validate(L, tbl) + } +} + +func BenchmarkValidate_Record_Medium(b *testing.B) { + L := NewState() + defer L.Close() + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + OptField("icon", typ.String). + OptField("meta", typ.NewInterface("table", nil)). + OptField("content", typ.NewInterface("table", nil)). + OptField("tags", typ.NewArray(typ.String)). + OptField("actor", typ.String). + Build(), + name: "UpdateInput", + } + tbl := L.NewTable() + tbl.RawSetString("id", LString("abc")) + tbl.RawSetString("name", LString("test")) + tbl.RawSetString("actor", LString("user1")) + for b.ResetTimer(); b.N > 0; b.N-- { + rec.Validate(L, tbl) + } +} + +func BenchmarkValidate_Record_Full(b *testing.B) { + L := NewState() + defer L.Close() + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + OptField("icon", typ.String). + OptField("meta", typ.NewInterface("table", nil)). + OptField("content", typ.NewInterface("table", nil)). + OptField("tags", typ.NewArray(typ.String)). + OptField("actor", typ.String). + Build(), + name: "UpdateInput", + } + tbl := L.NewTable() + tbl.RawSetString("id", LString("abc")) + tbl.RawSetString("name", LString("test")) + tbl.RawSetString("icon", LString("icon.png")) + meta := L.NewTable() + meta.RawSetString("key", LString("val")) + tbl.RawSetString("meta", meta) + content := L.NewTable() + content.RawSetString("body", LString("text")) + tbl.RawSetString("content", content) + tags := L.NewTable() + tags.Append(LString("a")) + tags.Append(LString("b")) + tbl.RawSetString("tags", tags) + tbl.RawSetString("actor", LString("user1")) + for b.ResetTimer(); b.N > 0; b.N-- { + rec.Validate(L, tbl) + } +} + +func BenchmarkValidate_Record_Nested(b *testing.B) { + L := NewState() + defer L.Close() + addr := typ.NewRecord(). + Field("street", typ.String). + Field("zip", typ.String). + Build() + person := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Field("address", addr). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("name", LString("Alice")) + a := L.NewTable() + a.RawSetString("street", LString("Main St")) + a.RawSetString("zip", LString("12345")) + tbl.RawSetString("address", a) + for b.ResetTimer(); b.N > 0; b.N-- { + person.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Record :is() benchmark (error path allocation) +// --------------------------------------------------------------------------- + +func BenchmarkIs_Record_Pass(b *testing.B) { + L := NewState() + defer L.Close() + OpenErrors(L) + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + OptField("count", typ.Number). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("id", LString("abc")) + tbl.RawSetString("name", LString("test")) + tbl.RawSetString("count", LNumber(5)) + isMethod := L.typeGetField(rec, "is") + for b.ResetTimer(); b.N > 0; b.N-- { + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) + } +} + +func BenchmarkIs_Record_Fail(b *testing.B) { + L := NewState() + defer L.Close() + OpenErrors(L) + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("id", LNumber(123)) + tbl.RawSetString("name", LString("test")) + isMethod := L.typeGetField(rec, "is") + for b.ResetTimer(); b.N > 0; b.N-- { + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) + } +} + +// --------------------------------------------------------------------------- +// Array validation benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Array_10(b *testing.B) { + L := NewState() + defer L.Close() + arrType := <ype{inner: typ.NewArray(typ.Number)} + tbl := L.NewTable() + for i := 0; i < 10; i++ { + tbl.Append(LNumber(float64(i))) + } + for b.ResetTimer(); b.N > 0; b.N-- { + arrType.Validate(L, tbl) + } +} + +func BenchmarkValidate_Array_100(b *testing.B) { + L := NewState() + defer L.Close() + arrType := <ype{inner: typ.NewArray(typ.Number)} + tbl := L.NewTable() + for i := 0; i < 100; i++ { + tbl.Append(LNumber(float64(i))) + } + for b.ResetTimer(); b.N > 0; b.N-- { + arrType.Validate(L, tbl) + } +} + +func BenchmarkValidate_Array_1000(b *testing.B) { + L := NewState() + defer L.Close() + arrType := <ype{inner: typ.NewArray(typ.Number)} + tbl := L.NewTable() + for i := 0; i < 1000; i++ { + tbl.Append(LNumber(float64(i))) + } + for b.ResetTimer(); b.N > 0; b.N-- { + arrType.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Union and Literal benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Union_2Members(b *testing.B) { + L := NewState() + defer L.Close() + u := <ype{inner: typ.NewUnion(typ.Number, typ.String)} + for b.ResetTimer(); b.N > 0; b.N-- { + u.Validate(L, LString("hello")) + } +} + +func BenchmarkValidate_Union_5Literals(b *testing.B) { + L := NewState() + defer L.Close() + u := <ype{ + inner: typ.NewUnion( + typ.LiteralString("active"), + typ.LiteralString("draft"), + typ.LiteralString("archived"), + typ.LiteralString("deleted"), + typ.LiteralString("pending"), + ), + } + for b.ResetTimer(); b.N > 0; b.N-- { + u.Validate(L, LString("pending")) + } +} + +func BenchmarkValidate_Literal_String(b *testing.B) { + L := NewState() + defer L.Close() + lit := <ype{inner: typ.LiteralString("active")} + for b.ResetTimer(); b.N > 0; b.N-- { + lit.Validate(L, LString("active")) + } +} + +// --------------------------------------------------------------------------- +// Map validation benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Map_StringToNumber_10(b *testing.B) { + L := NewState() + defer L.Close() + mapType := <ype{inner: typ.NewMap(typ.String, typ.Number)} + tbl := L.NewTable() + for i := 0; i < 10; i++ { + tbl.RawSetString("key"+string(rune('a'+i)), LNumber(float64(i))) + } + for b.ResetTimer(); b.N > 0; b.N-- { + mapType.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Annotated type benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Annotated_MinMax(b *testing.B) { + L := NewState() + defer L.Close() + ann := <ype{ + inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(100)}, + }), + } + for b.ResetTimer(); b.N > 0; b.N-- { + ann.Validate(L, LNumber(50)) + } +} + +func BenchmarkValidate_Annotated_Pattern(b *testing.B) { + L := NewState() + defer L.Close() + ann := <ype{ + inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "pattern", Arg: "^[a-z]+$"}, + }), + } + for b.ResetTimer(); b.N > 0; b.N-- { + ann.Validate(L, LString("hello")) + } +} + +func BenchmarkValidate_Record_Annotated(b *testing.B) { + L := NewState() + defer L.Close() + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("name", typ.String, false, []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + {Name: "max_len", Arg: float64(100)}, + }). + AnnotatedField("age", typ.Number, false, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(150)}, + }). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("name", LString("Alice")) + tbl.RawSetString("age", LNumber(30)) + for b.ResetTimer(); b.N > 0; b.N-- { + rec.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Intersection benchmark +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Intersection(b *testing.B) { + L := NewState() + defer L.Close() + recA := typ.NewRecord().Field("x", typ.Number).Build() + recB := typ.NewRecord().Field("y", typ.String).Build() + inter := <ype{inner: typ.NewIntersection(recA, recB)} + tbl := L.NewTable() + tbl.RawSetString("x", LNumber(1)) + tbl.RawSetString("y", LString("hello")) + for b.ResetTimer(); b.N > 0; b.N-- { + inter.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Recursive type benchmark +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Recursive_Depth3(b *testing.B) { + L := NewState() + defer L.Close() + nodeType := typ.NewRecursive("Node", func(self typ.Type) typ.Type { + return typ.NewRecord(). + Field("value", typ.Number). + OptField("next", self). + Build() + }) + rt := <ype{inner: nodeType} + n3 := L.NewTable() + n3.RawSetString("value", LNumber(3)) + n2 := L.NewTable() + n2.RawSetString("value", LNumber(2)) + n2.RawSetString("next", n3) + n1 := L.NewTable() + n1.RawSetString("value", LNumber(1)) + n1.RawSetString("next", n2) + for b.ResetTimer(); b.N > 0; b.N-- { + rt.Validate(L, n1) + } +} + +// --------------------------------------------------------------------------- +// Ref resolution benchmark +// --------------------------------------------------------------------------- + +func BenchmarkValidate_RefResolution(b *testing.B) { + L := NewState() + defer L.Close() + resolver := &typeResolver{ + types: map[string]typ.Type{ + "Status": typ.NewUnion(typ.LiteralString("active"), typ.LiteralString("draft")), + }, + } + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("status", typ.NewRef("", "Status")). + Build(), + resolver: resolver, + } + tbl := L.NewTable() + tbl.RawSetString("id", LString("abc")) + tbl.RawSetString("status", LString("active")) + for b.ResetTimer(); b.N > 0; b.N-- { + rec.Validate(L, tbl) + } +} + +// --------------------------------------------------------------------------- +// Failure path benchmarks (how expensive are errors?) +// --------------------------------------------------------------------------- + +func BenchmarkValidate_Fail_TypeMismatch(b *testing.B) { + L := NewState() + defer L.Close() + for b.ResetTimer(); b.N > 0; b.N-- { + LTypeNumber.Validate(L, LString("bad")) + } +} + +func BenchmarkIs_Fail_TypeMismatch(b *testing.B) { + L := NewState() + defer L.Close() + OpenErrors(L) + isMethod := L.typeGetField(LTypeNumber, "is") + for b.ResetTimer(); b.N > 0; b.N-- { + L.Push(isMethod) + L.Push(LString("bad")) + L.Call(1, 2) + L.Pop(2) + } +} + +func BenchmarkIs_Fail_MissingField(b *testing.B) { + L := NewState() + defer L.Close() + OpenErrors(L) + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + Build(), + } + tbl := L.NewTable() + tbl.RawSetString("id", LString("abc")) + isMethod := L.typeGetField(rec, "is") + for b.ResetTimer(); b.N > 0; b.N-- { + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + L.Pop(2) + } +} diff --git a/ltype_edge_test.go b/ltype_edge_test.go new file mode 100644 index 00000000..c862b7ca --- /dev/null +++ b/ltype_edge_test.go @@ -0,0 +1,3772 @@ +package lua + +import ( + "fmt" + "strings" + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +// errMessage extracts the message string from a validation error. +func errMessage(errVal LValue) string { + if errVal == nil || errVal == LNil { + return "" + } + if e, ok := errVal.(*Error); ok { + return e.Message + } + return errVal.String() +} + +func errDetail(errVal LValue, key string) string { + if e, ok := errVal.(*Error); ok { + if d := e.Details(); d != nil { + if v, ok := d[key]; ok { + return fmt.Sprint(v) + } + } + } + return "" +} + +// errField extracts the field path from a validation error. +func errField(errVal LValue) string { + return errDetail(errVal, "field") +} + +// errExpected extracts the expected type from a validation error. +func errExpected(errVal LValue) string { + return errDetail(errVal, "expected") +} + +// errGot extracts the actual type from a validation error. +func errGot(errVal LValue) string { + return errDetail(errVal, "got") +} + +// errConstraint extracts the constraint name from a validation error. +func errConstraint(errVal LValue) string { + return errDetail(errVal, "constraint") +} + +// --------------------------------------------------------------------------- +// Group 1: Optional + every inner type kind +// --------------------------------------------------------------------------- + +func TestOptionalInterface_Table(t *testing.T) { + L := NewState() + defer L.Close() + + // table? — the exact pattern that fails in production + optTable := <ype{inner: typ.NewOptional(typ.NewInterface("table", nil))} + + tbl := L.NewTable() + tbl.RawSetString("key", LString("value")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"table passes", tbl, true}, + {"empty table passes", L.NewTable(), true}, + {"nil passes", LNil, true}, + {"string fails", LString("hello"), false}, + {"number fails", LNumber(42), false}, + {"boolean fails", LTrue, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optTable.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalInterface_TableIs(t *testing.T) { + L := NewState() + defer L.Close() + + // table? validated via :is() — the exact call path that fails + optTable := <ype{inner: typ.NewOptional(typ.NewInterface("table", nil))} + + tbl := L.NewTable() + tbl.RawSetString("key", LString("value")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"table passes", tbl, true}, + {"empty table passes", L.NewTable(), true}, + {"nil passes", LNil, true}, + {"string fails", LString("hello"), false}, + {"number fails", LNumber(42), false}, + } + + isMethod := L.typeGetField(optTable, "is") + if isMethod == LNil { + t.Fatal(":is method should not be nil") + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + L.Push(isMethod) + L.Push(tt.value) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if tt.ok { + // Success is indicated by errVal == nil, not val ~= nil. + // For optional types, nil is a valid value so val can be nil on success. + if errVal != LNil { + t.Errorf("expected nil error, got %v", errVal) + } + if val != tt.value { + t.Errorf("expected value %v, got %v", tt.value, val) + } + } else { + if val != LNil { + t.Errorf("expected nil value, got %v", val) + } + if errVal == LNil { + t.Error("expected error, got nil") + } + } + }) + } +} + +func TestOptionalRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // {x: number}? + inner := typ.NewRecord().Field("x", typ.Number).Build() + optRecord := <ype{inner: typ.NewOptional(inner)} + + valid := L.NewTable() + valid.RawSetString("x", LNumber(1)) + + invalid := L.NewTable() + invalid.RawSetString("x", LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid record passes", valid, true}, + {"nil passes", LNil, true}, + {"empty table fails (missing x)", L.NewTable(), false}, + {"invalid field type fails", invalid, false}, + {"string fails", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optRecord.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalArray(t *testing.T) { + L := NewState() + defer L.Close() + + // {number}? + optArray := <ype{inner: typ.NewOptional(typ.NewArray(typ.Number))} + + valid := L.NewTable() + valid.Append(LNumber(1)) + valid.Append(LNumber(2)) + + invalid := L.NewTable() + invalid.Append(LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid array passes", valid, true}, + {"nil passes", LNil, true}, + {"empty table passes", L.NewTable(), true}, + {"invalid element type fails", invalid, false}, + {"number fails", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optArray.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalMap(t *testing.T) { + L := NewState() + defer L.Close() + + // {[string]: number}? + optMap := <ype{inner: typ.NewOptional(typ.NewMap(typ.String, typ.Number))} + + valid := L.NewTable() + valid.RawSetString("a", LNumber(1)) + + invalid := L.NewTable() + invalid.RawSetString("a", LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid map passes", valid, true}, + {"nil passes", LNil, true}, + {"empty table passes", L.NewTable(), true}, + {"invalid value type fails", invalid, false}, + {"string fails", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optMap.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalUnion(t *testing.T) { + L := NewState() + defer L.Close() + + // (number | string)? + optUnion := <ype{inner: typ.NewOptional(typ.NewUnion(typ.Number, typ.String))} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes", LNumber(42), true}, + {"string passes", LString("hello"), true}, + {"nil passes", LNil, true}, + {"boolean fails", LTrue, false}, + {"table fails", <able{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optUnion.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalLiteral(t *testing.T) { + L := NewState() + defer L.Close() + + // "active"? + optLiteral := <ype{inner: typ.NewOptional(typ.LiteralString("active"))} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"matching literal passes", LString("active"), true}, + {"nil passes", LNil, true}, + {"non-matching string fails", LString("inactive"), false}, + {"number fails", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optLiteral.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalAnnotated(t *testing.T) { + L := NewState() + defer L.Close() + + // (number @min(0))? + annotated := typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + }) + optAnnotated := <ype{inner: typ.NewOptional(annotated)} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid number passes", LNumber(5), true}, + {"nil passes", LNil, true}, + {"negative fails annotation", LNumber(-1), false}, + {"string fails type", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optAnnotated.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalFunction(t *testing.T) { + L := NewState() + defer L.Close() + + // function? + optFunc := <ype{inner: typ.NewOptional(typ.Func().Param("x", typ.Number).Returns(typ.String).Build())} + + luaFn := L.NewFunction(func(L *LState) int { return 0 }) + goFn := LGoFunc(func(L *LState) int { return 0 }) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"lua function passes", luaFn, true}, + {"go function passes", goFn, true}, + {"nil passes", LNil, true}, + {"number fails", LNumber(42), false}, + {"table fails", L.NewTable(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optFunc.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 2: Record with optional table-typed fields (exact production scenario) +// --------------------------------------------------------------------------- + +func TestRecordWithOptionalTableFields(t *testing.T) { + L := NewState() + defer L.Close() + + // Mirrors the UpdateInput type from the user's binding + tableIface := typ.NewInterface("table", nil) + updateInput := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("name", typ.String). + OptField("meta", tableIface). + OptField("content", tableIface). + OptField("tags", typ.NewArray(typ.String)). + Build(), + name: "UpdateInput", + } + + // Full valid input + full := L.NewTable() + full.RawSetString("id", LString("abc")) + full.RawSetString("name", LString("test")) + meta := L.NewTable() + meta.RawSetString("key", LString("val")) + full.RawSetString("meta", meta) + content := L.NewTable() + content.RawSetString("body", LString("text")) + full.RawSetString("content", content) + tags := L.NewTable() + tags.Append(LString("tag1")) + full.RawSetString("tags", tags) + + // Minimal valid input (only required fields) + minimal := L.NewTable() + minimal.RawSetString("id", LString("abc")) + + // Missing required field + missingID := L.NewTable() + missingID.RawSetString("name", LString("test")) + + // Wrong type for optional table field + wrongContent := L.NewTable() + wrongContent.RawSetString("id", LString("abc")) + wrongContent.RawSetString("content", LString("not a table")) + + // Empty table as content (should pass) + emptyContent := L.NewTable() + emptyContent.RawSetString("id", LString("abc")) + emptyContent.RawSetString("content", L.NewTable()) + + // Nested table with arrays as content + nestedContent := L.NewTable() + nestedContent.RawSetString("id", LString("abc")) + inner := L.NewTable() + inner.RawSetString("items", L.NewTable()) + inner.RawGet(LString("items")).(*LTable).Append(LNumber(1)) + nestedContent.RawSetString("content", inner) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"full valid input", full, true}, + {"minimal valid input", minimal, true}, + {"missing required field fails", missingID, false}, + {"wrong type for optional table field fails", wrongContent, false}, + {"empty table as content passes", emptyContent, true}, + {"nested table as content passes", nestedContent, true}, + {"not a table fails", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateInput.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecordWithOptionalTableFieldsIs(t *testing.T) { + L := NewState() + defer L.Close() + + // Same structure but test via :is() for error message quality + tableIface := typ.NewInterface("table", nil) + updateInput := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("content", tableIface). + Build(), + name: "UpdateInput", + } + + isMethod := L.typeGetField(updateInput, "is") + + // content is a table — must pass + input := L.NewTable() + input.RawSetString("id", LString("abc")) + input.RawSetString("content", L.NewTable()) + + L.Push(isMethod) + L.Push(input) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("table field should validate; error: %v", errVal) + } + + // content is string — must fail with useful error + badInput := L.NewTable() + badInput.RawSetString("id", LString("abc")) + badInput.RawSetString("content", LString("not a table")) + + L.Push(isMethod) + L.Push(badInput) + L.Call(1, 2) + val = L.Get(-2) + errVal = L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Error("string content should fail validation") + } + if errVal == LNil { + t.Fatal("expected error message") + } + + errStr := errMessage(errVal) + if !strings.Contains(errStr, "content") { + t.Errorf("error should mention field name 'content', got: %s", errStr) + } + if strings.Contains(errStr, "expected table, got table") { + t.Errorf("error should not say 'expected table, got table', got: %s", errStr) + } +} + +// Record with type-level optional (field.Optional=false, field.Type=Optional(T)) +func TestRecordWithTypeLevelOptional(t *testing.T) { + L := NewState() + defer L.Close() + + // content field is non-optional, but type is table? + tableIface := typ.NewInterface("table", nil) + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("content", typ.NewOptional(tableIface)). + Build(), + } + + // content present as table + withContent := L.NewTable() + withContent.RawSetString("id", LString("abc")) + withContent.RawSetString("content", L.NewTable()) + + // content absent (nil) + withoutContent := L.NewTable() + withoutContent.RawSetString("id", LString("abc")) + + // content is wrong type + wrongContent := L.NewTable() + wrongContent.RawSetString("id", LString("abc")) + wrongContent.RawSetString("content", LNumber(42)) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"content present as table", withContent, true}, + {"content absent (nil passes optional)", withoutContent, true}, + {"content is wrong type", wrongContent, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 3: Interface type validation +// --------------------------------------------------------------------------- + +func TestInterfaceValidation(t *testing.T) { + L := NewState() + defer L.Close() + + tableType := <ype{inner: typ.NewInterface("table", nil)} + + plain := L.NewTable() + nested := L.NewTable() + nested.RawSetString("inner", L.NewTable()) + + arrayTable := L.NewTable() + arrayTable.Append(LNumber(1)) + arrayTable.Append(LNumber(2)) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"plain table", plain, true}, + {"nested table", nested, true}, + {"array-like table", arrayTable, true}, + {"empty table", L.NewTable(), true}, + {"string fails", LString("hello"), false}, + {"number fails", LNumber(42), false}, + {"nil fails", LNil, false}, + {"boolean fails", LTrue, false}, + {"function fails", LGoFunc(func(L *LState) int { return 0 }), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tableType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestInterfaceValidationIs(t *testing.T) { + L := NewState() + defer L.Close() + + tableType := <ype{inner: typ.NewInterface("table", nil)} + isMethod := L.typeGetField(tableType, "is") + + // table passes + L.Push(isMethod) + L.Push(L.NewTable()) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + if val == LNil { + t.Errorf("table should pass Interface validation; error: %v", errVal) + } + + // string fails with useful error + L.Push(isMethod) + L.Push(LString("not a table")) + L.Call(1, 2) + val = L.Get(-2) + errVal = L.Get(-1) + L.Pop(2) + if val != LNil { + t.Error("string should fail Interface validation") + } + if errVal == LNil { + t.Fatal("expected error") + } + errStr := errMessage(errVal) + if !strings.Contains(errStr, "table") { + t.Errorf("error should mention 'table', got: %s", errStr) + } + if !strings.Contains(errStr, "string") { + t.Errorf("error should mention 'string', got: %s", errStr) + } +} + +// --------------------------------------------------------------------------- +// Group 4: Error message quality +// --------------------------------------------------------------------------- + +func TestErrorMessages_FieldPath(t *testing.T) { + L := NewState() + defer L.Close() + + // Nested: {a: {b: {c: number}}} + inner := typ.NewRecord().Field("c", typ.Number).Build() + mid := typ.NewRecord().Field("b", inner).Build() + outer := <ype{inner: typ.NewRecord().Field("a", mid).Build()} + + bad := L.NewTable() + aTable := L.NewTable() + bTable := L.NewTable() + bTable.RawSetString("c", LString("not a number")) + aTable.RawSetString("b", bTable) + bad.RawSetString("a", aTable) + + isMethod := L.typeGetField(outer, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail validation") + } + errStr := errMessage(errVal) + if !strings.Contains(errStr, "a.b.c") { + t.Errorf("error should contain full path 'a.b.c', got: %s", errStr) + } +} + +func TestErrorMessages_ArrayElement(t *testing.T) { + L := NewState() + defer L.Close() + + arrType := <ype{inner: typ.NewArray(typ.Number)} + + bad := L.NewTable() + bad.Append(LNumber(1)) + bad.Append(LString("bad")) + bad.Append(LNumber(3)) + + isMethod := L.typeGetField(arrType, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail validation") + } + errStr := errMessage(errVal) + if !strings.Contains(errStr, "[2]") { + t.Errorf("error should contain element index '[2]', got: %s", errStr) + } +} + +func TestErrorMessages_ExpectedVsGot(t *testing.T) { + L := NewState() + defer L.Close() + + tests := []struct { + name string + typ *LType + value LValue + expectInErr string + rejectInErr string + }{ + { + "number rejects string", + LTypeNumber, LString("x"), + "expected number, got string", "", + }, + { + "string rejects number", + LTypeString, LNumber(1), + "expected string, got number", "", + }, + { + "boolean rejects number", + LTypeBoolean, LNumber(1), + "expected boolean, got number", "", + }, + { + "table rejects string", + <ype{inner: typ.NewInterface("table", nil)}, + LString("x"), + "expected table, got string", "", + }, + { + "record rejects number", + <ype{inner: typ.NewRecord().Field("x", typ.Number).Build()}, + LNumber(42), + "expected", "expected table, got table", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isMethod := L.typeGetField(tt.typ, "is") + L.Push(isMethod) + L.Push(tt.value) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail validation") + } + errStr := errMessage(errVal) + if tt.expectInErr != "" && !strings.Contains(errStr, tt.expectInErr) { + t.Errorf("error should contain %q, got: %s", tt.expectInErr, errStr) + } + if tt.rejectInErr != "" && strings.Contains(errStr, tt.rejectInErr) { + t.Errorf("error should NOT contain %q, got: %s", tt.rejectInErr, errStr) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 5: Complex nested types +// --------------------------------------------------------------------------- + +func TestRecordWithOptionalNestedRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // {name: string, address: {street: string, zip: string}?} + address := typ.NewRecord(). + Field("street", typ.String). + Field("zip", typ.String). + Build() + person := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + OptField("address", address). + Build(), + } + + withAddr := L.NewTable() + withAddr.RawSetString("name", LString("John")) + addr := L.NewTable() + addr.RawSetString("street", LString("Main St")) + addr.RawSetString("zip", LString("12345")) + withAddr.RawSetString("address", addr) + + withoutAddr := L.NewTable() + withoutAddr.RawSetString("name", LString("John")) + + badAddr := L.NewTable() + badAddr.RawSetString("name", LString("John")) + bad := L.NewTable() + bad.RawSetString("street", LNumber(123)) + badAddr.RawSetString("address", bad) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"with valid address", withAddr, true}, + {"without address", withoutAddr, true}, + {"with invalid address field", badAddr, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := person.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestArrayOfOptionalElements(t *testing.T) { + L := NewState() + defer L.Close() + + // {(number?)} — array where elements can be number or nil + arrOfOpt := <ype{inner: typ.NewArray(typ.NewOptional(typ.Number))} + + valid := L.NewTable() + valid.Append(LNumber(1)) + valid.Append(LNil) + valid.Append(LNumber(3)) + + // Array with string element should fail + invalid := L.NewTable() + invalid.Append(LNumber(1)) + invalid.Append(LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"array with nils passes", valid, true}, + {"array with string fails", invalid, false}, + {"empty array passes", L.NewTable(), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := arrOfOpt.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestMapWithOptionalValues(t *testing.T) { + L := NewState() + defer L.Close() + + // {[string]: number?} + mapOfOpt := <ype{inner: typ.NewMap(typ.String, typ.NewOptional(typ.Number))} + + valid := L.NewTable() + valid.RawSetString("a", LNumber(1)) + // Note: setting nil in strdict doesn't store it, so we test with present values + + invalid := L.NewTable() + invalid.RawSetString("a", LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid map passes", valid, true}, + {"map with wrong value type fails", invalid, false}, + {"empty map passes", L.NewTable(), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mapOfOpt.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestUnionOfRecords(t *testing.T) { + L := NewState() + defer L.Close() + + // {kind: "a", x: number} | {kind: "b", y: string} + recA := typ.NewRecord(). + Field("kind", typ.LiteralString("a")). + Field("x", typ.Number). + Build() + recB := typ.NewRecord(). + Field("kind", typ.LiteralString("b")). + Field("y", typ.String). + Build() + unionRec := <ype{inner: typ.NewUnion(recA, recB)} + + validA := L.NewTable() + validA.RawSetString("kind", LString("a")) + validA.RawSetString("x", LNumber(42)) + + validB := L.NewTable() + validB.RawSetString("kind", LString("b")) + validB.RawSetString("y", LString("hello")) + + invalidKind := L.NewTable() + invalidKind.RawSetString("kind", LString("c")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"record A passes", validA, true}, + {"record B passes", validB, true}, + {"unknown kind fails", invalidKind, false}, + {"non-table fails", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := unionRec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecordWithArrayField(t *testing.T) { + L := NewState() + defer L.Close() + + // {name: string, tags: {string}} + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Field("tags", typ.NewArray(typ.String)). + Build(), + } + + valid := L.NewTable() + valid.RawSetString("name", LString("test")) + tags := L.NewTable() + tags.Append(LString("a")) + tags.Append(LString("b")) + valid.RawSetString("tags", tags) + + invalidTag := L.NewTable() + invalidTag.RawSetString("name", LString("test")) + badTags := L.NewTable() + badTags.Append(LNumber(1)) + invalidTag.RawSetString("tags", badTags) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid record with array", valid, true}, + {"invalid array element type", invalidTag, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecordWithMapField(t *testing.T) { + L := NewState() + defer L.Close() + + // {config: {[string]: string}} + rec := <ype{ + inner: typ.NewRecord(). + Field("config", typ.NewMap(typ.String, typ.String)). + Build(), + } + + valid := L.NewTable() + config := L.NewTable() + config.RawSetString("key", LString("value")) + valid.RawSetString("config", config) + + invalid := L.NewTable() + badConfig := L.NewTable() + badConfig.RawSetString("key", LNumber(42)) + invalid.RawSetString("config", badConfig) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid map field", valid, true}, + {"invalid map value type", invalid, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 6: Record required vs optional field semantics +// --------------------------------------------------------------------------- + +func TestRecordRequiredFieldMissing(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("a", typ.String). + Field("b", typ.Number). + OptField("c", typ.Boolean). + Build(), + } + + // Only a provided — b missing + missing := L.NewTable() + missing.RawSetString("a", LString("hello")) + + if rec.Validate(L, missing) { + t.Error("missing required field 'b' should fail") + } + + // a and b provided, c optional + valid := L.NewTable() + valid.RawSetString("a", LString("hello")) + valid.RawSetString("b", LNumber(42)) + + if !rec.Validate(L, valid) { + t.Error("all required fields present should pass") + } +} + +func TestRecordOptionalFieldWrongType(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("count", typ.Number). + Build(), + } + + // count present but wrong type + bad := L.NewTable() + bad.RawSetString("id", LString("abc")) + bad.RawSetString("count", LString("not a number")) + + if rec.Validate(L, bad) { + t.Error("optional field with wrong type should fail") + } + + // Verify :is() gives useful error + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Error("should fail") + } + errStr := errMessage(errVal) + if !strings.Contains(errStr, "count") { + t.Errorf("error should reference field 'count', got: %s", errStr) + } +} + +func TestRecordAllFieldsOptional(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + OptField("a", typ.String). + OptField("b", typ.Number). + Build(), + } + + empty := L.NewTable() + if !rec.Validate(L, empty) { + t.Error("empty table should pass when all fields are optional") + } +} + +// --------------------------------------------------------------------------- +// Group 7: Tuple validation +// --------------------------------------------------------------------------- + +func TestTupleValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // (number, string, boolean) + tupleType := <ype{inner: typ.NewTuple(typ.Number, typ.String, typ.Boolean)} + + valid := L.NewTable() + valid.Append(LNumber(1)) + valid.Append(LString("hello")) + valid.Append(LTrue) + + wrongOrder := L.NewTable() + wrongOrder.Append(LString("hello")) + wrongOrder.Append(LNumber(1)) + wrongOrder.Append(LTrue) + + tooShort := L.NewTable() + tooShort.Append(LNumber(1)) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid tuple", valid, true}, + {"wrong element order", wrongOrder, false}, + {"missing elements", tooShort, false}, + {"not a table", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tupleType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 8: Integer edge cases +// --------------------------------------------------------------------------- + +func TestIntegerEdgeCases(t *testing.T) { + L := NewState() + defer L.Close() + + intType := LTypeInteger + + tests := []struct { + name string + value LValue + ok bool + }{ + {"LInteger passes", LInteger(42), true}, + {"whole LNumber passes", LNumber(42.0), true}, + {"fractional LNumber fails", LNumber(42.5), false}, + {"zero passes", LNumber(0), true}, + {"negative integer passes", LInteger(-1), true}, + {"negative whole number passes", LNumber(-5.0), true}, + {"string fails", LString("42"), false}, + {"boolean fails", LTrue, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := intType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 9: Alias and Ref type handling at runtime +// --------------------------------------------------------------------------- + +func TestAliasTypeValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // type Score = number + alias := typ.NewAlias("Score", typ.Number) + scoreType := <ype{inner: alias} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes alias", LNumber(42), true}, + {"integer passes alias", LInteger(42), true}, + {"string fails alias", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := scoreType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAliasTypeValidationIs(t *testing.T) { + L := NewState() + defer L.Close() + + alias := typ.NewAlias("Score", typ.Number) + scoreType := <ype{inner: alias} + + isMethod := L.typeGetField(scoreType, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("alias to number should pass; error: %v", errVal) + } + + L.Push(isMethod) + L.Push(LString("bad")) + L.Call(1, 2) + val = L.Get(-2) + errVal = L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Error("string should fail alias-to-number validation") + } + if errVal == LNil { + t.Fatal("expected error") + } +} + +func TestOptionalAliasType(t *testing.T) { + L := NewState() + defer L.Close() + + // type Score = number; Score? + alias := typ.NewAlias("Score", typ.Number) + optAlias := <ype{inner: typ.NewOptional(alias)} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes", LNumber(42), true}, + {"nil passes", LNil, true}, + {"string fails", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optAlias.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRefTypeWithResolver(t *testing.T) { + L := NewState() + defer L.Close() + + // Simulate a Ref that resolves to a known type + ref := typ.NewRef("", "Score") + refType := <ype{ + inner: ref, + resolver: &typeResolver{ + types: map[string]typ.Type{ + "Score": typ.Number, + }, + }, + } + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes resolved ref", LNumber(42), true}, + {"string fails resolved ref", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := refType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRefTypeWithResolverIs(t *testing.T) { + L := NewState() + defer L.Close() + + ref := typ.NewRef("", "Score") + refType := <ype{ + inner: ref, + resolver: &typeResolver{ + types: map[string]typ.Type{ + "Score": typ.Number, + }, + }, + } + + isMethod := L.typeGetField(refType, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("resolved ref should pass; error: %v", errVal) + } +} + +func TestRefTypeWithoutResolver(t *testing.T) { + L := NewState() + defer L.Close() + + // Unresolved Ref — should not silently accept everything + ref := typ.NewRef("", "Unknown") + refType := <ype{inner: ref} + + isMethod := L.typeGetField(refType, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + // An unresolved ref should fail validation with a useful error, + // not produce "expected Unknown, got number" + if val != LNil && errVal != LNil { + // Either it passes (permissive) or fails (strict) — but error must be useful + errStr := errMessage(errVal) + if strings.Contains(errStr, "expected "+ref.String()+", got") { + // This is a poor error — it means the ref was not resolved + t.Logf("unresolved ref produced fallthrough error: %s", errStr) + } + } +} + +// --------------------------------------------------------------------------- +// Group 10: Intersection type handling +// --------------------------------------------------------------------------- + +func TestIntersectionTypeValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // {x: number} & {y: string} — value must satisfy both + recA := typ.NewRecord().Field("x", typ.Number).Build() + recB := typ.NewRecord().Field("y", typ.String).Build() + intersection := <ype{inner: typ.NewIntersection(recA, recB)} + + // Has both x and y + valid := L.NewTable() + valid.RawSetString("x", LNumber(1)) + valid.RawSetString("y", LString("hello")) + + // Only has x + onlyX := L.NewTable() + onlyX.RawSetString("x", LNumber(1)) + + // Only has y + onlyY := L.NewTable() + onlyY.RawSetString("y", LString("hello")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"has both fields", valid, true}, + {"missing y", onlyX, false}, + {"missing x", onlyY, false}, + {"not a table", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := intersection.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestIntersectionTypeIs(t *testing.T) { + L := NewState() + defer L.Close() + + recA := typ.NewRecord().Field("x", typ.Number).Build() + recB := typ.NewRecord().Field("y", typ.String).Build() + intersection := <ype{inner: typ.NewIntersection(recA, recB)} + + valid := L.NewTable() + valid.RawSetString("x", LNumber(1)) + valid.RawSetString("y", LString("hello")) + + isMethod := L.typeGetField(intersection, "is") + L.Push(isMethod) + L.Push(valid) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("intersection validation should pass; error: %v", errVal) + } +} + +// --------------------------------------------------------------------------- +// Group 11: Annotated type inside optional inside record field +// --------------------------------------------------------------------------- + +func TestRecordWithAnnotatedOptionalField(t *testing.T) { + L := NewState() + defer L.Close() + + // {name: string, score: (number @min(0) @max(100))?} + annotatedNum := typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(100)}, + }) + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + OptField("score", annotatedNum). + Build(), + } + + valid := L.NewTable() + valid.RawSetString("name", LString("test")) + valid.RawSetString("score", LNumber(50)) + + noScore := L.NewTable() + noScore.RawSetString("name", LString("test")) + + tooHigh := L.NewTable() + tooHigh.RawSetString("name", LString("test")) + tooHigh.RawSetString("score", LNumber(150)) + + wrongType := L.NewTable() + wrongType.RawSetString("name", LString("test")) + wrongType.RawSetString("score", LString("fifty")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid score", valid, true}, + {"missing optional score", noScore, true}, + {"score exceeds max", tooHigh, false}, + {"score wrong type", wrongType, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 12: Literal type validation edge cases +// --------------------------------------------------------------------------- + +func TestLiteralTypeEdgeCases(t *testing.T) { + L := NewState() + defer L.Close() + + tests := []struct { + name string + typ *LType + value LValue + ok bool + }{ + // String literals + {"empty string literal matches", <ype{inner: typ.LiteralString("")}, LString(""), true}, + {"empty string literal rejects non-empty", <ype{inner: typ.LiteralString("")}, LString("x"), false}, + + // Number literals + {"zero literal matches", <ype{inner: typ.LiteralNumber(0)}, LNumber(0), true}, + {"negative literal matches", <ype{inner: typ.LiteralNumber(-1)}, LNumber(-1), true}, + + // Bool literals + {"true literal matches true", <ype{inner: typ.LiteralBool(true)}, LTrue, true}, + {"true literal rejects false", <ype{inner: typ.LiteralBool(true)}, LFalse, false}, + {"false literal matches false", <ype{inner: typ.LiteralBool(false)}, LFalse, true}, + + // Int64 literals with cross-type matching + {"int literal matches LInteger", <ype{inner: typ.LiteralInt(42)}, LInteger(42), true}, + {"int literal matches equivalent LNumber", <ype{inner: typ.LiteralInt(42)}, LNumber(42), true}, + {"int literal rejects different value", <ype{inner: typ.LiteralInt(42)}, LInteger(43), false}, + + // Type mismatches + {"string literal rejects number", <ype{inner: typ.LiteralString("42")}, LNumber(42), false}, + {"number literal rejects string", <ype{inner: typ.LiteralNumber(42)}, LString("42"), false}, + {"bool literal rejects number", <ype{inner: typ.LiteralBool(true)}, LNumber(1), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.typ.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 13: UserData handling +// --------------------------------------------------------------------------- + +func TestUserDataValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // UserData should not pass as table, number, string, etc. + ud := &LUserData{Value: "some data"} + + tests := []struct { + name string + typ *LType + ok bool + }{ + {"not a table", <ype{inner: typ.NewInterface("table", nil)}, false}, + {"not a number", LTypeNumber, false}, + {"not a string", LTypeString, false}, + {"passes any", LTypeAny, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.typ.Validate(L, ud); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 14: Never type +// --------------------------------------------------------------------------- + +func TestNeverType(t *testing.T) { + L := NewState() + defer L.Close() + + neverType := LTypeNever + + values := []LValue{LNil, LTrue, LNumber(0), LString(""), L.NewTable()} + for _, v := range values { + if neverType.Validate(L, v) { + t.Errorf("never should reject %v", v) + } + } +} + +// --------------------------------------------------------------------------- +// Group 15: Record with Ref fields (manifest scenario) +// Ref fields inside records simulate how types come from manifests where +// inter-type references are stored as typ.Ref. +// --------------------------------------------------------------------------- + +func TestRecordWithRefField_Resolved(t *testing.T) { + L := NewState() + defer L.Close() + + // Simulates: type Status = "active" | "draft" + // type Input = {id: string, status: Status?} + // At runtime, the Record stores status as Ref("Status") which the resolver maps. + statusType := typ.NewUnion(typ.LiteralString("active"), typ.LiteralString("draft")) + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("status", typ.NewRef("", "Status")). + Build(), + resolver: &typeResolver{ + types: map[string]typ.Type{ + "Status": statusType, + }, + }, + } + + valid := L.NewTable() + valid.RawSetString("id", LString("abc")) + valid.RawSetString("status", LString("active")) + + noStatus := L.NewTable() + noStatus.RawSetString("id", LString("abc")) + + badStatus := L.NewTable() + badStatus.RawSetString("id", LString("abc")) + badStatus.RawSetString("status", LString("unknown")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid status", valid, true}, + {"missing optional status", noStatus, true}, + {"invalid status value", badStatus, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecordWithRefField_ResolvedIs(t *testing.T) { + L := NewState() + defer L.Close() + + statusType := typ.NewUnion(typ.LiteralString("active"), typ.LiteralString("draft")) + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("status", typ.NewRef("", "Status")). + Build(), + resolver: &typeResolver{ + types: map[string]typ.Type{ + "Status": statusType, + }, + }, + } + + valid := L.NewTable() + valid.RawSetString("id", LString("abc")) + valid.RawSetString("status", LString("active")) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(valid) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("resolved Ref field should pass; error: %v", errVal) + } +} + +// Ref to "table" — the exact scenario where table? fails if resolver +// doesn't have "table" as a builtin +func TestRecordWithRefToTable_NoBuiltin(t *testing.T) { + L := NewState() + defer L.Close() + + // Record with content: Ref("table") — simulates what happens when + // the manifest stores table as a Ref instead of inline Interface + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("content", typ.NewRef("", "table")). + Build(), + // resolver does NOT have "table" — simulates missing builtin + resolver: &typeResolver{ + types: map[string]typ.Type{}, + }, + } + + input := L.NewTable() + input.RawSetString("id", LString("abc")) + input.RawSetString("content", L.NewTable()) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(input) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + // This is the exact scenario that produces "content: expected table, got table" + // The Ref("table") can't be resolved, falls through, and both type name and + // value name are "table". + if val == LNil { + errStr := "" + if errVal != LNil { + errStr = string(errVal.(LString)) + } + t.Errorf("unresolved Ref('table') should not reject a table; error: %s", errStr) + } +} + +// Same scenario but with the resolver containing the builtin +func TestRecordWithRefToTable_WithBuiltin(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + OptField("content", typ.NewRef("", "table")). + Build(), + resolver: &typeResolver{ + types: map[string]typ.Type{ + "table": typ.NewInterface("table", nil), + }, + }, + } + + input := L.NewTable() + input.RawSetString("id", LString("abc")) + input.RawSetString("content", L.NewTable()) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(input) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("resolved Ref('table') should pass; error: %v", errVal) + } +} + +// --------------------------------------------------------------------------- +// Group 16: Sum type handling +// --------------------------------------------------------------------------- + +func TestSumTypeValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // Sum types at runtime — currently unhandled + sumType := <ype{ + inner: typ.NewSum("Status", []typ.Variant{ + {Tag: "Active", Types: nil}, + {Tag: "Suspended", Types: nil}, + }), + } + + // Sum types use tables for runtime representation + tbl := L.NewTable() + tbl.RawSetString("tag", LString("Active")) + + // At minimum, sum validation should not panic and should + // produce a useful error or accept tables + result := sumType.Validate(L, tbl) + _ = result // Document behavior, don't assert specific outcome yet + + // Non-table should definitely fail + if sumType.Validate(L, LString("Active")) { + t.Error("sum type should reject non-table values") + } +} + +// --------------------------------------------------------------------------- +// Group 17: Unresolved Ref error messages +// --------------------------------------------------------------------------- + +func TestUnresolvedRefErrorMessage(t *testing.T) { + L := NewState() + defer L.Close() + + // Unresolved Ref should produce a clear error, not "expected X, got X" + refType := <ype{inner: typ.NewRef("", "SomeType")} + + isMethod := L.typeGetField(refType, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil && errVal == LNil { + // If it passes, that's one valid approach (permissive for unresolved) + return + } + + if errVal != LNil { + errStr := errMessage(errVal) + // The error should indicate the type is unresolved, not just + // "expected SomeType, got number" which is confusing + if !strings.Contains(errStr, "unresolved") { + t.Errorf("unresolved Ref error should mention 'unresolved', got: %s", errStr) + } + } +} + +// =========================================================================== +// STRUCTURED ERROR TESTS +// =========================================================================== + +// --------------------------------------------------------------------------- +// Group 18: Structured error fields +// --------------------------------------------------------------------------- + +func TestStructuredError_TypeMismatch(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Field("age", typ.Number). + Build(), + } + + bad := L.NewTable() + bad.RawSetString("name", LNumber(123)) + bad.RawSetString("age", LNumber(25)) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + // Verify structured fields + if errField(errVal) != "name" { + t.Errorf("field should be 'name', got %q", errField(errVal)) + } + if errExpected(errVal) != "string" { + t.Errorf("expected should be 'string', got %q", errExpected(errVal)) + } + if errGot(errVal) != "number" { + t.Errorf("got should be 'number', got %q", errGot(errVal)) + } +} + +func TestStructuredError_MissingRequired(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("id", typ.String). + Field("name", typ.String). + Build(), + } + + missing := L.NewTable() + missing.RawSetString("id", LString("abc")) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(missing) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + msg := errMessage(errVal) + if !strings.Contains(msg, "required") { + t.Errorf("error should mention 'required', got: %s", msg) + } + if errField(errVal) != "name" { + t.Errorf("field should be 'name', got %q", errField(errVal)) + } + if errExpected(errVal) != "string" { + t.Errorf("expected should be 'string', got %q", errExpected(errVal)) + } +} + +func TestStructuredError_NestedField(t *testing.T) { + L := NewState() + defer L.Close() + + inner := typ.NewRecord().Field("zip", typ.String).Build() + outer := <ype{inner: typ.NewRecord().Field("addr", inner).Build()} + + bad := L.NewTable() + a := L.NewTable() + a.RawSetString("zip", LNumber(12345)) + bad.RawSetString("addr", a) + + isMethod := L.typeGetField(outer, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + if errField(errVal) != "addr.zip" { + t.Errorf("field should be 'addr.zip', got %q", errField(errVal)) + } + if errExpected(errVal) != "string" { + t.Errorf("expected should be 'string', got %q", errExpected(errVal)) + } + if errGot(errVal) != "number" { + t.Errorf("got should be 'number', got %q", errGot(errVal)) + } +} + +func TestStructuredError_ArrayElement(t *testing.T) { + L := NewState() + defer L.Close() + + arrType := <ype{inner: typ.NewArray(typ.Number)} + + bad := L.NewTable() + bad.Append(LNumber(1)) + bad.Append(LString("bad")) + + isMethod := L.typeGetField(arrType, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + if errField(errVal) != "[2]" { + t.Errorf("field should be '[2]', got %q", errField(errVal)) + } + if errExpected(errVal) != "number" { + t.Errorf("expected should be 'number', got %q", errExpected(errVal)) + } +} + +func TestStructuredError_ConstraintViolation(t *testing.T) { + L := NewState() + defer L.Close() + + annotated := <ype{ + inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + }), + } + + isMethod := L.typeGetField(annotated, "is") + L.Push(isMethod) + L.Push(LNumber(-5)) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + if errConstraint(errVal) != "min" { + t.Errorf("constraint should be 'min', got %q", errConstraint(errVal)) + } +} + +func TestStructuredError_NestedConstraint(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("score", typ.Number, false, []typ.Annotation{ + {Name: "max", Arg: float64(100)}, + }). + Build(), + } + + bad := L.NewTable() + bad.RawSetString("score", LNumber(150)) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + if errField(errVal) != "score" { + t.Errorf("field should be 'score', got %q", errField(errVal)) + } + if errConstraint(errVal) != "max" { + t.Errorf("constraint should be 'max', got %q", errConstraint(errVal)) + } +} + +func TestStructuredError_TostringMetamethod(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + L.SetGlobal("Number", LTypeNumber) + + err := L.DoString(` + local val, err = Number:is("hello") + assert(val == nil, "should fail") + assert(err ~= nil, "should have error") + local msg = tostring(err) + assert(type(msg) == "string", "tostring should return string, got " .. type(msg)) + assert(msg:find("expected"), "message should contain 'expected', got: " .. msg) + `) + if err != nil { + t.Fatalf("tostring test failed: %v", err) + } +} + +func TestStructuredError_ConcatMetamethod(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + L.SetGlobal("Number", LTypeNumber) + + err := L.DoString(` + local val, err = Number:is("hello") + local msg1 = "validation failed: " .. err + assert(type(msg1) == "string", "concat should produce string") + assert(msg1:find("expected"), "concat should contain error message") + + local msg2 = err .. " (fatal)" + assert(type(msg2) == "string", "concat should produce string") + `) + if err != nil { + t.Fatalf("concat test failed: %v", err) + } +} + +func TestStructuredError_ErrorMethods(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Build(), + name: "TestRec", + } + L.SetGlobal("TestRec", rec) + + err := L.DoString(` + local val, err = TestRec:is({name = 123}) + assert(val == nil) + assert(err ~= nil) + + -- kind should be Invalid + assert(err:kind() == "Invalid", "kind should be 'Invalid', got: " .. tostring(err:kind())) + + -- message method + local msg = err:message() + assert(msg:find("expected string"), "message should mention 'expected string'") + + -- details method returns structured info + local d = err:details() + assert(d ~= nil, "details should not be nil") + assert(d.field == "name", "details.field should be 'name', got: " .. tostring(d.field)) + assert(d.expected == "string", "details.expected should be 'string', got: " .. tostring(d.expected)) + assert(d.got == "number", "details.got should be 'number', got: " .. tostring(d.got)) + `) + if err != nil { + t.Fatalf("error methods test failed: %v", err) + } +} + +func TestStructuredError_UnresolvedRef(t *testing.T) { + L := NewState() + defer L.Close() + + refType := <ype{inner: typ.NewRef("", "Missing")} + + isMethod := L.typeGetField(refType, "is") + L.Push(isMethod) + L.Push(LNumber(42)) + L.Call(1, 2) + _ = L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + msg := errMessage(errVal) + if !strings.Contains(msg, "unresolved") { + t.Errorf("should mention 'unresolved', got: %s", msg) + } + if !strings.Contains(msg, "Missing") { + t.Errorf("should mention type name 'Missing', got: %s", msg) + } +} + +// --------------------------------------------------------------------------- +// Group 19: Record with map component +// --------------------------------------------------------------------------- + +func TestRecordWithMapComponent(t *testing.T) { + L := NewState() + defer L.Close() + + // {name: string, [string]: number} — record with known fields AND dynamic map + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + MapComponent(typ.String, typ.Number). + Build(), + } + + valid := L.NewTable() + valid.RawSetString("name", LString("test")) + valid.RawSetString("score", LNumber(42)) + valid.RawSetString("count", LNumber(7)) + + badMapVal := L.NewTable() + badMapVal.RawSetString("name", LString("test")) + badMapVal.RawSetString("extra", LString("not a number")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid with extra number fields", valid, true}, + {"invalid extra field type", badMapVal, false}, + {"only required fields", func() LValue { + t := L.NewTable() + t.RawSetString("name", LString("x")) + return t + }(), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecordWithMapComponentIs(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + MapComponent(typ.String, typ.Number). + Build(), + } + + bad := L.NewTable() + bad.RawSetString("name", LString("test")) + bad.RawSetString("extra", LString("not a number")) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Error("should fail") + } + if errField(errVal) == "" { + t.Error("error should have a field path") + } +} + +// --------------------------------------------------------------------------- +// Group 20: Edge cases for number/integer cross-type validation +// --------------------------------------------------------------------------- + +func TestNumberIntegerCrossType(t *testing.T) { + L := NewState() + defer L.Close() + + tests := []struct { + name string + typ *LType + value LValue + ok bool + }{ + // number accepts both + {"number accepts LNumber", LTypeNumber, LNumber(42.5), true}, + {"number accepts LInteger", LTypeNumber, LInteger(42), true}, + {"number accepts LNumber zero", LTypeNumber, LNumber(0), true}, + {"number accepts LInteger zero", LTypeNumber, LInteger(0), true}, + + // integer is strict + {"integer accepts LInteger", LTypeInteger, LInteger(42), true}, + {"integer accepts whole LNumber", LTypeInteger, LNumber(42.0), true}, + {"integer rejects fractional", LTypeInteger, LNumber(42.5), false}, + {"integer rejects NaN-like", LTypeInteger, LNumber(0.1 + 0.2), false}, // 0.30000000000000004 + + // edge: very large numbers + {"integer accepts max int", LTypeInteger, LInteger(9223372036854775807), true}, + {"integer accepts min int", LTypeInteger, LInteger(-9223372036854775808), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.typ.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 21: Union type with literals (discriminated union pattern) +// --------------------------------------------------------------------------- + +func TestUnionLiterals(t *testing.T) { + L := NewState() + defer L.Close() + + // "active" | "draft" | "archived" + statusUnion := <ype{ + inner: typ.NewUnion( + typ.LiteralString("active"), + typ.LiteralString("draft"), + typ.LiteralString("archived"), + ), + } + + tests := []struct { + name string + value LValue + ok bool + }{ + {"active passes", LString("active"), true}, + {"draft passes", LString("draft"), true}, + {"archived passes", LString("archived"), true}, + {"unknown fails", LString("deleted"), false}, + {"number fails", LNumber(1), false}, + {"empty string fails", LString(""), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := statusUnion.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestUnionLiteralsErrorDetail(t *testing.T) { + L := NewState() + defer L.Close() + + statusUnion := <ype{ + inner: typ.NewUnion( + typ.LiteralString("active"), + typ.LiteralString("draft"), + ), + } + + isMethod := L.typeGetField(statusUnion, "is") + L.Push(isMethod) + L.Push(LString("invalid")) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + + msg := errMessage(errVal) + // Error should show the union type, not individual members + if !strings.Contains(msg, "expected") { + t.Errorf("should contain 'expected', got: %s", msg) + } + if errGot(errVal) != "string" { + t.Errorf("got should be 'string', got: %q", errGot(errVal)) + } +} + +// --------------------------------------------------------------------------- +// Group 22: Optional(Optional(T)) normalization +// --------------------------------------------------------------------------- + +func TestDoubleOptional(t *testing.T) { + L := NewState() + defer L.Close() + + // number?? should normalize to number? (NewOptional already handles this) + inner := typ.NewOptional(typ.Number) + outer := typ.NewOptional(inner) + + doubleOpt := <ype{inner: outer} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes", LNumber(42), true}, + {"nil passes", LNil, true}, + {"string fails", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := doubleOpt.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 23: Ref chain resolution +// --------------------------------------------------------------------------- + +func TestRefChainResolution(t *testing.T) { + L := NewState() + defer L.Close() + + // A -> B -> number (alias chain through refs) + resolver := &typeResolver{ + types: map[string]typ.Type{ + "A": typ.NewAlias("A", typ.NewRef("", "B")), + "B": typ.NewAlias("B", typ.Number), + }, + } + + refType := <ype{ + inner: typ.NewRef("", "A"), + resolver: resolver, + } + + tests := []struct { + name string + value LValue + ok bool + }{ + {"number passes through chain", LNumber(42), true}, + {"string fails through chain", LString("hello"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := refType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 24: Full Lua integration with structured errors +// --------------------------------------------------------------------------- + +func TestLuaIntegration_StructuredErrors(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + personType := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Field("age", typ.Number). + OptField("email", typ.String). + Build(), + name: "Person", + } + L.SetGlobal("Person", personType) + + err := L.DoString(` + -- Valid input + local p, err = Person:is({name = "Alice", age = 30}) + assert(p ~= nil, "valid person should pass") + assert(err == nil, "valid person should have no error") + + -- Missing required field + local p2, err2 = Person:is({name = "Bob"}) + assert(p2 == nil, "missing age should fail") + assert(err2 ~= nil, "should have error") + local d2 = err2:details() + assert(d2.field == "age", "field should be 'age', got: " .. tostring(d2.field)) + assert(err2:message():find("required"), "should mention 'required': " .. err2:message()) + + -- Wrong type + local p3, err3 = Person:is({name = 123, age = 30}) + assert(p3 == nil, "wrong name type should fail") + local d3 = err3:details() + assert(d3.field == "name", "field should be 'name'") + assert(d3.expected == "string", "expected should be 'string'") + assert(d3.got == "number", "got should be 'number'") + + -- Optional field present but wrong type + local p4, err4 = Person:is({name = "Carol", age = 25, email = 123}) + assert(p4 == nil, "wrong email type should fail") + local d4 = err4:details() + assert(d4.field == "email", "field should be 'email'") + + -- Not a table + local p5, err5 = Person:is("not a table") + assert(p5 == nil, "string should fail") + local d5 = err5:details() + assert(d5.got == "string", "got should be 'string'") + + -- Error kind is Invalid + assert(err5:kind() == "Invalid", "kind should be Invalid") + `) + if err != nil { + t.Fatalf("Lua integration test failed: %v", err) + } +} + +func TestLuaIntegration_NestedStructuredErrors(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + addrType := typ.NewRecord(). + Field("street", typ.String). + Field("zip", typ.String). + Build() + personType := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Field("address", addrType). + Build(), + name: "Person", + } + L.SetGlobal("Person", personType) + + err := L.DoString(` + local p, err = Person:is({ + name = "Alice", + address = { street = "Main St", zip = 12345 } + }) + assert(p == nil, "should fail") + local d = err:details() + assert(d.field == "address.zip", "field should be 'address.zip', got: " .. tostring(d.field)) + assert(d.expected == "string", "expected should be 'string'") + assert(d.got == "number", "got should be 'number'") + `) + if err != nil { + t.Fatalf("nested structured errors test failed: %v", err) + } +} + +func TestLuaIntegration_AnnotationErrors(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("score", typ.Number, false, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(100)}, + }). + AnnotatedField("name", typ.String, false, []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + }). + Build(), + name: "Input", + } + L.SetGlobal("Input", rec) + + err := L.DoString(` + -- Score below min + local v, err = Input:is({score = -5, name = "test"}) + assert(v == nil) + local d = err:details() + assert(d.field == "score", "field should be 'score', got: " .. tostring(d.field)) + assert(d.constraint == "min", "constraint should be 'min', got: " .. tostring(d.constraint)) + + -- Name too short + local v2, err2 = Input:is({score = 50, name = ""}) + assert(v2 == nil) + local d2 = err2:details() + assert(d2.field == "name", "field should be 'name', got: " .. tostring(d2.field)) + assert(d2.constraint == "min_len", "constraint should be 'min_len', got: " .. tostring(d2.constraint)) + + -- All valid + local v3, err3 = Input:is({score = 50, name = "test"}) + assert(v3 ~= nil, "should pass") + assert(err3 == nil, "should have no error") + `) + if err != nil { + t.Fatalf("annotation errors test failed: %v", err) + } +} + +// =========================================================================== +// REMAINING TYPE COVERAGE TESTS +// =========================================================================== + +// --------------------------------------------------------------------------- +// Group 25: Recursive type validation +// --------------------------------------------------------------------------- + +func TestRecursiveTypeValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // type Node = { value: number, next: Node? } + nodeType := typ.NewRecursive("Node", func(self typ.Type) typ.Type { + return typ.NewRecord(). + Field("value", typ.Number). + OptField("next", self). + Build() + }) + + recType := <ype{inner: nodeType, name: "Node"} + + // Single node + single := L.NewTable() + single.RawSetString("value", LNumber(1)) + + // Two-node chain + second := L.NewTable() + second.RawSetString("value", LNumber(2)) + chain := L.NewTable() + chain.RawSetString("value", LNumber(1)) + chain.RawSetString("next", second) + + // Invalid node (value is string) + invalid := L.NewTable() + invalid.RawSetString("value", LString("bad")) + + // Invalid next (not a table) + badNext := L.NewTable() + badNext.RawSetString("value", LNumber(1)) + badNext.RawSetString("next", LString("not a table")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"single node", single, true}, + {"two-node chain", chain, true}, + {"invalid value type", invalid, false}, + {"invalid next type", badNext, false}, + {"not a table", LNumber(42), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := recType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestRecursiveTypeValidationIs(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + nodeType := typ.NewRecursive("Node", func(self typ.Type) typ.Type { + return typ.NewRecord(). + Field("value", typ.Number). + OptField("next", self). + Build() + }) + recType := <ype{inner: nodeType, name: "Node"} + + chain := L.NewTable() + chain.RawSetString("value", LNumber(1)) + next := L.NewTable() + next.RawSetString("value", LNumber(2)) + chain.RawSetString("next", next) + + isMethod := L.typeGetField(recType, "is") + L.Push(isMethod) + L.Push(chain) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val == LNil { + t.Errorf("recursive type should validate chain; error: %v", errMessage(errVal)) + } +} + +func TestRecursiveOptionalType(t *testing.T) { + L := NewState() + defer L.Close() + + // Node? — optional recursive + nodeType := typ.NewRecursive("Node", func(self typ.Type) typ.Type { + return typ.NewRecord(). + Field("value", typ.Number). + OptField("next", self). + Build() + }) + optNode := <ype{inner: typ.NewOptional(nodeType)} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"nil passes", LNil, true}, + {"valid node passes", func() LValue { + t := L.NewTable() + t.RawSetString("value", LNumber(1)) + return t + }(), true}, + {"string fails", LString("nope"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optNode.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 26: Sum type validation +// --------------------------------------------------------------------------- + +func TestSumTypeValidationFull(t *testing.T) { + L := NewState() + defer L.Close() + + sumType := <ype{ + inner: typ.NewSum("Color", []typ.Variant{ + {Tag: "Red", Types: nil}, + {Tag: "Green", Types: nil}, + {Tag: "RGB", Types: []typ.Type{typ.Number, typ.Number, typ.Number}}, + }), + name: "Color", + } + + tbl := L.NewTable() + tbl.RawSetString("tag", LString("Red")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"table passes", tbl, true}, + {"empty table passes", L.NewTable(), true}, + {"string fails", LString("Red"), false}, + {"number fails", LNumber(1), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sumType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestSumTypeIs(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + sumType := <ype{ + inner: typ.NewSum("Result", []typ.Variant{ + {Tag: "Ok", Types: []typ.Type{typ.String}}, + {Tag: "Err", Types: []typ.Type{typ.String}}, + }), + name: "Result", + } + + isMethod := L.typeGetField(sumType, "is") + + // Table passes + tbl := L.NewTable() + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + if val == LNil { + t.Errorf("sum should accept table; error: %v", errMessage(errVal)) + } + + // Non-table fails with structured error + L.Push(isMethod) + L.Push(LString("not a table")) + L.Call(1, 2) + val = L.Get(-2) + errVal = L.Get(-1) + L.Pop(2) + if val != LNil { + t.Error("sum should reject string") + } + if errGot(errVal) != "string" { + t.Errorf("got should be 'string', got %q", errGot(errVal)) + } +} + +// --------------------------------------------------------------------------- +// Group 27: Platform type validation +// --------------------------------------------------------------------------- + +func TestPlatformTypeValidation(t *testing.T) { + L := NewState() + defer L.Close() + + fileType := <ype{inner: typ.NewPlatform("File"), name: "File"} + + ud := &LUserData{Value: "file handle"} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"userdata passes", ud, true}, + {"table fails", L.NewTable(), false}, + {"string fails", LString("file"), false}, + {"number fails", LNumber(0), false}, + {"nil fails", LNil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fileType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestOptionalPlatformType(t *testing.T) { + L := NewState() + defer L.Close() + + optFile := <ype{inner: typ.NewOptional(typ.NewPlatform("File"))} + ud := &LUserData{Value: "file handle"} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"userdata passes", ud, true}, + {"nil passes", LNil, true}, + {"string fails", LString("file"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := optFile.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 28: Annotation edge cases +// --------------------------------------------------------------------------- + +func TestAnnotation_OnOptionalField(t *testing.T) { + L := NewState() + defer L.Close() + + // {name: string @min_len(1)?} — optional field with annotated type + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("name", typ.String, true, []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + }). + Build(), + } + + // Missing optional field — should pass + empty := L.NewTable() + if !rec.Validate(L, empty) { + t.Error("missing optional annotated field should pass") + } + + // Present but valid + valid := L.NewTable() + valid.RawSetString("name", LString("hello")) + if !rec.Validate(L, valid) { + t.Error("valid annotated field should pass") + } + + // Present but fails annotation + tooShort := L.NewTable() + tooShort.RawSetString("name", LString("")) + if rec.Validate(L, tooShort) { + t.Error("empty name should fail min_len annotation") + } +} + +func TestAnnotation_PatternOnField(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("email", typ.String, false, []typ.Annotation{ + {Name: "pattern", Arg: "^[^@]+@[^@]+$"}, + }). + Build(), + } + + valid := L.NewTable() + valid.RawSetString("email", LString("user@example.com")) + + bad := L.NewTable() + bad.RawSetString("email", LString("invalid")) + + isMethod := L.typeGetField(rec, "is") + + // Valid + L.Push(isMethod) + L.Push(valid) + L.Call(1, 2) + val := L.Get(-2) + L.Pop(2) + if val == LNil { + t.Error("valid email should pass") + } + + // Invalid + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val = L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + if val != LNil { + t.Error("invalid email should fail") + } + if errConstraint(errVal) != "pattern" { + t.Errorf("constraint should be 'pattern', got %q", errConstraint(errVal)) + } + if errField(errVal) != "email" { + t.Errorf("field should be 'email', got %q", errField(errVal)) + } +} + +func TestAnnotation_MultipleOnSameField(t *testing.T) { + L := NewState() + defer L.Close() + + // score: number @min(0) @max(100) — multiple annotations + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("score", typ.Number, false, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(100)}, + }). + Build(), + } + + tests := []struct { + name string + score float64 + ok bool + }{ + {"in range", 50, true}, + {"at min", 0, true}, + {"at max", 100, true}, + {"below min", -1, false}, + {"above max", 101, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tbl := L.NewTable() + tbl.RawSetString("score", LNumber(tt.score)) + if got := rec.Validate(L, tbl); got != tt.ok { + t.Errorf("Validate(%v) = %v, want %v", tt.score, got, tt.ok) + } + }) + } +} + +func TestAnnotation_MinLenOnArray(t *testing.T) { + L := NewState() + defer L.Close() + + // {string} @min_len(1) — array must have at least 1 element + arrType := <ype{ + inner: typ.NewAnnotated(typ.NewArray(typ.String), []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + }), + } + + empty := L.NewTable() + nonempty := L.NewTable() + nonempty.Append(LString("hello")) + + if arrType.Validate(L, empty) { + t.Error("empty array should fail min_len(1)") + } + if !arrType.Validate(L, nonempty) { + t.Error("non-empty array should pass min_len(1)") + } +} + +// --------------------------------------------------------------------------- +// Group 29: Map with number keys — Array part +// --------------------------------------------------------------------------- + +func TestMapNumberKeys_ArrayPart(t *testing.T) { + L := NewState() + defer L.Close() + + // {[number]: string} — number-keyed map + numMap := <ype{inner: typ.NewMap(typ.Number, typ.String)} + + // Table with entries in Array part (via Append) + tbl := L.NewTable() + tbl.Append(LString("first")) + tbl.Append(LString("second")) + + // Array part entries have implicit integer keys 1, 2, ... + // For a {[number]: string} map, these should be valid + // Currently only Dict and Strdict are checked — Array is missed + if !numMap.Validate(L, tbl) { + t.Error("{[number]: string} should accept table with array-part string values") + } +} + +// --------------------------------------------------------------------------- +// Group 30: Record field lookup in Dict (non-Strdict) +// --------------------------------------------------------------------------- + +func TestRecordFieldLookupDict(t *testing.T) { + L := NewState() + defer L.Close() + + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + Build(), + } + + // Table with "name" stored via RawSet with LString key (goes to Dict, not Strdict) + tbl := L.NewTable() + tbl.RawSet(LString("name"), LString("hello")) + + // This entry is in Strdict because RawSet with LString puts it in Strdict + // (at least in this implementation). Verify the table works: + if !rec.Validate(L, tbl) { + t.Error("record should find field in table set via RawSet(LString)") + } +} + +// --------------------------------------------------------------------------- +// Group 31: Open record accepts unknown fields +// --------------------------------------------------------------------------- + +func TestOpenRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // Open record: {name: string, ...} + rec := <ype{ + inner: typ.NewRecord(). + Field("name", typ.String). + SetOpen(true). + Build(), + } + + // Table with extra fields + tbl := L.NewTable() + tbl.RawSetString("name", LString("test")) + tbl.RawSetString("extra", LNumber(42)) + tbl.RawSetString("another", LTrue) + + if !rec.Validate(L, tbl) { + t.Error("open record should accept extra fields") + } +} + +// --------------------------------------------------------------------------- +// Group 32: Empty record +// --------------------------------------------------------------------------- + +func TestEmptyRecord(t *testing.T) { + L := NewState() + defer L.Close() + + // {} — empty record accepts any table + rec := <ype{inner: typ.NewRecord().Build()} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"empty table", L.NewTable(), true}, + {"table with fields", func() LValue { + t := L.NewTable() + t.RawSetString("x", LNumber(1)) + return t + }(), true}, + {"not a table", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 33: Instantiated generic at runtime +// --------------------------------------------------------------------------- + +func TestInstantiatedGenericValidation(t *testing.T) { + L := NewState() + defer L.Close() + + // Generic Array instantiated as Array + generic := typ.NewGeneric("Array", []*typ.TypeParam{ + typ.NewTypeParam("T", nil), + }, typ.NewArray(typ.NewTypeParam("T", nil))) + + instantiated := typ.Instantiate(generic, typ.Number) + instType := <ype{inner: instantiated} + + valid := L.NewTable() + valid.Append(LNumber(1)) + valid.Append(LNumber(2)) + + invalid := L.NewTable() + invalid.Append(LString("bad")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"valid number array", valid, true}, + {"invalid string element", invalid, false}, + {"not a table", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := instType.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 34: Annotation validator edge cases +// --------------------------------------------------------------------------- + +func TestAnnotation_MinOnInteger(t *testing.T) { + L := NewState() + defer L.Close() + + ann := <ype{inner: typ.NewAnnotated(typ.Integer, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + })} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"positive LInteger", LInteger(42), true}, + {"zero LInteger", LInteger(0), true}, + {"negative LInteger", LInteger(-1), false}, + {"positive LNumber whole", LNumber(5.0), true}, + {"negative LNumber whole", LNumber(-5.0), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAnnotation_MaxOnInteger(t *testing.T) { + L := NewState() + defer L.Close() + + ann := <ype{inner: typ.NewAnnotated(typ.Integer, []typ.Annotation{ + {Name: "max", Arg: float64(100)}, + })} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"within range", LInteger(50), true}, + {"at max", LInteger(100), true}, + {"above max", LInteger(101), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAnnotation_MinLenOnString(t *testing.T) { + L := NewState() + defer L.Close() + + ann := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "min_len", Arg: float64(3)}, + })} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"exact length", LString("abc"), true}, + {"longer", LString("abcdef"), true}, + {"too short", LString("ab"), false}, + {"empty", LString(""), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAnnotation_MaxLenZero(t *testing.T) { + L := NewState() + defer L.Close() + + ann := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "max_len", Arg: float64(0)}, + })} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"empty string passes", LString(""), true}, + {"non-empty fails", LString("a"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAnnotation_MinLenOnTable(t *testing.T) { + L := NewState() + defer L.Close() + + // @min_len on table checks LTable.Len() which counts the array part + ann := <ype{inner: typ.NewAnnotated(typ.NewInterface("table", nil), []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + })} + + empty := L.NewTable() + withArray := L.NewTable() + withArray.Append(LString("a")) + + // Table with only strdict entries — Len() returns 0 + strdictOnly := L.NewTable() + strdictOnly.RawSetString("key", LString("val")) + + tests := []struct { + name string + value LValue + ok bool + }{ + {"empty table fails", empty, false}, + {"table with array element passes", withArray, true}, + // Len() only counts array part, strdict-only table has Len()=0 + {"table with only strdict fails min_len", strdictOnly, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ann.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestAnnotation_PatternEdgeCases(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + tests := []struct { + name string + pattern string + value string + ok bool + }{ + {"dot star matches anything", ".*", "anything", true}, + {"dot star matches empty", ".*", "", true}, + {"caret dollar exact", "^hello$", "hello", true}, + {"caret dollar rejects extra", "^hello$", "hello world", false}, + {"email-like pattern", "^[^@]+@[^@]+\\.[^@]+$", "user@example.com", true}, + {"email-like rejects bare", "^[^@]+@[^@]+\\.[^@]+$", "notanemail", false}, + {"uuid pattern", "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "550e8400-e29b-41d4-a716-446655440000", true}, + {"uuid pattern rejects", "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "not-a-uuid", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ann := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "pattern", Arg: tt.pattern}, + })} + if got := ann.Validate(L, LString(tt.value)); got != tt.ok { + t.Errorf("Validate(%q) = %v, want %v", tt.value, got, tt.ok) + } + }) + } +} + +func TestAnnotation_PatternStructuredError(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("code", typ.String, false, []typ.Annotation{ + {Name: "pattern", Arg: "^[A-Z]{3}$"}, + }). + Build(), + } + + bad := L.NewTable() + bad.RawSetString("code", LString("lowercase")) + + isMethod := L.typeGetField(rec, "is") + L.Push(isMethod) + L.Push(bad) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Fatal("should fail") + } + if errField(errVal) != "code" { + t.Errorf("field=%q, want 'code'", errField(errVal)) + } + if errConstraint(errVal) != "pattern" { + t.Errorf("constraint=%q, want 'pattern'", errConstraint(errVal)) + } +} + +func TestAnnotation_WrongBaseType(t *testing.T) { + L := NewState() + defer L.Close() + + // @min on a string type — the min validator silently passes + // because toNumber fails on string. The TYPE check catches the error. + ann := <ype{inner: typ.NewAnnotated(typ.String, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + })} + + // String value: type check passes, min annotation silently passes (can't extract number) + if !ann.Validate(L, LString("hello")) { + t.Error("string @min(0) should pass for string values (min is a no-op on strings)") + } + + // Number value: type check (string) fails before annotations run + if ann.Validate(L, LNumber(42)) { + t.Error("string @min(0) should fail for number values (type mismatch)") + } +} + +func TestAnnotation_MinLenOnWrongType(t *testing.T) { + L := NewState() + defer L.Close() + + // @min_len on number type — length check silently passes + ann := <ype{inner: typ.NewAnnotated(typ.Number, []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + })} + + // Number passes type check, min_len silently passes (can't get length of number) + if !ann.Validate(L, LNumber(42)) { + t.Error("number @min_len(1) should pass (min_len is a no-op on numbers)") + } +} + +func TestAnnotation_CombinedRecord(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + // Full real-world-like record with multiple annotated fields + rec := <ype{ + inner: typ.NewRecord(). + AnnotatedField("name", typ.String, false, []typ.Annotation{ + {Name: "min_len", Arg: float64(1)}, + {Name: "max_len", Arg: float64(255)}, + }). + AnnotatedField("email", typ.String, false, []typ.Annotation{ + {Name: "pattern", Arg: "^[^@]+@[^@]+$"}, + }). + AnnotatedField("age", typ.Number, false, []typ.Annotation{ + {Name: "min", Arg: float64(0)}, + {Name: "max", Arg: float64(200)}, + }). + AnnotatedField("bio", typ.String, true, []typ.Annotation{ + {Name: "max_len", Arg: float64(1000)}, + }). + Build(), + name: "UserInput", + } + L.SetGlobal("UserInput", rec) + + err := L.DoString(` + -- Valid input + local v, e = UserInput:is({name = "Alice", email = "alice@example.com", age = 30}) + assert(v ~= nil, "valid input should pass") + + -- With optional bio + local v2, e2 = UserInput:is({name = "Bob", email = "bob@example.com", age = 25, bio = "Hi there"}) + assert(v2 ~= nil, "valid input with bio should pass") + + -- Without optional bio + local v3, e3 = UserInput:is({name = "Carol", email = "carol@example.com", age = 28}) + assert(v3 ~= nil, "valid input without bio should pass") + + -- Empty name fails min_len + local v4, e4 = UserInput:is({name = "", email = "d@e.com", age = 20}) + assert(v4 == nil, "empty name should fail") + local d4 = e4:details() + assert(d4.field == "name", "field should be 'name'") + assert(d4.constraint == "min_len", "constraint should be 'min_len'") + + -- Bad email fails pattern + local v5, e5 = UserInput:is({name = "Eve", email = "notanemail", age = 20}) + assert(v5 == nil, "bad email should fail") + local d5 = e5:details() + assert(d5.field == "email", "field should be 'email'") + assert(d5.constraint == "pattern", "constraint should be 'pattern'") + + -- Negative age fails min + local v6, e6 = UserInput:is({name = "Frank", email = "f@g.com", age = -1}) + assert(v6 == nil, "negative age should fail") + local d6 = e6:details() + assert(d6.field == "age", "field should be 'age'") + assert(d6.constraint == "min", "constraint should be 'min'") + + -- Age above max + local v7, e7 = UserInput:is({name = "Grace", email = "g@h.com", age = 300}) + assert(v7 == nil, "age 300 should fail") + local d7 = e7:details() + assert(d7.constraint == "max", "constraint should be 'max'") + `) + if err != nil { + t.Fatalf("combined annotation test failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Group 35: Map Array-part rejection +// --------------------------------------------------------------------------- + +func TestMapNumberKeys_ArrayPart_BadValue(t *testing.T) { + L := NewState() + defer L.Close() + + numMap := <ype{inner: typ.NewMap(typ.Number, typ.String)} + + tbl := L.NewTable() + tbl.Append(LString("ok")) + tbl.Append(LNumber(42)) // wrong: value should be string + + if numMap.Validate(L, tbl) { + t.Error("{[number]: string} should reject number values in Array part") + } +} + +func TestMapNumberKeys_ArrayPart_Is(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + numMap := <ype{inner: typ.NewMap(typ.Number, typ.String)} + + tbl := L.NewTable() + tbl.Append(LNumber(42)) // wrong value type + + isMethod := L.typeGetField(numMap, "is") + L.Push(isMethod) + L.Push(tbl) + L.Call(1, 2) + val := L.Get(-2) + errVal := L.Get(-1) + L.Pop(2) + + if val != LNil { + t.Error("should fail") + } + if errField(errVal) != "[1]" { + t.Errorf("field should be '[1]', got %q", errField(errVal)) + } +} + +func TestMapStringKeys_IgnoresArrayPart(t *testing.T) { + L := NewState() + defer L.Close() + + // {[string]: number} — Array part should be ignored for string-keyed maps + strMap := <ype{inner: typ.NewMap(typ.String, typ.Number)} + + tbl := L.NewTable() + tbl.RawSetString("a", LNumber(1)) + tbl.Append(LString("stray")) // in Array part, should be ignored + + if !strMap.Validate(L, tbl) { + t.Error("{[string]: number} should ignore Array part entries") + } +} + +// --------------------------------------------------------------------------- +// Group 35: Recursive depth limit +// --------------------------------------------------------------------------- + +func TestRecursiveDepthLimit(t *testing.T) { + L := NewState() + defer L.Close() + + // A recursive type that would infinite loop without depth limit. + // Body is the recursive type itself (no Optional wrapper to break recursion). + rec := typ.NewRecursivePlaceholder("Loop") + rec.SetBody(rec) // self-referential body + + loopType := <ype{inner: rec} + + // Should not hang — depth limit should kick in and return false + result := loopType.Validate(L, L.NewTable()) + _ = result // We don't care about the result, just that it terminates +} + +// --------------------------------------------------------------------------- +// Group 36: typeCall vs :is() consistency +// --------------------------------------------------------------------------- + +func TestTypeCall_ErrorFormat(t *testing.T) { + L := NewState() + defer L.Close() + OpenBase(L) + + rec := <ype{ + inner: typ.NewRecord(). + Field("x", typ.Number). + Build(), + name: "Point", + } + L.SetGlobal("Point", rec) + + // Type(value) should error on invalid input + err := L.DoString(` + local ok, err = pcall(function() + Point("not a table") + end) + assert(not ok, "should error") + `) + if err != nil { + t.Fatalf("typeCall error test failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Group 37: Tuple edge cases +// --------------------------------------------------------------------------- + +func TestTupleEmpty(t *testing.T) { + L := NewState() + defer L.Close() + + // Empty tuple () — should accept empty table + emptyTuple := <ype{inner: typ.NewTuple()} + + tests := []struct { + name string + value LValue + ok bool + }{ + {"empty table passes", L.NewTable(), true}, + {"non-empty table passes", func() LValue { + t := L.NewTable() + t.Append(LNumber(1)) + return t + }(), true}, + {"not a table", LNumber(42), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := emptyTuple.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +func TestTupleExtraElements(t *testing.T) { + L := NewState() + defer L.Close() + + // (number, string) — table with 3 elements should still pass (extra ignored) + tuple := <ype{inner: typ.NewTuple(typ.Number, typ.String)} + + tbl := L.NewTable() + tbl.Append(LNumber(1)) + tbl.Append(LString("hello")) + tbl.Append(LTrue) // extra, should be ignored + + if !tuple.Validate(L, tbl) { + t.Error("tuple with extra elements should pass") + } +} + +func TestTupleNilHoles(t *testing.T) { + L := NewState() + defer L.Close() + + // (number, string) — table with nil in position 2 + tuple := <ype{inner: typ.NewTuple(typ.Number, typ.NewOptional(typ.String))} + + tbl := L.NewTable() + tbl.Append(LNumber(1)) + // Position 2 is missing (nil) — optional so should pass + + if !tuple.Validate(L, tbl) { + t.Error("tuple with optional nil element should pass") + } +} + +// --------------------------------------------------------------------------- +// Group 38: Both field.Optional and Optional(T) type simultaneously +// --------------------------------------------------------------------------- + +func TestRecordFieldOptionalAndTypeOptional(t *testing.T) { + L := NewState() + defer L.Close() + + // Field is optional AND type is Optional(number) — double optional + rec := <ype{ + inner: typ.NewRecord(). + OptField("count", typ.NewOptional(typ.Number)). + Build(), + } + + tests := []struct { + name string + value *LTable + ok bool + }{ + {"field absent", <able{}, true}, + {"field present with number", func() *LTable { + t := <able{Strdict: map[string]LValue{"count": LNumber(5)}} + return t + }(), true}, + {"field present with nil", func() *LTable { + // Setting nil in strdict doesn't actually store, so field is absent + return <able{} + }(), true}, + {"field present with wrong type", func() *LTable { + return <able{Strdict: map[string]LValue{"count": LString("bad")}} + }(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rec.Validate(L, tt.value); got != tt.ok { + t.Errorf("Validate() = %v, want %v", got, tt.ok) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Group 39: Generic type rejection +// --------------------------------------------------------------------------- + +func TestGenericTypeRejectsAll(t *testing.T) { + L := NewState() + defer L.Close() + + generic := <ype{inner: typ.NewGeneric("Container", []*typ.TypeParam{ + typ.NewTypeParam("T", nil), + }, typ.NewArray(typ.NewTypeParam("T", nil)))} + + // Uninstantiated generic should reject everything + values := []LValue{LNil, LTrue, LNumber(0), LString(""), L.NewTable()} + for _, v := range values { + if generic.Validate(L, v) { + t.Errorf("uninstantiated generic should reject %v", v) + } + } +} + +// --------------------------------------------------------------------------- +// Group 40: Sparse arrays +// --------------------------------------------------------------------------- + +func TestArraySparse(t *testing.T) { + L := NewState() + defer L.Close() + + arrType := <ype{inner: typ.NewArray(typ.Number)} + + // Array with nil holes — nils should be skipped + tbl := <able{Array: []LValue{LNumber(1), LNil, LNumber(3), LNil, LNumber(5)}} + + if !arrType.Validate(L, tbl) { + t.Error("sparse array with nil holes should pass (nils are skipped)") + } +} + +func TestArraySparseInvalid(t *testing.T) { + L := NewState() + defer L.Close() + + arrType := <ype{inner: typ.NewArray(typ.Number)} + + // Array with nil holes AND a wrong-type element + tbl := <able{Array: []LValue{LNumber(1), LNil, LString("bad"), LNil, LNumber(5)}} + + if arrType.Validate(L, tbl) { + t.Error("sparse array with wrong-type non-nil element should fail") + } +} + +// --------------------------------------------------------------------------- +// Group 41: Validate and Is consistency +// --------------------------------------------------------------------------- + +func TestValidateAndIsConsistency(t *testing.T) { + L := NewState() + defer L.Close() + OpenErrors(L) + + types := []struct { + name string + typ *LType + }{ + {"number", LTypeNumber}, + {"string", LTypeString}, + {"boolean", LTypeBoolean}, + {"optional number", <ype{inner: typ.NewOptional(typ.Number)}}, + {"table", <ype{inner: typ.NewInterface("table", nil)}}, + {"array", <ype{inner: typ.NewArray(typ.Number)}}, + } + + values := []LValue{ + LNil, LTrue, LFalse, LNumber(42), LInteger(42), LString("hello"), + L.NewTable(), &LUserData{Value: "x"}, + } + + for _, tt := range types { + for _, v := range values { + validateResult := tt.typ.Validate(L, v) + + isMethod := L.typeGetField(tt.typ, "is") + L.Push(isMethod) + L.Push(v) + L.Call(1, 2) + isVal := L.Get(-2) + isErr := L.Get(-1) + L.Pop(2) + + isResult := isVal != LNil || (isVal == LNil && isErr == LNil) + + // For optional types, nil is valid — :is(nil) returns (nil, nil) + if v == LNil && isErr == LNil { + isResult = true + } + + if validateResult != isResult { + t.Errorf("%s / %v: Validate()=%v but :is() returned val=%v err=%v", + tt.name, v, validateResult, isVal, errMessage(isErr)) + } + } + } +} diff --git a/ltype_fuzz_test.go b/ltype_fuzz_test.go new file mode 100644 index 00000000..9451062b --- /dev/null +++ b/ltype_fuzz_test.go @@ -0,0 +1,299 @@ +package lua + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/parse" + typeio "github.com/wippyai/go-lua/types/io" + "github.com/wippyai/go-lua/types/typ" +) + +// --------------------------------------------------------------------------- +// Fuzz 1: Corrupted manifest bytes → decode → validate +// +// This is the real attack surface: a corrupted or truncated manifest file +// is decoded, producing types that are then used for runtime validation. +// Must never panic — corrupted data must produce errors, not segfaults. +// --------------------------------------------------------------------------- + +func FuzzManifestToValidation(f *testing.F) { + // Seed with small valid manifests (one type each to keep size down) + for _, entry := range []struct { + name string + typ typ.Type + }{ + {"Num", typ.Number}, + {"Pt", typ.NewRecord().Field("x", typ.Number).Build()}, + {"Opt", typ.NewOptional(typ.String)}, + {"Arr", typ.NewArray(typ.Number)}, + } { + m := typeio.NewManifest("m") + m.DefineType(entry.name, entry.typ) + encoded, err := typeio.EncodeManifest(m) + if err == nil && len(encoded) <= 512 { + f.Add(encoded) + } + } + + testValues := []LValue{ + LNil, LTrue, LNumber(42), LInteger(7), LString("hello"), + <able{}, + <able{Strdict: map[string]LValue{"x": LNumber(1), "y": LNumber(2)}}, + <able{Array: []LValue{LString("a"), LString("b")}}, + &LUserData{Value: "ud"}, + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 256 { + return + } + + manifest, err := typeio.DecodeManifest(data) + if err != nil || manifest == nil { + return + } + + resolver := &typeResolver{path: manifest.Path, types: manifest.Types} + + for name, tp := range manifest.Types { + if tp == nil { + continue + } + lt := <ype{inner: tp, name: name, resolver: resolver} + + for _, val := range testValues { + // Validate() must not panic + lt.Validate(nil, val) + } + + // :is() must not panic + L := NewState() + OpenErrors(L) + isMethod := L.typeGetField(lt, "is") + for _, val := range testValues { + L.Push(isMethod) + L.Push(val) + L.Call(1, 2) + L.Pop(2) + } + L.Close() + } + }) +} + +// --------------------------------------------------------------------------- +// Fuzz 2: Corrupted single-type bytes → decode → validate +// +// Feeds corrupted bytes to the single type decoder, then validates. +// Smaller surface than manifest but faster iteration. +// --------------------------------------------------------------------------- + +func FuzzTypeDecodeToValidation(f *testing.F) { + seeds := []typ.Type{ + typ.Number, typ.String, typ.Boolean, + typ.NewOptional(typ.Number), + typ.NewArray(typ.String), + typ.NewMap(typ.String, typ.Number), + typ.NewRecord().Field("x", typ.Number).OptField("y", typ.String).Build(), + typ.NewUnion(typ.Number, typ.String), + typ.LiteralString("active"), + typ.NewInterface("table", nil), + typ.NewTuple(typ.Number, typ.String), + typ.NewAnnotated(typ.Number, []typ.Annotation{{Name: "min", Arg: float64(0)}}), + } + + for _, seed := range seeds { + data, err := typeio.Encode(seed) + if err == nil { + f.Add(data) + } + } + f.Add([]byte{}) + f.Add([]byte{0xFF}) + + testValues := []LValue{ + LNil, LTrue, LNumber(42), LString("x"), + <able{}, + <able{Strdict: map[string]LValue{"x": LNumber(1)}}, + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 256 { + return + } + + decoded, err := typeio.Decode(data) + if err != nil || decoded == nil { + return + } + + lt := <ype{inner: decoded} + + for _, val := range testValues { + // Must not panic + lt.Validate(nil, val) + } + + // :is() path + L := NewState() + OpenErrors(L) + isMethod := L.typeGetField(lt, "is") + for _, val := range testValues { + L.Push(isMethod) + L.Push(val) + L.Call(1, 2) + L.Pop(2) + } + L.Close() + }) +} + +// --------------------------------------------------------------------------- +// Fuzz 3: Lua source code with type declarations → compile → execute +// +// This tests the REAL pipeline: Lua source code goes through the parser +// and compiler, producing types that are used at runtime for validation. +// Must never panic — parse/compile errors are expected, panics are not. +// --------------------------------------------------------------------------- + +func FuzzLuaTypeValidation(f *testing.F) { + // Seed with real Lua code patterns that exercise runtime validation + f.Add(` + type Point = {x: number, y: number} + local p, err = Point:is({x = 1, y = 2}) + assert(p ~= nil) + `) + f.Add(` + type Status = "active" | "draft" | "archived" + local s, err = Status:is("active") + `) + f.Add(` + type Input = { + id: string, + name: string?, + tags: {string}?, + meta: table?, + } + local data = {id = "abc", name = "test"} + local v, err = Input:is(data) + `) + f.Add(` + type Pair = {first: number, second: string} + local v, err = Pair:is({first = "bad"}) + `) + f.Add(` + type Config = {[string]: number} + local c, err = Config:is({a = 1, b = 2}) + `) + f.Add(` + local val = string("hello") + `) + f.Add(` + local ok = number(42) + `) + f.Add(` + type Node = {value: number, next: Node?} + local n = {value = 1, next = {value = 2}} + local v, err = Node:is(n) + `) + + f.Fuzz(func(t *testing.T, source string) { + // Parse + chunk, err := parse.ParseString(source, "fuzz.lua") + if err != nil { + return + } + + // Compile with type resolution + proto, err := CompileWithOptions(chunk, "fuzz.lua", CompileOptions{}) + if err != nil { + return + } + + // Execute — must never panic + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + OpenString(L) + + fn := L.LoadProto(proto) + L.Push(fn) + _ = L.PCall(0, MultRet, nil) // runtime errors are OK, panics are not + }) +} + +// --------------------------------------------------------------------------- +// Fuzz 4: Lua source with manifest-provided types → compile → execute +// +// Types come from a manifest (as in real module loading), and the Lua code +// uses :is() and Type(value) calls. Tests the manifest → runtime path. +// --------------------------------------------------------------------------- + +func FuzzLuaWithManifestTypes(f *testing.F) { + f.Add(` + local v, err = Point:is({x = 1, y = 2}) + if v then return v.x + v.y end + return nil, err + `) + f.Add(`local p = Point({x = 10, y = 20}); return p.x`) + f.Add(`local v, err = Point:is("not a table"); return err`) + f.Add(`local v, err = Point:is(nil); return v, err`) + f.Add(`local v, err = Point:is({}); return v, err`) + f.Add(`local v, err = Point:is({x = "bad", y = 2}); return v, err`) + f.Add(` + local v, err = Status:is("active") + if not v then return nil, err end + return v + `) + f.Add(`local v = Status:is("unknown"); return v`) + f.Add(` + local v, err = Input:is({id = "abc", name = "test", meta = {key = "val"}}) + return v, err + `) + f.Add(`local v, err = Input:is({id = 123}); return v, err`) + f.Add(`local v, err = Input:is(nil); return v, err`) + + // Build a manifest with several types + manifest := typeio.NewManifest("fuzz") + manifest.DefineType("Point", typ.NewRecord(). + Field("x", typ.Number). + Field("y", typ.Number). + Build()) + manifest.DefineType("Status", typ.NewUnion( + typ.LiteralString("active"), + typ.LiteralString("draft"), + typ.LiteralString("archived"), + )) + manifest.DefineType("Input", typ.NewRecord(). + Field("id", typ.String). + OptField("name", typ.String). + OptField("tags", typ.NewArray(typ.String)). + OptField("meta", typ.NewInterface("table", nil)). + Build()) + manifestData, err := typeio.EncodeManifest(manifest) + if err != nil { + return + } + + f.Fuzz(func(t *testing.T, source string) { + chunk, err := parse.ParseString(source, "fuzz.lua") + if err != nil { + return + } + + proto, err := CompileWithOptions(chunk, "fuzz.lua", CompileOptions{TypeInfo: manifestData}) + if err != nil { + return + } + + L := NewState() + defer L.Close() + OpenBase(L) + OpenErrors(L) + + fn := L.LoadProto(proto) + L.Push(fn) + _ = L.PCall(0, MultRet, nil) + }) +} diff --git a/ltype_validate.go b/ltype_validate.go index ad26accf..80d3d7bc 100644 --- a/ltype_validate.go +++ b/ltype_validate.go @@ -153,6 +153,30 @@ func (vc *ValidationContext) validateValue(val LValue, t typ.Type, path *pathBui return } } + + case *typ.Intersection: + for _, member := range tt.Members { + vc.validateValue(val, member, path, errors) + } + + case *typ.Recursive: + if tt.Body != nil { + vc.validateValue(val, tt.Body, path, errors) + } + + case *typ.Tuple: + tbl := val.(*LTable) + for i, elemType := range tt.Elements { + var elemVal LValue + if i < len(tbl.Array) { + elemVal = tbl.Array[i] + } else { + elemVal = LNil + } + path.pushIndex(i + 1) + vc.validateValue(elemVal, elemType, path, errors) + path.pop() + } } } @@ -205,7 +229,15 @@ func validateBasic(val LValue, t typ.Type) bool { kind.Sum, kind.Interface, kind.Intersection: _, ok := val.(*LTable) return ok - case kind.Generic, kind.TypeParam, kind.Platform, kind.Self, kind.Meta: + case kind.Recursive: + if rec, ok := t.(*typ.Recursive); ok && rec.Body != nil { + return validateBasic(val, rec.Body) + } + return true + case kind.Platform: + _, ok := val.(*LUserData) + return ok + case kind.Generic, kind.TypeParam, kind.Self, kind.Meta: return true default: // fall though diff --git a/types/io/predicates.go b/types/io/predicates.go index db7bd22d..a5706b5c 100644 --- a/types/io/predicates.go +++ b/types/io/predicates.go @@ -402,6 +402,9 @@ func (r *typeReader) readCondition() constraint.Condition { if count == 0 { return constraint.Condition{} } + if !r.checkSliceLen(count) { + return constraint.Condition{} + } disjuncts := make([][]constraint.Constraint, int(count)) for i := 0; i < int(count); i++ { disjuncts[i] = r.readConjunction() diff --git a/types/io/reader.go b/types/io/reader.go index 90a73633..33a40759 100644 --- a/types/io/reader.go +++ b/types/io/reader.go @@ -13,11 +13,16 @@ import ( "github.com/wippyai/go-lua/types/typ" ) +const maxTypeDepth = 32 +const maxTypeNodes = 1024 + type typeReader struct { r *bytes.Reader err error recursive map[uint64]*typ.Recursive + depth int + nodeCount int } func (r *typeReader) readByte() byte { @@ -94,6 +99,13 @@ func (r *typeReader) readType() typ.Type { if r.err != nil { return nil } + r.depth++ + r.nodeCount++ + if r.depth > maxTypeDepth || r.nodeCount > maxTypeNodes { + r.err = ErrCorruptedData + return nil + } + defer func() { r.depth-- }() k := byteToKind(r.readByte()) diff --git a/types/io/serialize.go b/types/io/serialize.go index 427a1533..c6c8f0ef 100644 --- a/types/io/serialize.go +++ b/types/io/serialize.go @@ -42,7 +42,7 @@ var ( ErrCorruptedData = errors.New("corrupted type data") ) -const maxSliceLen = 1 << 20 +const maxSliceLen = 64 const ( annotationArgNil byte = iota diff --git a/types/typ/annotated.go b/types/typ/annotated.go index 4dc3f3f9..5320d428 100644 --- a/types/typ/annotated.go +++ b/types/typ/annotated.go @@ -44,12 +44,19 @@ func NewAnnotated(inner Type, annotations []Annotation) Type { } func (a *Annotated) Kind() kind.Kind { + if a.Inner == nil { + return kind.Unknown + } return a.Inner.Kind() } func (a *Annotated) String() string { var sb strings.Builder - sb.WriteString(a.Inner.String()) + if a.Inner != nil { + sb.WriteString(a.Inner.String()) + } else { + sb.WriteString("unknown") + } for _, ann := range a.Annotations { sb.WriteString(" @") sb.WriteString(ann.Name) @@ -101,6 +108,9 @@ func (a *Annotated) Equals(other Type) bool { // UnwrapAnnotated returns the inner type, stripping annotations. func UnwrapAnnotated(t Type) Type { if a, ok := t.(*Annotated); ok { + if a.Inner == nil { + return Unknown + } return a.Inner } return t diff --git a/types/typ/container.go b/types/typ/container.go index bc64aced..e1354e35 100644 --- a/types/typ/container.go +++ b/types/typ/container.go @@ -27,7 +27,12 @@ func NewArray(elem Type) *Array { } func (a *Array) Kind() kind.Kind { return kind.Array } -func (a *Array) String() string { return a.Element.String() + "[]" } +func (a *Array) String() string { + if a.Element == nil { + return "unknown[]" + } + return a.Element.String() + "[]" +} func (a *Array) Hash() uint64 { return a.hash } func (a *Array) Equals(o Type) bool { return TypeEquals(a, o) @@ -59,7 +64,16 @@ func NewMap(key, value Type) *Map { } func (m *Map) Kind() kind.Kind { return kind.Map } -func (m *Map) String() string { return "{[" + m.Key.String() + "]: " + m.Value.String() + "}" } +func (m *Map) String() string { + ks, vs := "unknown", "unknown" + if m.Key != nil { + ks = m.Key.String() + } + if m.Value != nil { + vs = m.Value.String() + } + return "{[" + ks + "]: " + vs + "}" +} func (m *Map) Hash() uint64 { return m.hash } func (m *Map) Equals(o Type) bool { return TypeEquals(m, o) @@ -95,7 +109,11 @@ func (t *Tuple) Kind() kind.Kind { return kind.Tuple } func (t *Tuple) String() string { parts := make([]string, len(t.Elements)) for i, e := range t.Elements { - parts[i] = e.String() + if e == nil { + parts[i] = "unknown" + } else { + parts[i] = e.String() + } } return "(" + strings.Join(parts, ", ") + ")" diff --git a/types/typ/intersection.go b/types/typ/intersection.go index 0fa37916..cd8553c0 100644 --- a/types/typ/intersection.go +++ b/types/typ/intersection.go @@ -121,7 +121,11 @@ func (i *Intersection) Kind() kind.Kind { return kind.Intersection } func (i *Intersection) String() string { parts := make([]string, len(i.Members)) for j, m := range i.Members { - parts[j] = m.String() + if m == nil { + parts[j] = "unknown" + } else { + parts[j] = m.String() + } } return strings.Join(parts, " & ") diff --git a/types/typ/optional.go b/types/typ/optional.go index 153eb450..cd3c1adc 100644 --- a/types/typ/optional.go +++ b/types/typ/optional.go @@ -54,6 +54,9 @@ func NewOptional(inner Type) Type { func (o *Optional) Kind() kind.Kind { return kind.Optional } func (o *Optional) String() string { + if o.Inner == nil { + return "nil?" + } return o.Inner.String() + "?" } diff --git a/types/typ/record.go b/types/typ/record.go index 2e7becf5..716e8e58 100644 --- a/types/typ/record.go +++ b/types/typ/record.go @@ -139,7 +139,11 @@ func (r *Record) String() string { } sb.WriteString(": ") - sb.WriteString(f.Type.String()) + if f.Type != nil { + sb.WriteString(f.Type.String()) + } else { + sb.WriteString("unknown") + } } if r.HasMapComponent() { @@ -147,9 +151,17 @@ func (r *Record) String() string { sb.WriteString(", ") } sb.WriteString("[") - sb.WriteString(r.MapKey.String()) + if r.MapKey != nil { + sb.WriteString(r.MapKey.String()) + } else { + sb.WriteString("unknown") + } sb.WriteString("]: ") - sb.WriteString(r.MapValue.String()) + if r.MapValue != nil { + sb.WriteString(r.MapValue.String()) + } else { + sb.WriteString("unknown") + } } if r.Open { diff --git a/types/typ/union.go b/types/typ/union.go index 9c79f9a3..d0d56fb6 100644 --- a/types/typ/union.go +++ b/types/typ/union.go @@ -202,7 +202,11 @@ func (u *Union) Kind() kind.Kind { return kind.Union } func (u *Union) String() string { parts := make([]string, len(u.Members)) for i, m := range u.Members { - parts[i] = m.String() + if m == nil { + parts[i] = "nil" + } else { + parts[i] = m.String() + } } return strings.Join(parts, " | ") diff --git a/value.go b/value.go index fdf55f82..457984f3 100644 --- a/value.go +++ b/value.go @@ -236,9 +236,9 @@ type LState struct { frameExt map[int16]*callFrameExt // lazy-allocated frame extensions keyed by Idx yieldState uint8 // 0=not yielded, 1=system yield, 2=user yield yieldCont uint8 // pending yield continuation type for Lua frames - yieldContRA int32 // target register for continuation result - yieldContRB int32 // call's ReturnBase (where the result lands) - yieldContIdx int16 // frame Idx that owns this continuation + yieldContRA int32 // target register for continuation result + yieldContRB int32 // call's ReturnBase (where the result lands) + yieldContIdx int16 // frame Idx that owns this continuation } func (ls *LState) String() string { return fmt.Sprintf("thread: %p", ls) }