From 3ab9bf03da7fdef168bd8b09b73dfbd1526b7374 Mon Sep 17 00:00:00 2001 From: Mauricio Gomes Date: Sun, 6 Sep 2026 11:56:07 -0400 Subject: [PATCH 1/3] Add bounded graph cloning for capability data --- internal/capabilitydata/clone.go | 447 ++++++++++++++++++ internal/capabilitydata/clone_test.go | 167 +++++++ vibes/capability/contextcap/contextcap.go | 5 +- .../capabilitycontract/clone_graph_test.go | 80 ++++ vibes/internal/capabilitycontract/contract.go | 189 +------- 5 files changed, 701 insertions(+), 187 deletions(-) create mode 100644 internal/capabilitydata/clone.go create mode 100644 internal/capabilitydata/clone_test.go create mode 100644 vibes/internal/capabilitycontract/clone_graph_test.go diff --git a/internal/capabilitydata/clone.go b/internal/capabilitydata/clone.go new file mode 100644 index 000000000..f4515126a --- /dev/null +++ b/internal/capabilitydata/clone.go @@ -0,0 +1,447 @@ +// Package capabilitydata bounds and isolates data graphs crossing host capabilities. +package capabilitydata + +import ( + "context" + "errors" + "fmt" + "math/bits" + "reflect" + "unsafe" + + "github.com/mgomes/vibescript/vibes/value" +) + +// MaxDepth is the existing capability payload nesting limit. +const MaxDepth = 256 + +const ( + maxNodes = 1 << 18 + maxEdges = 1 << 20 + maxBytes = 64 << 20 + maxWork = 64 << 20 + valueBytes = int(unsafe.Sizeof(value.Value{})) + inlineMemoSize = 8 +) + +var ( + errCallable = errors.New("must be data-only") + errCycle = errors.New("must not contain cyclic references") +) + +type limitError struct { + message string + cause error +} + +func (e *limitError) Error() string { return e.message } +func (e *limitError) LimitError() bool { return true } +func (e *limitError) Unwrap() error { return e.cause } + +func labeledError(label string, err error) error { + var limit interface{ LimitError() bool } + if errors.As(err, &limit) && limit.LimitError() { + return &limitError{message: label + " " + err.Error(), cause: err} + } + return fmt.Errorf("%s %w", label, err) +} + +func exceeded(resource string, limit int) error { + return &limitError{message: fmt.Sprintf("exceeds capability clone %s limit %d", resource, limit)} +} + +// Budget accounts for cumulative work and allocations across independent snapshots +// in one operation. Callers must use it sequentially and release their external +// reservation after the operation. Nil hooks retain the standalone hard limits. +type Budget struct { + ctx context.Context + chargeSteps func(int) error + reserve func(int) error + nodes int + edges int + bytes int + work int + remainder int +} + +// NewBudget creates a bounded operation with optional interpreter step and memory +// hooks. Each charged step represents 64 units of graph or byte work. +func NewBudget(ctx context.Context, chargeSteps, reserve func(int) error) *Budget { + return &Budget{ctx: ctx, chargeSteps: chargeSteps, reserve: reserve} +} + +// Work charges work before a traversal, hash, copy, or allocation performs it. +func (b *Budget) Work(units int) error { + if b.ctx != nil { + if err := b.ctx.Err(); err != nil { + return err + } + } + if units < 0 || units > maxWork-b.work { + return exceeded("work", maxWork) + } + b.work += units + units += b.remainder + b.remainder = units % 64 + if b.chargeSteps != nil && units >= 64 { + return b.chargeSteps(units / 64) + } + return nil +} + +// Reserve checks allocation size before it is passed to make or a constructor. +func (b *Budget) Reserve(bytes int) error { + if bytes < 0 || bytes > maxBytes-b.bytes { + return exceeded("byte", maxBytes) + } + if err := b.Work(bytes); err != nil { + return err + } + if b.reserve != nil { + if err := b.reserve(bytes); err != nil { + return err + } + } + b.bytes += bytes + return nil +} + +func (b *Budget) reserveSlots(count, size, base int) error { + if count < 0 || base > maxBytes-b.bytes || count > (maxBytes-b.bytes-base)/size { + return exceeded("byte", maxBytes) + } + return b.Reserve(base + count*size) +} + +func (b *Budget) visit() error { + if b.edges == maxEdges { + return exceeded("edge", maxEdges) + } + b.edges++ + return b.Work(1) +} + +func (b *Budget) checkEdges(count int) error { + if count < 0 || count > maxEdges-b.edges { + return exceeded("edge", maxEdges) + } + return nil +} + +func (b *Budget) node() error { + if b.nodes == maxNodes { + return exceeded("node", maxNodes) + } + b.nodes++ + return b.Work(1) +} + +// Options preserves the distinct contracts of validated option parsers and +// runtime containment copies. The default rejects runtime values and strips tags. +type Options struct { + AllowRuntimeValues bool + PreserveObjectTags bool +} + +type nodeKey struct { + id uintptr + kind value.ValueKind + tag value.ObjectTag +} + +type cloneEntry struct { + // Keep the source alive while its uintptr identity is in the memo. + source value.Value + cloned value.Value + err error + height int + active bool +} + +type memoEntry struct { + key nodeKey + cloneEntry +} + +// Cloner preserves shared children across every root of one immutable input +// graph. Create a new Cloner after invoking host code or yielding a snapshot; +// reuse its Budget to retain cumulative limits without retaining stale clones. +type Cloner struct { + budget *Budget + options Options + memo map[nodeKey]cloneEntry + inline [inlineMemoSize]memoEntry + inlineCount int + err error +} + +// NewCloner starts an independent identity memo using the operation's budget. +func NewCloner(budget *Budget, options Options) *Cloner { + if budget == nil { + budget = NewBudget(context.Background(), nil, nil) + } + return &Cloner{budget: budget, options: options} +} + +// Clone validates and isolates one root, preserving aliases to earlier roots. +func (c *Cloner) Clone(label string, source value.Value) (value.Value, error) { + if c.err != nil { + return value.NewNil(), c.err + } + cloned, _, err := c.clone(source, 0) + if err != nil { + c.err = labeledError(label, err) + return value.NewNil(), c.err + } + return cloned, nil +} + +// Kwargs isolates keyword values with the same memo as positional roots. +func (c *Cloner) Kwargs(method string, source map[string]value.Value) (map[string]value.Value, error) { + if c.err != nil { + return nil, c.err + } + if len(source) == 0 { + return nil, nil + } + if err := c.budget.checkEdges(len(source)); err != nil { + return nil, labeledError(method+" keywords", err) + } + if err := c.reserveMap(len(source)); err != nil { + return nil, labeledError(method+" keywords", err) + } + out := make(map[string]value.Value, len(source)) + for key, item := range source { + if err := c.budget.Work(len(key)); err != nil { + return nil, labeledError(method+" keywords", err) + } + cloned, err := c.Clone(method+" keyword "+key, item) + if err != nil { + return nil, err + } + out[key] = cloned + } + return out, nil +} + +func (c *Cloner) clone(source value.Value, depth int) (value.Value, int, error) { + if depth > MaxDepth { + return value.NewNil(), 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} + } + if err := c.budget.visit(); err != nil { + return value.NewNil(), 0, err + } + switch source.Kind() { + case value.KindFunction, value.KindBuiltin, value.KindBlock, value.KindClass, value.KindInstance, value.KindShape: + if !c.options.AllowRuntimeValues { + return value.NewNil(), 0, errCallable + } + return source, 0, nil + case value.KindArray, value.KindHash, value.KindObject: + default: + return source, 0, nil + } + key := c.identity(source) + if entry, ok := c.lookup(key); ok { + if entry.active { + return value.NewNil(), 0, errCycle + } + if entry.height > MaxDepth-depth { + return value.NewNil(), 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} + } + return entry.cloned, entry.height, entry.err + } + if err := c.budget.node(); err != nil { + return value.NewNil(), 0, err + } + if err := c.remember(key, cloneEntry{source: source, active: true}); err != nil { + return value.NewNil(), 0, err + } + var cloned value.Value + var height int + var err error + if source.Kind() == value.KindArray { + cloned, height, err = c.cloneArray(source, depth) + } else { + cloned, height, err = c.cloneMap(source, depth) + } + c.complete(key, cloneEntry{source: source, cloned: cloned, height: height, err: err}) + return cloned, height, err +} + +func (c *Cloner) lookup(key nodeKey) (cloneEntry, bool) { + if c.memo != nil { + entry, ok := c.memo[key] + return entry, ok + } + for i := range c.inlineCount { + if c.inline[i].key == key { + return c.inline[i].cloneEntry, true + } + } + return cloneEntry{}, false +} + +func (c *Cloner) remember(key nodeKey, entry cloneEntry) error { + const slotBytes = 2 * (int(unsafe.Sizeof(memoEntry{})) + 32) + if c.memo == nil && c.inlineCount < len(c.inline) { + c.inline[c.inlineCount] = memoEntry{key: key, cloneEntry: entry} + c.inlineCount++ + return nil + } + if c.memo == nil { + if err := c.budget.reserveSlots(c.inlineCount+1, slotBytes, 64); err != nil { + return err + } + c.memo = make(map[nodeKey]cloneEntry, c.inlineCount+1) + for i := range c.inlineCount { + c.memo[c.inline[i].key] = c.inline[i].cloneEntry + } + clear(c.inline[:]) + } else if err := c.budget.Reserve(slotBytes); err != nil { + return err + } + c.memo[key] = entry + return nil +} + +func (c *Cloner) complete(key nodeKey, entry cloneEntry) { + if c.memo != nil { + c.memo[key] = entry + return + } + for i := range c.inlineCount { + if c.inline[i].key == key { + c.inline[i].cloneEntry = entry + return + } + } +} + +func (c *Cloner) identity(source value.Value) nodeKey { + key := nodeKey{kind: source.Kind()} + switch source.Kind() { + case value.KindArray: + key.id = value.ArrayIdentity(source) + case value.KindHash: + key.id = value.HashIdentity(source) + case value.KindObject: + key.id = reflect.ValueOf(source.HashEntryMap()).Pointer() + if c.options.PreserveObjectTags { + key.tag = source.ObjectTag() + } + } + return key +} + +func (c *Cloner) cloneArray(source value.Value, depth int) (value.Value, int, error) { + items := source.Array() + if err := c.budget.checkEdges(len(items)); err != nil { + return value.NewNil(), 0, err + } + if err := c.budget.reserveSlots(len(items), valueBytes, value.ArrayDataBytes); err != nil { + return value.NewNil(), 0, err + } + out := make([]value.Value, len(items)) + height := 0 + var cycle error + for i, item := range items { + cloned, childHeight, err := c.clone(item, depth+1) + if err != nil && !errors.Is(err, errCycle) { + return value.NewNil(), 0, err + } + if errors.Is(err, errCycle) { + cycle = err + } + height = max(height, childHeight+1) + out[i] = cloned + } + if cycle != nil { + return value.NewNil(), height, cycle + } + return value.NewArray(out), height, nil +} + +func (c *Cloner) reserveMap(count int) error { + // Include capacity slack and a minimum group, as in the runtime's + // structural map estimates, before any bucket or key-order allocation. + const slotBytes = 2 * (16 + valueBytes + 32) + return c.budget.reserveSlots(count, slotBytes, 64+8*slotBytes) +} + +func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, error) { + items := source.HashEntryMap() + if err := c.budget.checkEdges(len(items)); err != nil { + return value.NewNil(), 0, err + } + if err := c.reserveMap(len(items)); err != nil { + return value.NewNil(), 0, err + } + // A key-order fallback sorts the map keys. Account for comparison and + // rehashing work, including long common prefixes, before that helper runs. + factor := 1 + if source.Kind() == value.KindHash { + factor += bits.Len(uint(len(items))) + } + scalarOnly := true + for key, item := range items { + if len(key) > (maxWork-c.budget.work)/factor { + return value.NewNil(), 0, exceeded("work", maxWork) + } + if err := c.budget.Work(len(key)*factor + 1); err != nil { + return value.NewNil(), 0, err + } + if !scalar(item.Kind()) { + scalarOnly = false + } + } + out := make(map[string]value.Value, len(items)) + height := 0 + var cycle error + for key, item := range items { + childDepth := depth + 1 + // Preserve the existing scalar-map fast path's depth contract. + if scalarOnly { + childDepth = depth + } + cloned, childHeight, err := c.clone(item, childDepth) + if err != nil && !errors.Is(err, errCycle) { + return value.NewNil(), 0, err + } + if errors.Is(err, errCycle) { + cycle = err + } + if !scalarOnly { + height = max(height, childHeight+1) + } + out[key] = cloned + } + if cycle != nil { + return value.NewNil(), height, cycle + } + if source.Kind() == value.KindHash { + if err := c.budget.reserveSlots(len(items), valueBytes+16, value.HashDataBytes); err != nil { + return value.NewNil(), 0, err + } + return value.NewHashWithTrustedOrder(out, source.HashKeyOrder()), height, nil + } + if err := c.budget.Reserve(value.ObjectDataBytes); err != nil { + return value.NewNil(), 0, err + } + if c.options.PreserveObjectTags { + if text, ok := source.ObjectStringForm(); ok { + return value.NewTaggedObject(out, source.ObjectTag(), text), height, nil + } + } + return value.NewObject(out), height, nil +} + +func scalar(kind value.ValueKind) bool { + switch kind { + case value.KindNil, value.KindBool, value.KindInt, value.KindFloat, value.KindString, + value.KindMoney, value.KindDuration, value.KindTime, value.KindSymbol, value.KindRange, value.KindRegex: + return true + default: + return false + } +} diff --git a/internal/capabilitydata/clone_test.go b/internal/capabilitydata/clone_test.go new file mode 100644 index 000000000..e4eb3e3ec --- /dev/null +++ b/internal/capabilitydata/clone_test.go @@ -0,0 +1,167 @@ +package capabilitydata + +import ( + "context" + "errors" + "math" + "testing" + + "github.com/mgomes/vibescript/vibes/value" +) + +func TestCloneKeepsDistinctEmptyArrays(t *testing.T) { + t.Parallel() + first, second := value.NewArray(nil), value.NewArray(nil) + cloned, err := NewCloner(nil, Options{}).Clone("payload", value.NewArray([]value.Value{first, second, first})) + if err != nil { + t.Fatal(err) + } + items := cloned.Array() + if value.ArrayIdentity(items[0]) == value.ArrayIdentity(items[1]) { + t.Error("Clone merged distinct empty arrays") + } + if value.ArrayIdentity(items[0]) != value.ArrayIdentity(items[2]) { + t.Error("Clone duplicated a shared empty array") + } +} + +func TestCloneBudgetLimits(t *testing.T) { + t.Parallel() + for _, resource := range []string{"node", "edge", "byte", "work"} { + t.Run(resource, func(t *testing.T) { + t.Parallel() + budget := NewBudget(context.Background(), nil, nil) + switch resource { + case "node": + budget.nodes = maxNodes + case "edge": + budget.edges = maxEdges + case "byte": + budget.bytes = maxBytes + case "work": + budget.work = maxWork + } + cloner := NewCloner(budget, Options{}) + _, err := cloner.Clone("payload", value.NewArray([]value.Value{value.NewInt(1)})) + var limit *limitError + if !errors.As(err, &limit) { + t.Fatalf("Clone with exhausted %s budget error = %v, want limit error", resource, err) + } + if cloner.memo != nil { + t.Error("Clone allocated its identity memo after exhausting the budget") + } + }) + } +} + +func TestCloneRejectsBeforeAllocating(t *testing.T) { + t.Parallel() + refused := errors.New("memory quota exceeded") + budget := NewBudget(context.Background(), nil, func(int) error { return refused }) + cloner := NewCloner(budget, Options{}) + _, err := cloner.Clone("payload", value.NewArray(make([]value.Value, 1024))) + if !errors.Is(err, refused) { + t.Fatalf("Clone error = %v, want reservation failure", err) + } + if cloner.memo != nil || budget.bytes != 0 { + t.Error("Clone retained allocation state after its first reservation failed") + } + if err := budget.reserveSlots(math.MaxInt, valueBytes, 0); err == nil { + t.Error("reserveSlots accepted an overflowing allocation size") + } +} + +func TestCloneChecksContainerEdgesBeforeAllocating(t *testing.T) { + t.Parallel() + reserved := 0 + budget := NewBudget(context.Background(), nil, func(bytes int) error { + reserved += bytes + return nil + }) + budget.edges = maxEdges - 2 + _, err := NewCloner(budget, Options{}).Clone("payload", value.NewArray([]value.Value{value.NewInt(1), value.NewInt(2)})) + var limit *limitError + if !errors.As(err, &limit) { + t.Fatalf("Clone with one child edge remaining error = %v, want limit error", err) + } + if reserved != 0 { + t.Errorf("Clone reserved %d bytes for a container exceeding its edge budget, want 0", reserved) + } +} + +func TestCloneBudgetSurvivesIndependentSnapshots(t *testing.T) { + t.Parallel() + budget := NewBudget(context.Background(), nil, nil) + budget.nodes = maxNodes - 2 + source := value.NewArray([]value.Value{value.NewInt(1)}) + first, err := NewCloner(budget, Options{}).Clone("first row", source) + if err != nil { + t.Fatal(err) + } + first.Array()[0] = value.NewInt(2) + second, err := NewCloner(budget, Options{}).Clone("second row", source) + if err != nil { + t.Fatal(err) + } + if !second.Array()[0].Equal(value.NewInt(1)) { + t.Error("fresh snapshot reused the earlier mutated clone") + } + if _, err := NewCloner(budget, Options{}).Clone("third row", source); err == nil { + t.Error("fresh snapshot reset the cumulative node budget") + } +} + +func TestCloneCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + steps := 0 + budget := NewBudget(ctx, func(n int) error { + steps += n + if steps >= 32 { + cancel() + } + return nil + }, nil) + graph := value.NewInt(1) + for range 64 { + graph = value.NewArray([]value.Value{graph, graph}) + } + cloner := NewCloner(budget, Options{}) + if _, err := cloner.Clone("payload", graph); !errors.Is(err, context.Canceled) { + t.Fatalf("Clone error = %v, want context cancellation", err) + } + if len(cloner.memo) >= 64 { + t.Error("Clone finished traversing the graph after cancellation") + } +} + +func TestCloneOptions(t *testing.T) { + t.Parallel() + callable := value.NewValue(value.KindFunction, nil) + cloned, err := NewCloner(nil, Options{AllowRuntimeValues: true}).Clone("validated option", callable) + if err != nil || cloned != callable { + t.Fatalf("Clone(validated callable) = %v, %v, want unchanged value", cloned.Kind(), err) + } + if _, err := NewCloner(nil, Options{}).Clone("option", callable); !errors.Is(err, errCallable) { + t.Fatalf("Clone(callable) error = %v, want data-only error", err) + } + entries := map[string]value.Value{"message": value.NewString("changed")} + tagged := value.NewTaggedObject(entries, value.ObjectTagRescuedError, "original") + plain := value.NewObject(entries) + cloned, err = NewCloner(nil, Options{PreserveObjectTags: true}).Clone("result", value.NewArray([]value.Value{tagged, plain})) + if err != nil { + t.Fatal(err) + } + items := cloned.Array() + if text, ok := items[0].ObjectStringForm(); !ok || text != "original" { + t.Errorf("Clone(tagged object) string form = %q, %t, want original", text, ok) + } + if items[1].ObjectTag() != value.ObjectTagNone { + t.Error("Clone gave an ordinary object another wrapper's provenance") + } + stripped, err := NewCloner(nil, Options{}).Clone("payload", tagged) + if err != nil || stripped.ObjectTag() != value.ObjectTagNone { + t.Errorf("Clone(default object) tag = %v, error = %v, want no tag", stripped.ObjectTag(), err) + } +} diff --git a/vibes/capability/contextcap/contextcap.go b/vibes/capability/contextcap/contextcap.go index 877376ed6..9963f2957 100644 --- a/vibes/capability/contextcap/contextcap.go +++ b/vibes/capability/contextcap/contextcap.go @@ -11,7 +11,7 @@ import ( "context" "fmt" - "github.com/mgomes/vibescript/vibes/internal/capabilitycontract" + "github.com/mgomes/vibescript/internal/capabilitydata" "github.com/mgomes/vibescript/vibes/value" ) @@ -65,7 +65,8 @@ func (c *Capability) Bind(ctx context.Context) (map[string]value.Value, error) { return nil, fmt.Errorf("%s capability resolver must return hash/object", c.name) } label := c.name + " capability value" - cloned, err := capabilitycontract.CloneDataOnlyValue(label, val) + budget := capabilitydata.NewBudget(ctx, nil, nil) + cloned, err := capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(label, val) if err != nil { return nil, err } diff --git a/vibes/internal/capabilitycontract/clone_graph_test.go b/vibes/internal/capabilitycontract/clone_graph_test.go new file mode 100644 index 000000000..faebd0d75 --- /dev/null +++ b/vibes/internal/capabilitycontract/clone_graph_test.go @@ -0,0 +1,80 @@ +package capabilitycontract + +import ( + "fmt" + "testing" + + "github.com/mgomes/vibescript/vibes/value" +) + +func TestCloneDataOnlyValuePreservesSharedChildren(t *testing.T) { + t.Parallel() + for _, kind := range []value.ValueKind{value.KindArray, value.KindHash, value.KindObject} { + t.Run(kind.String(), func(t *testing.T) { + t.Parallel() + child := value.NewArray([]value.Value{value.NewInt(7)}) + var source value.Value + switch kind { + case value.KindArray: + source = value.NewArray([]value.Value{child, child}) + case value.KindHash: + source = value.NewHash(map[string]value.Value{"a": child, "b": child}) + case value.KindObject: + source = value.NewObject(map[string]value.Value{"a": child, "b": child}) + } + cloned, err := CloneDataOnlyValue("payload", source) + if err != nil { + t.Fatalf("CloneDataOnlyValue(%s) error = %v", kind, err) + } + var first, second value.Value + if kind == value.KindArray { + first, second = cloned.Array()[0], cloned.Array()[1] + } else { + first, second = cloned.HashEntryMap()["a"], cloned.HashEntryMap()["b"] + } + if value.ArrayIdentity(first) != value.ArrayIdentity(second) { + t.Error("CloneDataOnlyValue duplicated a shared child") + } + first.Array()[0] = value.NewInt(9) + if !second.Array()[0].Equal(value.NewInt(9)) { + t.Error("mutating one cloned alias did not update the other") + } + if !child.Array()[0].Equal(value.NewInt(7)) { + t.Error("mutating the cloned child changed the source") + } + }) + } +} + +func TestCloneKwargsDataOnlyPreservesSharedRoots(t *testing.T) { + t.Parallel() + child := value.NewArray([]value.Value{value.NewInt(7)}) + cloned, err := CloneKwargsDataOnly("db.find", map[string]value.Value{"a": child, "b": child}) + if err != nil { + t.Fatalf("CloneKwargsDataOnly(shared roots) error = %v", err) + } + if value.ArrayIdentity(cloned["a"]) != value.ArrayIdentity(cloned["b"]) { + t.Error("CloneKwargsDataOnly duplicated a root shared by two keywords") + } + if value.ArrayIdentity(cloned["a"]) == value.ArrayIdentity(child) { + t.Error("CloneKwargsDataOnly retained a source alias") + } +} + +func BenchmarkCloneDataOnlySharedGraph(b *testing.B) { + for _, depth := range []int{8, 12, 16} { + b.Run(fmt.Sprintf("depth_%d", depth), func(b *testing.B) { + graph := value.NewInt(7) + for range depth { + graph = value.NewArray([]value.Value{graph, graph}) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := CloneDataOnlyValue("payload", graph); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/vibes/internal/capabilitycontract/contract.go b/vibes/internal/capabilitycontract/contract.go index ca338220c..ab09d8af2 100644 --- a/vibes/internal/capabilitycontract/contract.go +++ b/vibes/internal/capabilitycontract/contract.go @@ -7,17 +7,17 @@ package capabilitycontract import ( "fmt" - "maps" "reflect" "strings" "unsafe" + "github.com/mgomes/vibescript/internal/capabilitydata" "github.com/mgomes/vibescript/vibes/value" ) // MaxDataOnlyTraversalDepth bounds recursive capability payload validation and // cloning so deeply nested acyclic values cannot exhaust the host stack. -const MaxDataOnlyTraversalDepth = 256 +const MaxDataOnlyTraversalDepth = capabilitydata.MaxDepth type limitError struct { err error @@ -67,18 +67,7 @@ func CloneKwargs(kwargs map[string]value.Value) map[string]value.Value { // CloneKwargsDataOnly validates and deep-copies keyword arguments in one // pass so host callbacks receive isolated data-only values. func CloneKwargsDataOnly(method string, kwargs map[string]value.Value) (map[string]value.Value, error) { - if len(kwargs) == 0 { - return nil, nil - } - out := make(map[string]value.Value, len(kwargs)) - for key, val := range kwargs { - cloned, err := CloneDataOnlyValue(fmt.Sprintf("%s keyword %s", method, key), val) - if err != nil { - return nil, err - } - out[key] = cloned - } - return out, nil + return capabilitydata.NewCloner(nil, capabilitydata.Options{}).Kwargs(method, kwargs) } // CloneHash returns a deep copy of the provided string-keyed map. An @@ -141,11 +130,7 @@ func CloneHashValue(label string, val value.Value) (map[string]value.Value, erro // CloneDataOnlyValue validates and deep-copies val in one graph walk. func CloneDataOnlyValue(label string, val value.Value) (value.Value, error) { - cloned, issue := cloneDataOnlyValue(val, newSeenSet(), 0) - if err := dataOnlyIssueError(label, issue); err != nil { - return value.NewNil(), err - } - return cloned, nil + return capabilitydata.NewCloner(nil, capabilitydata.Options{}).Clone(label, val) } // IsNilImplementation reports whether impl is a nil interface or a @@ -327,22 +312,8 @@ const ( dataOnlyOK dataOnlyResult = iota dataOnlyCallable dataOnlyCycle - dataOnlyDepth ) -func dataOnlyIssueError(label string, issue dataOnlyResult) error { - switch issue { - case dataOnlyCallable: - return fmt.Errorf("%s must be data-only", label) - case dataOnlyCycle: - return fmt.Errorf("%s must not contain cyclic references", label) - case dataOnlyDepth: - return limitErrorf("%s exceeds maximum depth %d", label, MaxDataOnlyTraversalDepth) - default: - return nil - } -} - func sliceIdentity(values []value.Value) value.SliceIdentity { return value.SliceIdentity{ Ptr: uintptr(unsafe.Pointer(unsafe.SliceData(values))), @@ -413,158 +384,6 @@ func validateDataOnly(val value.Value, visiting, seen *seenSet) dataOnlyResult { } } -func cloneDataOnlyValue(val value.Value, visiting *seenSet, depth int) (value.Value, dataOnlyResult) { - if depth > MaxDataOnlyTraversalDepth { - return value.NewNil(), dataOnlyDepth - } - switch val.Kind() { - case value.KindFunction, value.KindBuiltin, value.KindBlock, value.KindClass, value.KindInstance, - value.KindShape: - return value.NewNil(), dataOnlyCallable - case value.KindArray: - values := val.Array() - id := sliceIdentity(values) - if _, ok := visiting.arrays[id]; ok { - return value.NewNil(), dataOnlyCycle - } - visiting.arrays[id] = struct{}{} - cloned := make([]value.Value, len(values)) - issue := dataOnlyOK - for i, item := range values { - next, result := cloneDataOnlyValue(item, visiting, depth+1) - switch result { - case dataOnlyCallable: - return value.NewNil(), dataOnlyCallable - case dataOnlyDepth: - return value.NewNil(), dataOnlyDepth - case dataOnlyCycle: - issue = dataOnlyCycle - default: - cloned[i] = next - } - } - delete(visiting.arrays, id) - if issue != dataOnlyOK { - return value.NewNil(), issue - } - return value.NewArray(cloned), dataOnlyOK - case value.KindHash: - return cloneDataOnlyHash(val, visiting, depth) - case value.KindObject: - return cloneDataOnlyMap(val.HashEntryMap(), visiting, value.NewObject, depth) - default: - return val, dataOnlyOK - } -} - -// cloneDataOnlyHash clones a KindHash, isolating its entries and its Ruby-style -// default metadata. A default proc is a KindBlock callable, so a hash carrying -// one is rejected just like any other embedded callable; a data-only default -// value is cloned and preserved on the result so the isolated copy keeps the -// same missing-key behavior. -func cloneDataOnlyHash(val value.Value, visiting *seenSet, depth int) (value.Value, dataOnlyResult) { - entries := val.HashEntryMap() - // Track the whole hash wrapper, not just the entry map: two wrappers can - // share one entry map yet carry distinct defaults, and the cycle check must - // follow each wrapper's own default graph. - ptr := value.HashIdentity(val) - if ptr == 0 { - ptr = reflect.ValueOf(entries).Pointer() - } - if _, ok := visiting.maps[ptr]; ok { - return value.NewNil(), dataOnlyCycle - } - visiting.maps[ptr] = struct{}{} - defer delete(visiting.maps, ptr) - - var cloned map[string]value.Value - issue := dataOnlyOK - if len(entries) > 0 && scalarOnlyDataEntries(entries) { - // Every value is a scalar, so validation cannot fail and the clone can - // copy the map's bucket structure wholesale instead of rehashing and - // reinserting every key. This is the dominant shape for row payloads - // crossing the capability boundary. - cloned = maps.Clone(entries) - } else { - cloned = make(map[string]value.Value, len(entries)) - for key, item := range entries { - next, result := cloneDataOnlyValue(item, visiting, depth+1) - switch result { - case dataOnlyCallable: - return value.NewNil(), dataOnlyCallable - case dataOnlyDepth: - return value.NewNil(), dataOnlyDepth - case dataOnlyCycle: - issue = dataOnlyCycle - default: - cloned[key] = next - } - } - } - - if issue != dataOnlyOK { - return value.NewNil(), issue - } - return value.NewHashWithTrustedOrder(cloned, val.HashKeyOrder()), dataOnlyOK -} - -func cloneDataOnlyMap( - entries map[string]value.Value, - visiting *seenSet, - construct func(map[string]value.Value) value.Value, - depth int, -) (value.Value, dataOnlyResult) { - ptr := reflect.ValueOf(entries).Pointer() - if _, ok := visiting.maps[ptr]; ok { - return value.NewNil(), dataOnlyCycle - } - visiting.maps[ptr] = struct{}{} - var cloned map[string]value.Value - issue := dataOnlyOK - if len(entries) > 0 && scalarOnlyDataEntries(entries) { - // See cloneDataOnlyHash: scalar-only maps validate trivially and clone - // their bucket structure wholesale. - cloned = maps.Clone(entries) - } else { - cloned = make(map[string]value.Value, len(entries)) - for key, item := range entries { - next, result := cloneDataOnlyValue(item, visiting, depth+1) - switch result { - case dataOnlyCallable: - return value.NewNil(), dataOnlyCallable - case dataOnlyDepth: - return value.NewNil(), dataOnlyDepth - case dataOnlyCycle: - issue = dataOnlyCycle - default: - cloned[key] = next - } - } - } - delete(visiting.maps, ptr) - if issue != dataOnlyOK { - return value.NewNil(), issue - } - return construct(cloned), dataOnlyOK -} - -// scalarOnlyDataEntries reports whether every value in one entry map is an -// immutable scalar kind — nothing to validate recursively and nothing that -// needs an isolating per-value clone. Kinds are whitelisted so any future -// composite or callable kind fails closed onto the per-entry walk. -func scalarOnlyDataEntries(entries map[string]value.Value) bool { - for _, item := range entries { - switch item.Kind() { - case value.KindNil, value.KindBool, value.KindInt, value.KindFloat, - value.KindString, value.KindMoney, value.KindDuration, - value.KindTime, value.KindSymbol, value.KindRange, value.KindRegex: - default: - return false - } - } - return true -} - func valueKindName(kind value.ValueKind) string { switch kind { case value.KindNil: From 08431e3272cfaae354f43eb6b582cd3c1e478129 Mon Sep 17 00:00:00 2001 From: Mauricio Gomes Date: Sun, 6 Sep 2026 12:55:04 -0400 Subject: [PATCH 2/3] Bound capability copies across adapter requests and snapshots --- changelog.d/capability-graph-copying.md | 3 + docs/integration.md | 14 + internal/capabilitydata/clone.go | 207 ++++++--- internal/capabilitydata/context.go | 45 ++ internal/capabilitydata/policy_test.go | 57 +++ internal/capabilitydata/validate.go | 76 ++++ internal/capabilitydata/validate_test.go | 100 +++++ internal/jobqueueoptions/options.go | 93 +++++ internal/runtime/call.go | 13 +- internal/runtime/capabilities.go | 367 +--------------- internal/runtime/capability_adapters.go | 132 ++++-- internal/runtime/capability_data_budget.go | 50 +++ .../runtime/capability_data_budget_test.go | 394 ++++++++++++++++++ internal/runtime/memory.go | 9 +- vibes/capability/contextcap/contextcap.go | 5 +- vibes/capability/db/calls.go | 57 ++- vibes/capability/db/clone_budget_test.go | 96 +++++ vibes/capability/events/events.go | 129 ++---- vibes/capability/events/events_test.go | 8 +- vibes/capability/jobqueue/jobqueue.go | 106 +---- vibes/capability/jobqueue/jobqueue_test.go | 8 +- vibes/internal/capabilitycontract/contract.go | 204 +-------- 22 files changed, 1302 insertions(+), 871 deletions(-) create mode 100644 changelog.d/capability-graph-copying.md create mode 100644 internal/capabilitydata/context.go create mode 100644 internal/capabilitydata/policy_test.go create mode 100644 internal/capabilitydata/validate.go create mode 100644 internal/capabilitydata/validate_test.go create mode 100644 internal/jobqueueoptions/options.go create mode 100644 internal/runtime/capability_data_budget.go create mode 100644 internal/runtime/capability_data_budget_test.go create mode 100644 vibes/capability/db/clone_budget_test.go diff --git a/changelog.d/capability-graph-copying.md b/changelog.d/capability-graph-copying.md new file mode 100644 index 000000000..012afa06b --- /dev/null +++ b/changelog.d/capability-graph-copying.md @@ -0,0 +1,3 @@ +- **Fixed: capability data copying.** DB, events, jobqueue, and context adapters + preserve shared data within requests and snapshots, bound copy work and + allocations, and charge runtime step and memory quotas. diff --git a/docs/integration.md b/docs/integration.md index 5108e9e1a..6fea07147 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -257,6 +257,20 @@ skip the boundary copies entirely, and one that never writes a script container can declare `vibes.DeclareNonMutating` to skip argument isolation; both are safety promises, so declare only what is true. +The DB, events, jobqueue, and context adapters preserve shared children within +each copied data graph. Positional arguments and keyword options share one +request copy. Returns and individual `db.each` rows use fresh snapshots, so a +host callback or an earlier row cannot leave a stale copy in a later result. +Jobqueue payloads retain object provenance; extra enqueue options keep their +existing behavior of stripping it. + +These adapters bound copy work even when used without an interpreter. One +operation permits at most 262,144 composite-node visits, 1,048,576 value visits, +64 MiB of cumulative allocation reservations, and 67,108,864 units of traversal +and byte work. The existing nesting limit remains 256. Runtime calls also +charge their configured step and memory quotas and check cancellation while +copying. All rows in one `db.each` call share the operation budget. + ### Handling Dynamic Types Every call returns a `value.Value`. Inspect the `Kind()` before consuming it: diff --git a/internal/capabilitydata/clone.go b/internal/capabilitydata/clone.go index f4515126a..20258be89 100644 --- a/internal/capabilitydata/clone.go +++ b/internal/capabilitydata/clone.go @@ -57,6 +57,7 @@ type Budget struct { ctx context.Context chargeSteps func(int) error reserve func(int) error + refresh func() error nodes int edges int bytes int @@ -70,6 +71,21 @@ func NewBudget(ctx context.Context, chargeSteps, reserve func(int) error) *Budge return &Budget{ctx: ctx, chargeSteps: chargeSteps, reserve: reserve} } +// SetSnapshotRefresh installs the runtime hook that refreshes live memory after +// a host call or script callback changes the operation's reachable roots. +func (b *Budget) SetSnapshotRefresh(refresh func() error) { b.refresh = refresh } + +// Refresh starts the next snapshot against current live roots. +func (b *Budget) Refresh() error { + if err := b.Work(0); err != nil { + return err + } + if b.refresh != nil { + return b.refresh() + } + return nil +} + // Work charges work before a traversal, hash, copy, or allocation performs it. func (b *Budget) Work(units int) error { if b.ctx != nil { @@ -144,11 +160,19 @@ type Options struct { } type nodeKey struct { - id uintptr - kind value.ValueKind - tag value.ObjectTag + id uintptr + kind value.ValueKind + tag value.ObjectTag + preserveTags bool } +type graphTraits uint8 + +const ( + hasTags graphTraits = 1 << iota + hasRuntimeValues +) + type cloneEntry struct { // Keep the source alive while its uintptr identity is in the memo. source value.Value @@ -156,6 +180,7 @@ type cloneEntry struct { err error height int active bool + traits graphTraits } type memoEntry struct { @@ -167,12 +192,13 @@ type memoEntry struct { // graph. Create a new Cloner after invoking host code or yielding a snapshot; // reuse its Budget to retain cumulative limits without retaining stale clones. type Cloner struct { - budget *Budget - options Options - memo map[nodeKey]cloneEntry - inline [inlineMemoSize]memoEntry - inlineCount int - err error + budget *Budget + options Options + memo map[nodeKey]cloneEntry + inline [inlineMemoSize]memoEntry + inlineCount int + err error + validateOnly bool } // NewCloner starts an independent identity memo using the operation's budget. @@ -185,10 +211,16 @@ func NewCloner(budget *Budget, options Options) *Cloner { // Clone validates and isolates one root, preserving aliases to earlier roots. func (c *Cloner) Clone(label string, source value.Value) (value.Value, error) { + return c.CloneWithOptions(label, source, c.options) +} + +// CloneWithOptions applies a root's existing containment policy. Tag-free data +// shares its clone across policies; tagged ancestors receive separate views. +func (c *Cloner) CloneWithOptions(label string, source value.Value, options Options) (value.Value, error) { if c.err != nil { return value.NewNil(), c.err } - cloned, _, err := c.clone(source, 0) + cloned, _, _, err := c.clone(source, 0, options) if err != nil { c.err = labeledError(label, err) return value.NewNil(), c.err @@ -196,8 +228,27 @@ func (c *Cloner) Clone(label string, source value.Value) (value.Value, error) { return cloned, nil } +// Hash clones a hash or object using the request memo and returns its entries. +func (c *Cloner) Hash(label string, source value.Value) (map[string]value.Value, error) { + cloned, err := c.Clone(label, source) + if err != nil { + return nil, err + } + if cloned.Kind() != value.KindHash && cloned.Kind() != value.KindObject { + return nil, fmt.Errorf("%s expected hash, got %s", label, source.Kind()) + } + // The same wrapper can also occur in another request root. Mark its map + // exposed so later host map writes and HashSet calls reconcile key order. + return cloned.Hash(), nil +} + // Kwargs isolates keyword values with the same memo as positional roots. func (c *Cloner) Kwargs(method string, source map[string]value.Value) (map[string]value.Value, error) { + return c.KwargsWithOptions(method, source, c.options) +} + +// KwargsWithOptions applies a keyword group's policy with the request's memo. +func (c *Cloner) KwargsWithOptions(method string, source map[string]value.Value, options Options) (map[string]value.Value, error) { if c.err != nil { return nil, c.err } @@ -207,7 +258,7 @@ func (c *Cloner) Kwargs(method string, source map[string]value.Value) (map[strin if err := c.budget.checkEdges(len(source)); err != nil { return nil, labeledError(method+" keywords", err) } - if err := c.reserveMap(len(source)); err != nil { + if err := c.budget.ReserveMap(len(source)); err != nil { return nil, labeledError(method+" keywords", err) } out := make(map[string]value.Value, len(source)) @@ -215,7 +266,7 @@ func (c *Cloner) Kwargs(method string, source map[string]value.Value) (map[strin if err := c.budget.Work(len(key)); err != nil { return nil, labeledError(method+" keywords", err) } - cloned, err := c.Clone(method+" keyword "+key, item) + cloned, err := c.CloneWithOptions(method+" keyword "+key, item, options) if err != nil { return nil, err } @@ -224,49 +275,61 @@ func (c *Cloner) Kwargs(method string, source map[string]value.Value) (map[strin return out, nil } -func (c *Cloner) clone(source value.Value, depth int) (value.Value, int, error) { +func (c *Cloner) clone(source value.Value, depth int, options Options) (value.Value, int, graphTraits, error) { if depth > MaxDepth { - return value.NewNil(), 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} + return value.NewNil(), 0, 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} } if err := c.budget.visit(); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } switch source.Kind() { case value.KindFunction, value.KindBuiltin, value.KindBlock, value.KindClass, value.KindInstance, value.KindShape: - if !c.options.AllowRuntimeValues { - return value.NewNil(), 0, errCallable + if !options.AllowRuntimeValues { + return value.NewNil(), 0, hasRuntimeValues, errCallable } - return source, 0, nil + return source, 0, hasRuntimeValues, nil case value.KindArray, value.KindHash, value.KindObject: default: - return source, 0, nil + return source, 0, 0, nil + } + key := c.identity(source, options) + entry, found := c.lookup(key) + if !found { + other := key + other.preserveTags = !other.preserveTags + if alternative, ok := c.lookup(other); ok && !alternative.active && alternative.traits&hasTags == 0 { + entry, found = alternative, true + } } - key := c.identity(source) - if entry, ok := c.lookup(key); ok { + if found { if entry.active { - return value.NewNil(), 0, errCycle + return value.NewNil(), 0, 0, errCycle } if entry.height > MaxDepth-depth { - return value.NewNil(), 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} + return value.NewNil(), 0, 0, &limitError{message: fmt.Sprintf("exceeds maximum depth %d", MaxDepth)} } - return entry.cloned, entry.height, entry.err + if !options.AllowRuntimeValues && entry.traits&hasRuntimeValues != 0 { + return value.NewNil(), 0, entry.traits, errCallable + } + return entry.cloned, entry.height, entry.traits, entry.err } if err := c.budget.node(); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } if err := c.remember(key, cloneEntry{source: source, active: true}); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } var cloned value.Value var height int + var traits graphTraits var err error if source.Kind() == value.KindArray { - cloned, height, err = c.cloneArray(source, depth) + cloned, height, traits, err = c.cloneArray(source, depth, options) } else { - cloned, height, err = c.cloneMap(source, depth) + cloned, height, traits, err = c.cloneMap(source, depth, options) } - c.complete(key, cloneEntry{source: source, cloned: cloned, height: height, err: err}) - return cloned, height, err + c.complete(key, cloneEntry{source: source, cloned: cloned, height: height, traits: traits, err: err}) + return cloned, height, traits, err } func (c *Cloner) lookup(key nodeKey) (cloneEntry, bool) { @@ -318,8 +381,8 @@ func (c *Cloner) complete(key nodeKey, entry cloneEntry) { } } -func (c *Cloner) identity(source value.Value) nodeKey { - key := nodeKey{kind: source.Kind()} +func (c *Cloner) identity(source value.Value, options Options) nodeKey { + key := nodeKey{kind: source.Kind(), preserveTags: options.PreserveObjectTags} switch source.Kind() { case value.KindArray: key.id = value.ArrayIdentity(source) @@ -327,55 +390,70 @@ func (c *Cloner) identity(source value.Value) nodeKey { key.id = value.HashIdentity(source) case value.KindObject: key.id = reflect.ValueOf(source.HashEntryMap()).Pointer() - if c.options.PreserveObjectTags { - key.tag = source.ObjectTag() - } + key.tag = source.ObjectTag() } return key } -func (c *Cloner) cloneArray(source value.Value, depth int) (value.Value, int, error) { +func (c *Cloner) cloneArray(source value.Value, depth int, options Options) (value.Value, int, graphTraits, error) { items := source.Array() if err := c.budget.checkEdges(len(items)); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } - if err := c.budget.reserveSlots(len(items), valueBytes, value.ArrayDataBytes); err != nil { - return value.NewNil(), 0, err + var out []value.Value + if !c.validateOnly { + if err := c.budget.reserveSlots(len(items), valueBytes, value.ArrayDataBytes); err != nil { + return value.NewNil(), 0, 0, err + } + out = make([]value.Value, len(items)) } - out := make([]value.Value, len(items)) height := 0 + var traits graphTraits var cycle error for i, item := range items { - cloned, childHeight, err := c.clone(item, depth+1) + cloned, childHeight, childTraits, err := c.clone(item, depth+1, options) if err != nil && !errors.Is(err, errCycle) { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } + traits |= childTraits if errors.Is(err, errCycle) { cycle = err } height = max(height, childHeight+1) - out[i] = cloned + if !c.validateOnly { + out[i] = cloned + } } if cycle != nil { - return value.NewNil(), height, cycle + return value.NewNil(), height, traits, cycle } - return value.NewArray(out), height, nil + if c.validateOnly { + return source, height, traits, nil + } + return value.NewArray(out), height, traits, nil } -func (c *Cloner) reserveMap(count int) error { +// ReserveMap checks space for a cloned string-keyed map before allocation. +func (b *Budget) ReserveMap(count int) error { + if err := b.checkEdges(count); err != nil { + return err + } // Include capacity slack and a minimum group, as in the runtime's // structural map estimates, before any bucket or key-order allocation. const slotBytes = 2 * (16 + valueBytes + 32) - return c.budget.reserveSlots(count, slotBytes, 64+8*slotBytes) + return b.reserveSlots(count, slotBytes, 64+8*slotBytes) } -func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, error) { +func (c *Cloner) cloneMap(source value.Value, depth int, options Options) (value.Value, int, graphTraits, error) { + if c.validateOnly { + return c.validateMap(source, depth, options) + } items := source.HashEntryMap() if err := c.budget.checkEdges(len(items)); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } - if err := c.reserveMap(len(items)); err != nil { - return value.NewNil(), 0, err + if err := c.budget.ReserveMap(len(items)); err != nil { + return value.NewNil(), 0, 0, err } // A key-order fallback sorts the map keys. Account for comparison and // rehashing work, including long common prefixes, before that helper runs. @@ -386,10 +464,10 @@ func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, erro scalarOnly := true for key, item := range items { if len(key) > (maxWork-c.budget.work)/factor { - return value.NewNil(), 0, exceeded("work", maxWork) + return value.NewNil(), 0, 0, exceeded("work", maxWork) } if err := c.budget.Work(len(key)*factor + 1); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } if !scalar(item.Kind()) { scalarOnly = false @@ -397,6 +475,10 @@ func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, erro } out := make(map[string]value.Value, len(items)) height := 0 + var traits graphTraits + if source.Kind() == value.KindObject && source.ObjectTag() != value.ObjectTagNone { + traits |= hasTags + } var cycle error for key, item := range items { childDepth := depth + 1 @@ -404,10 +486,11 @@ func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, erro if scalarOnly { childDepth = depth } - cloned, childHeight, err := c.clone(item, childDepth) + cloned, childHeight, childTraits, err := c.clone(item, childDepth, options) if err != nil && !errors.Is(err, errCycle) { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } + traits |= childTraits if errors.Is(err, errCycle) { cycle = err } @@ -417,23 +500,23 @@ func (c *Cloner) cloneMap(source value.Value, depth int) (value.Value, int, erro out[key] = cloned } if cycle != nil { - return value.NewNil(), height, cycle + return value.NewNil(), height, traits, cycle } if source.Kind() == value.KindHash { if err := c.budget.reserveSlots(len(items), valueBytes+16, value.HashDataBytes); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } - return value.NewHashWithTrustedOrder(out, source.HashKeyOrder()), height, nil + return value.NewHashWithTrustedOrder(out, source.HashKeyOrder()), height, traits, nil } if err := c.budget.Reserve(value.ObjectDataBytes); err != nil { - return value.NewNil(), 0, err + return value.NewNil(), 0, 0, err } - if c.options.PreserveObjectTags { + if options.PreserveObjectTags { if text, ok := source.ObjectStringForm(); ok { - return value.NewTaggedObject(out, source.ObjectTag(), text), height, nil + return value.NewTaggedObject(out, source.ObjectTag(), text), height, traits, nil } } - return value.NewObject(out), height, nil + return value.NewObject(out), height, traits, nil } func scalar(kind value.ValueKind) bool { diff --git a/internal/capabilitydata/context.go b/internal/capabilitydata/context.go new file mode 100644 index 000000000..43731340e --- /dev/null +++ b/internal/capabilitydata/context.go @@ -0,0 +1,45 @@ +package capabilitydata + +import "context" + +type budgetContext struct { + context.Context + budget *Budget +} + +// WithBudget transports a budget only between runtime and first-party adapters. +// The receiver must unpack it before passing the context to host code. +func WithBudget(ctx context.Context, budget *Budget) context.Context { + return budgetContext{Context: ctx, budget: budget} +} + +// UnpackBudget returns the exact original context and operation budget. Ordinary +// callers receive a standalone budget; no state is stored on their context. +func UnpackBudget(ctx context.Context) (context.Context, *Budget) { + if wrapped, ok := ctx.(budgetContext); ok { + return wrapped.Context, wrapped.budget + } + return ctx, NewBudget(ctx, nil, nil) +} + +// Execution is the optional host execution context needed for budget fallback. +type Execution interface { + Context() context.Context + Step() error +} + +// ExecutionBudget uses the runtime's private provider when available, preserving +// the existing public execution interfaces for direct embedders. +func ExecutionBudget(exec Execution) *Budget { + if provider, ok := exec.(interface{ CapabilityDataBudget() *Budget }); ok { + return provider.CapabilityDataBudget() + } + return NewBudget(exec.Context(), func(steps int) error { + for range steps { + if err := exec.Step(); err != nil { + return err + } + } + return nil + }, nil) +} diff --git a/internal/capabilitydata/policy_test.go b/internal/capabilitydata/policy_test.go new file mode 100644 index 000000000..1c94d3b10 --- /dev/null +++ b/internal/capabilitydata/policy_test.go @@ -0,0 +1,57 @@ +package capabilitydata + +import ( + "errors" + "testing" + + "github.com/mgomes/vibescript/vibes/value" +) + +func TestCloneSharesInsensitiveChildrenAcrossPolicies(t *testing.T) { + t.Parallel() + for _, preserveFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "strip_first", true: "preserve_first"}[preserveFirst], func(t *testing.T) { + t.Parallel() + shared := value.NewArray([]value.Value{value.NewInt(1)}) + tagged := value.NewTaggedObject(map[string]value.Value{"child": shared}, value.ObjectTagRescuedError, "original") + source := value.NewArray([]value.Value{tagged, shared}) + cloner := NewCloner(nil, Options{}) + first, err := cloner.CloneWithOptions("first", source, Options{PreserveObjectTags: preserveFirst}) + if err != nil { + t.Fatal(err) + } + second, err := cloner.CloneWithOptions("second", source, Options{PreserveObjectTags: !preserveFirst}) + if err != nil { + t.Fatal(err) + } + preserved, stripped := first, second + if !preserveFirst { + preserved, stripped = second, first + } + if value.ArrayIdentity(preserved) == value.ArrayIdentity(stripped) { + t.Error("Clone reused an ancestor that needs two containment views") + } + if preserved.Array()[0].ObjectTag() != value.ObjectTagRescuedError || stripped.Array()[0].ObjectTag() != value.ObjectTagNone { + t.Error("Clone changed a containment view's provenance") + } + id := value.ArrayIdentity(first.Array()[1]) + for _, child := range []value.Value{second.Array()[1], first.Array()[0].HashEntryMap()["child"], second.Array()[0].HashEntryMap()["child"]} { + if value.ArrayIdentity(child) != id { + t.Error("Clone duplicated a tag-free child across containment views") + } + } + }) + } +} + +func TestStrictCloneRejectsPermissiveMemoEntry(t *testing.T) { + t.Parallel() + source := value.NewArray([]value.Value{value.NewValue(value.KindFunction, nil)}) + cloner := NewCloner(nil, Options{}) + if _, err := cloner.CloneWithOptions("validated", source, Options{AllowRuntimeValues: true}); err != nil { + t.Fatal(err) + } + if _, err := cloner.Clone("strict", source); !errors.Is(err, errCallable) { + t.Fatalf("Clone(strict) error = %v, want data-only rejection", err) + } +} diff --git a/internal/capabilitydata/validate.go b/internal/capabilitydata/validate.go new file mode 100644 index 000000000..008ec168c --- /dev/null +++ b/internal/capabilitydata/validate.go @@ -0,0 +1,76 @@ +package capabilitydata + +import ( + "errors" + + "github.com/mgomes/vibescript/vibes/value" +) + +// Validator checks a complete argument graph without copying its containers. +// Depth errors precede callable errors, which precede cycle errors. +type Validator struct { + cloner *Cloner +} + +// NewValidator starts a validation memo sharing the operation's budget. +func NewValidator(budget *Budget) *Validator { + cloner := NewCloner(budget, Options{AllowRuntimeValues: true}) + cloner.validateOnly = true + return &Validator{cloner: cloner} +} + +// Validate rejects runtime values, cycles, and excessive graph depth or work. +func (v *Validator) Validate(label string, source value.Value) error { + _, _, traits, err := v.cloner.clone(source, 0, v.cloner.options) + if err != nil && !errors.Is(err, errCycle) { + return labeledError(label, err) + } + if traits&hasRuntimeValues != 0 { + return labeledError(label, errCallable) + } + if err != nil { + return labeledError(label, err) + } + return nil +} + +// Kwargs validates every keyword using one memo and cumulative budget. +func (v *Validator) Kwargs(method string, kwargs map[string]value.Value) error { + if err := v.cloner.budget.checkEdges(len(kwargs)); err != nil { + return labeledError(method+" keywords", err) + } + for key, item := range kwargs { + if err := v.cloner.budget.Work(len(key)); err != nil { + return labeledError(method+" keywords", err) + } + if err := v.Validate(method+" keyword "+key, item); err != nil { + return err + } + } + return nil +} + +func (c *Cloner) validateMap(source value.Value, depth int, options Options) (value.Value, int, graphTraits, error) { + items := source.HashEntryMap() + if err := c.budget.checkEdges(len(items)); err != nil { + return value.NewNil(), 0, 0, err + } + height := 0 + var traits graphTraits + var cycle error + if source.Kind() == value.KindObject && source.ObjectTag() != value.ObjectTagNone { + traits |= hasTags + } + for _, item := range items { + _, childHeight, childTraits, err := c.clone(item, depth+1, options) + if err != nil && !errors.Is(err, errCycle) { + return value.NewNil(), 0, 0, err + } + if errors.Is(err, errCycle) { + cycle = err + } + height = max(height, childHeight+1) + traits |= childTraits + } + return source, height, traits, cycle +} diff --git a/internal/capabilitydata/validate_test.go b/internal/capabilitydata/validate_test.go new file mode 100644 index 000000000..69c0e3fbb --- /dev/null +++ b/internal/capabilitydata/validate_test.go @@ -0,0 +1,100 @@ +package capabilitydata + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/mgomes/vibescript/vibes/value" +) + +func TestValidatorErrorPrecedence(t *testing.T) { + t.Parallel() + cycle := value.NewArray(make([]value.Value, 1)) + cycle.Array()[0] = cycle + callable := value.NewValue(value.KindFunction, nil) + deep := value.NewInt(1) + for range MaxDepth + 1 { + deep = value.NewArray([]value.Value{deep}) + } + for _, tc := range []struct { + name string + items []value.Value + want string + }{ + {"cycle_before_callable", []value.Value{cycle, callable}, "must be data-only"}, + {"callable_before_cycle", []value.Value{callable, cycle}, "must be data-only"}, + {"depth_after_callable", []value.Value{callable, deep}, "exceeds maximum depth"}, + {"depth_after_cycle", []value.Value{cycle, deep}, "exceeds maximum depth"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := NewValidator(nil).Validate("payload", value.NewArray(tc.items)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Validate error = %v, want %s", err, tc.want) + } + }) + } +} + +func TestValidatorChecksLongestAliasedPath(t *testing.T) { + t.Parallel() + leaf := value.NewArray([]value.Value{value.NewInt(1)}) + deep := leaf + for range MaxDepth - 1 { + deep = value.NewArray([]value.Value{deep}) + } + validator := NewValidator(nil) + if err := validator.Validate("first", leaf); err != nil { + t.Fatal(err) + } + if err := validator.Validate("at limit", deep); err != nil { + t.Fatal(err) + } + err := validator.Validate("over limit", value.NewArray([]value.Value{deep})) + var limit *limitError + if !errors.As(err, &limit) { + t.Fatalf("Validate longer alias error = %v, want depth limit", err) + } +} + +func TestValidatorSharesMemoWithoutCloning(t *testing.T) { + t.Parallel() + graph := value.NewArray([]value.Value{value.NewInt(1)}) + for range 20 { + graph = value.NewArray([]value.Value{graph, graph}) + } + budget := NewBudget(context.Background(), nil, nil) + validator := NewValidator(budget) + if err := validator.Validate("first", graph); err != nil { + t.Fatal(err) + } + nodes, bytes := budget.nodes, budget.bytes + if err := validator.Kwargs("method", map[string]value.Value{"a": graph, "b": graph}); err != nil { + t.Fatal(err) + } + if budget.nodes != nodes || budget.bytes != bytes { + t.Fatal("validation allocated or walked a previously validated graph again") + } + if nodes != 21 || budget.edges > 64 { + t.Fatalf("validation visited %d nodes and %d edges for a 21-node graph", nodes, budget.edges) + } +} + +func TestValidatorCancellationAndReservationFailure(t *testing.T) { + t.Parallel() + graph := value.NewInt(1) + for range 32 { + graph = value.NewArray([]value.Value{graph}) + } + refused := errors.New("memory quota exceeded") + budget := NewBudget(context.Background(), nil, func(int) error { return refused }) + if err := NewValidator(budget).Validate("payload", graph); !errors.Is(err, refused) { + t.Fatalf("Validate error = %v, want memo reservation refusal", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := NewValidator(NewBudget(ctx, nil, nil)).Validate("payload", graph); !errors.Is(err, context.Canceled) { + t.Fatalf("Validate error = %v, want cancellation", err) + } +} diff --git a/internal/jobqueueoptions/options.go b/internal/jobqueueoptions/options.go new file mode 100644 index 000000000..ce5f45bea --- /dev/null +++ b/internal/jobqueueoptions/options.go @@ -0,0 +1,93 @@ +// Package jobqueueoptions shares option parsing between public and runtime adapters. +package jobqueueoptions + +import ( + "fmt" + "time" + + "github.com/mgomes/vibescript/internal/capabilitydata" + "github.com/mgomes/vibescript/vibes/value" +) + +// Options is the internal representation of parsed enqueue options. +type Options struct { + Delay *time.Duration + Key *string + Kwargs map[string]value.Value +} + +// Parse retains delay/key checks and clones extra options with the request's memo. +// The validated mode permits runtime values already checked by the caller. +func Parse(name string, kwargs map[string]value.Value, budget *capabilitydata.Budget, cloner *capabilitydata.Cloner, validate bool) (Options, error) { + if len(kwargs) == 0 { + return Options{}, nil + } + + if err := budget.ReserveMap(len(kwargs)); err != nil { + return Options{}, err + } + validator := capabilitydata.NewValidator(budget) + var delay *time.Duration + var key *string + extra := make(map[string]value.Value) + + for k, v := range kwargs { + if err := budget.Work(len(k) + 1); err != nil { + return Options{}, err + } + switch k { + case "delay": + d, err := valueToTimeDuration(name, v) + if err != nil { + return Options{}, err + } + if d < 0 { + return Options{}, fmt.Errorf("%s.enqueue delay must be non-negative", name) + } + delay = &d + case "key": + if v.Kind() != value.KindString { + return Options{}, fmt.Errorf("%s.enqueue key must be a string", name) + } + s := v.String() + if s == "" { + return Options{}, fmt.Errorf("%s.enqueue key must be non-empty", name) + } + key = &s + default: + if validate { + label := fmt.Sprintf("%s.enqueue keyword %s", name, k) + if err := validator.Validate(label, v); err != nil { + return Options{}, err + } + } + cloned, err := cloner.CloneWithOptions(name+".enqueue keyword "+k, v, capabilitydata.Options{AllowRuntimeValues: !validate}) + if err != nil { + return Options{}, err + } + extra[k] = cloned + } + } + + opts := Options{Delay: delay, Key: key} + if len(extra) > 0 { + opts.Kwargs = extra + } + return opts, nil +} + +func valueToTimeDuration(name string, val value.Value) (time.Duration, error) { + switch val.Kind() { + case value.KindDuration: + secs := val.Duration().Seconds() + return time.Duration(secs) * time.Second, nil + case value.KindInt, value.KindFloat: + secs, err := value.ValueToInt64(val) + if err != nil { + return 0, err + } + return time.Duration(secs) * time.Second, nil + default: + return 0, fmt.Errorf("%s.enqueue delay must be duration or numeric seconds", name) + } +} diff --git a/internal/runtime/call.go b/internal/runtime/call.go index 1334a0205..bf6ea89d6 100644 --- a/internal/runtime/call.go +++ b/internal/runtime/call.go @@ -1355,6 +1355,11 @@ func (r *callFunctionRebinder) rebindKeywords(kwargs map[string]Value) map[strin // An adapter that declares no contracts costs no walk: no host code runs, so // there is nothing to check ahead of. func hostCapabilityContracts(exec *Execution, adapter CapabilityAdapter) (map[string]CapabilityMethodContract, error) { + if internal, ok := adapter.(interface { + runtimeCapabilityContracts() map[string]CapabilityMethodContract + }); ok { + return internal.runtimeCapabilityContracts(), nil + } provider, ok := adapter.(CapabilityContractProvider) if !ok { return nil, nil @@ -1378,7 +1383,13 @@ func bindHostCapability(exec *Execution, adapter CapabilityAdapter, binding Capa if err := exec.checkMemory(); err != nil { return nil, nil, err } - globals, bound = adapter.Bind(binding) + if internal, ok := adapter.(interface { + bindWithExecution(*Execution, CapabilityBinding) (map[string]Value, error) + }); ok { + globals, bound = internal.bindWithExecution(exec, binding) + } else { + globals, bound = adapter.Bind(binding) + } return globals, bound, nil } diff --git a/internal/runtime/capabilities.go b/internal/runtime/capabilities.go index d714b9e7c..887d8fe94 100644 --- a/internal/runtime/capabilities.go +++ b/internal/runtime/capabilities.go @@ -4,9 +4,10 @@ import ( "context" "errors" "fmt" - "maps" "reflect" "slices" + + "github.com/mgomes/vibescript/internal/capabilitydata" ) // CapabilityAdapter binds host capabilities into a script invocation. @@ -305,17 +306,6 @@ func (state *deepCloneState) clonedPtr(id uintptr, spilled map[uintptr]Value, sm return NewNil(), false } -func mergeHash(dest, src map[string]Value) map[string]Value { - if len(src) == 0 { - return dest - } - if dest == nil { - dest = make(map[string]Value, len(src)) - } - maps.Copy(dest, src) - return dest -} - var ( capabilityTypeAny = &TypeExpr{ Name: "any", @@ -329,24 +319,16 @@ var ( const maxCapabilityDataOnlyDepth = 256 -func cloneCapabilityKwargs(kwargs map[string]Value) map[string]Value { - if len(kwargs) == 0 { - return nil - } - return cloneHash(kwargs) -} - func validateCapabilityKwargsDataOnly(method string, kwargs map[string]Value) error { - for key, val := range kwargs { - if err := validateCapabilityTypedValue(fmt.Sprintf("%s keyword %s", method, key), val, capabilityTypeAny); err != nil { - return err - } - } - return nil + return capabilitydata.NewValidator(nil).Kwargs(method, kwargs) } func validateCapabilityTypedValue(label string, val Value, ty *TypeExpr) error { - if err := validateCapabilityDataOnlyValue(label, val); err != nil { + return validateCapabilityTypedValueWithValidator(capabilitydata.NewValidator(nil), label, val, ty) +} + +func validateCapabilityTypedValueWithValidator(validator *capabilitydata.Validator, label string, val Value, ty *TypeExpr) error { + if err := validator.Validate(label, val); err != nil { return err } if err := checkValueType(val, ty); err != nil { @@ -358,10 +340,6 @@ func validateCapabilityTypedValue(label string, val Value, ty *TypeExpr) error { return nil } -func validateCapabilityHashValue(label string, val Value) error { - return validateCapabilityTypedValue(label, val, capabilityTypeHash) -} - func capabilityValidateAnyReturn(method string) func(result Value) error { return func(result Value) error { return validateCapabilityTypedValue(method+" return value", result, capabilityTypeAny) @@ -372,149 +350,12 @@ func cloneCapabilityMethodResult(method string, result Value) (Value, error) { return cloneCapabilityDataOnlyValue(method+" return value", result) } -type capabilityDataCloneScanner struct { - label string - clonedArrays map[uintptr]Value - clonedMaps map[uintptr]Value - clonedObjects map[objectCloneKey]Value - visitingArrays map[uintptr]struct{} - visitingMaps map[uintptr]struct{} -} - func cloneCapabilityDataOnlyValue(label string, val Value) (Value, error) { - if err := validateCapabilityTraversalDepth(label, val); err != nil { + budget := capabilitydata.NewBudget(context.Background(), nil, nil) + if err := capabilitydata.NewValidator(budget).Validate(label, val); err != nil { return NewNil(), err } - scanner := &capabilityDataCloneScanner{ - label: label, - clonedArrays: make(map[uintptr]Value), - clonedMaps: make(map[uintptr]Value), - clonedObjects: make(map[objectCloneKey]Value), - visitingArrays: make(map[uintptr]struct{}), - visitingMaps: make(map[uintptr]struct{}), - } - return scanner.clone(val) -} - -func (s *capabilityDataCloneScanner) clone(val Value) (Value, error) { - switch val.Kind() { - case KindFunction, KindBuiltin, KindBlock, KindClass, KindInstance, KindShape: - return NewNil(), fmt.Errorf("%s must be data-only", s.label) - case KindArray: - return s.cloneArray(val) - case KindHash: - return s.cloneHash(val) - case KindObject: - return s.cloneObject(val) - default: - return val, nil - } -} - -func (s *capabilityDataCloneScanner) cloneArray(val Value) (Value, error) { - // Key on the array wrapper identity so aliases of one mutable array clone - // to one shared object (and distinct empties stay distinct), and so a - // cyclic array is detected by object rather than by backing slice. - values := val.Array() - id := arrayIdentity(val) - if id != 0 { - if _, visiting := s.visitingArrays[id]; visiting { - return NewNil(), fmt.Errorf("%s must not contain cyclic references", s.label) - } - if cloned, ok := s.clonedArrays[id]; ok { - return cloned, nil - } - s.visitingArrays[id] = struct{}{} - } - clonedValues := make([]Value, len(values)) - cloned := NewArray(clonedValues) - if id != 0 { - s.clonedArrays[id] = cloned - } - for i, item := range values { - clonedItem, err := s.clone(item) - if err != nil { - return NewNil(), err - } - clonedValues[i] = clonedItem - } - // NewArray published the zero-filled slice, not these later inserts. - // A repeated child ([child, child]) must be shared, not left fresh. - publishCollectionElems(clonedValues) - if id != 0 { - delete(s.visitingArrays, id) - } - return cloned, nil -} - -func (s *capabilityDataCloneScanner) cloneHash(val Value) (Value, error) { - ptr := hashScanIdentity(val) - if ptr != 0 { - if _, visiting := s.visitingMaps[ptr]; visiting { - return NewNil(), fmt.Errorf("%s must not contain cyclic references", s.label) - } - if cloned, ok := s.clonedMaps[ptr]; ok { - return cloned, nil - } - s.visitingMaps[ptr] = struct{}{} - } - clonedEntries := make(map[string]Value, val.HashLen()) - cloned := NewHash(clonedEntries) - if ptr != 0 { - s.clonedMaps[ptr] = cloned - } - // The clone is filled entry by entry so it iterates in its source's order. - var entryBuf [smallHashKeyBufferSize]HashEntry - for _, entry := range val.HashEntriesInto(entryBuf[:]) { - clonedItem, err := s.clone(entry.Value) - if err != nil { - return NewNil(), err - } - setClonedHashEntry(cloned, entry.Key, clonedItem) - } - if ptr != 0 { - delete(s.visitingMaps, ptr) - } - return cloned, nil -} - -func (s *capabilityDataCloneScanner) cloneObject(val Value) (Value, error) { - entries := val.HashEntryMap() - ptr := reflect.ValueOf(entries).Pointer() - if ptr != 0 { - if _, visiting := s.visitingMaps[ptr]; visiting { - return NewNil(), fmt.Errorf("%s must not contain cyclic references", s.label) - } - // Keyed by provenance as well as entry map: a host can return both a - // tagged bag and NewObject over its live Hash(), and sharing one clone - // would give the plain wrapper the tag or strip the tagged one's - // published rendering, depending on which was cloned first. - key := objectCloneKey{ptr: ptr, tag: val.ObjectTag()} - if cloned, ok := s.clonedObjects[key]; ok { - return cloned, nil - } - s.visitingMaps[ptr] = struct{}{} - } - clonedEntries := make(map[string]Value, len(entries)) - cloned := retagClonedObject(val, clonedEntries) - if ptr != 0 { - s.clonedObjects[objectCloneKey{ptr: ptr, tag: val.ObjectTag()}] = cloned - } - for key, item := range entries { - clonedItem, err := s.clone(item) - if err != nil { - return NewNil(), err - } - clonedEntries[key] = clonedItem - } - // retagClonedObject published the empty map, not these later inserts. - for _, item := range clonedEntries { - publishCollection(item) - } - if ptr != 0 { - delete(s.visitingMaps, ptr) - } - return cloned, nil + return capabilitydata.NewCloner(budget, capabilitydata.Options{PreserveObjectTags: true}).Clone(label, val) } type capabilityContractScanner struct { @@ -652,94 +493,7 @@ func (exec *Execution) recordCapabilityYield(args []Value) { } func validateCapabilityDataOnlyValue(label string, val Value) error { - if err := validateCapabilityTraversalDepth(label, val); err != nil { - return err - } - callableScanner := newCapabilityContractScanner() - if callableScanner.containsCallable(val) { - return fmt.Errorf("%s must be data-only", label) - } - cycleScanner := newCapabilityCycleScanner() - if cycleScanner.containsCycle(val) { - return fmt.Errorf("%s must not contain cyclic references", label) - } - return nil -} - -type capabilityTraversalDepthScanner struct { - visitingArrays map[sliceIdentity]struct{} - seenArrays map[sliceIdentity]int - visitingMaps map[uintptr]struct{} - seenMaps map[uintptr]int -} - -func newCapabilityTraversalDepthScanner() *capabilityTraversalDepthScanner { - return &capabilityTraversalDepthScanner{ - visitingArrays: make(map[sliceIdentity]struct{}), - seenArrays: make(map[sliceIdentity]int), - visitingMaps: make(map[uintptr]struct{}), - seenMaps: make(map[uintptr]int), - } -} - -func validateCapabilityTraversalDepth(label string, val Value) error { - return newCapabilityTraversalDepthScanner().check(label, val, 0) -} - -func (s *capabilityTraversalDepthScanner) check(label string, val Value, depth int) error { - if depth > maxCapabilityDataOnlyDepth { - return guardLimitErrorf("%s exceeds maximum depth %d", label, maxCapabilityDataOnlyDepth) - } - remainingDepth := maxCapabilityDataOnlyDepth - depth - switch val.Kind() { - case KindArray: - values := val.Array() - id := sliceIdentity{ - Ptr: reflect.ValueOf(values).Pointer(), - Len: len(values), - Cap: cap(values), - } - if seenRemaining, seen := s.seenArrays[id]; seen && seenRemaining <= remainingDepth { - return nil - } - if _, visiting := s.visitingArrays[id]; visiting { - return nil - } - s.visitingArrays[id] = struct{}{} - for _, item := range values { - if err := s.check(label, item, depth+1); err != nil { - return err - } - } - delete(s.visitingArrays, id) - if seenRemaining, seen := s.seenArrays[id]; !seen || remainingDepth < seenRemaining { - s.seenArrays[id] = remainingDepth - } - case KindHash, KindObject: - ptr := hashScanIdentity(val) - if seenRemaining, seen := s.seenMaps[ptr]; seen && seenRemaining <= remainingDepth { - return nil - } - if _, visiting := s.visitingMaps[ptr]; visiting { - return nil - } - s.visitingMaps[ptr] = struct{}{} - var entryErr error - // Hash keys are plain strings and nest nothing, so the values are the - // whole graph the depth guard has to count. - anyHashValue(val, func(item Value) bool { - entryErr = s.check(label, item, depth+1) - return entryErr != nil - }) - if entryErr != nil { - return entryErr - } - delete(s.visitingMaps, ptr) - if seenRemaining, seen := s.seenMaps[ptr]; !seen || remainingDepth < seenRemaining { - s.seenMaps[ptr] = remainingDepth - } - } - return nil + return capabilitydata.NewValidator(nil).Validate(label, val) } func bindCapabilityContracts( @@ -766,103 +520,6 @@ func bindCapabilityContractsExcluding( scanner.bindContracts(val, scope, target, scopes) } -type capabilityCycleScanner struct { - visitingArrays map[sliceIdentity]struct{} - visitingMaps map[uintptr]struct{} - seenArrays map[sliceIdentity]struct{} - seenMaps map[uintptr]struct{} -} - -func newCapabilityCycleScanner() *capabilityCycleScanner { - return &capabilityCycleScanner{ - visitingArrays: make(map[sliceIdentity]struct{}), - visitingMaps: make(map[uintptr]struct{}), - seenArrays: make(map[sliceIdentity]struct{}), - seenMaps: make(map[uintptr]struct{}), - } -} - -func (s *capabilityCycleScanner) containsCycle(val Value) bool { - switch val.Kind() { - case KindArray: - values := val.Array() - id := sliceIdentity{ - Ptr: reflect.ValueOf(values).Pointer(), - Len: len(values), - Cap: cap(values), - } - if _, seen := s.seenArrays[id]; seen { - return false - } - if _, visiting := s.visitingArrays[id]; visiting { - return true - } - s.visitingArrays[id] = struct{}{} - if slices.ContainsFunc(values, s.containsCycle) { - return true - } - delete(s.visitingArrays, id) - s.seenArrays[id] = struct{}{} - return false - case KindHash, KindObject: - // Key on the whole hash wrapper (or the entry-map pointer for objects, - // which never carry defaults) so two wrappers sharing one entry map but - // carrying distinct defaults are each walked: a second wrapper's default - // is not skipped at the seen check, and a data-only diamond of shared-map - // wrappers is not mistaken for a cycle. - ptr := hashScanIdentity(val) - if _, seen := s.seenMaps[ptr]; seen { - return false - } - if _, visiting := s.visitingMaps[ptr]; visiting { - return true - } - s.visitingMaps[ptr] = struct{}{} - if anyHashValue(val, s.containsCycle) { - return true - } - delete(s.visitingMaps, ptr) - s.seenMaps[ptr] = struct{}{} - return false - default: - return false - } -} - -func (s *capabilityContractScanner) containsCallable(val Value) bool { - switch val.Kind() { - case KindFunction, KindBuiltin, KindBlock, KindClass, KindInstance, KindShape: - return true - case KindArray: - values := val.Array() - id := sliceIdentity{ - Ptr: reflect.ValueOf(values).Pointer(), - Len: len(values), - Cap: cap(values), - } - if _, seen := s.seenArrays[id]; seen { - return false - } - s.seenArrays[id] = struct{}{} - return slices.ContainsFunc(values, s.containsCallable) - case KindHash, KindObject: - // A KindHash's default metadata lives outside its entry map, so two - // wrappers can share one map yet carry different defaults. Key the - // seen-set on the whole hash wrapper (falling back to the entry-map - // pointer for objects, which never carry defaults) so a second wrapper's - // callable default is not hidden by an earlier plain wrapper marking the - // shared map seen. - ptr := hashScanIdentity(val) - if _, seen := s.seenMaps[ptr]; seen { - return false - } - s.seenMaps[ptr] = struct{}{} - return anyHashValue(val, s.containsCallable) - default: - return false - } -} - // scanClosureEnv walks a closure's captured environment chain (the Env of a // script function or a block) and applies visit to every value bound in each // frame. It stops at the ambient global chain: builtins bound there are diff --git a/internal/runtime/capability_adapters.go b/internal/runtime/capability_adapters.go index d913eb986..242b388e3 100644 --- a/internal/runtime/capability_adapters.go +++ b/internal/runtime/capability_adapters.go @@ -3,6 +3,9 @@ package runtime import ( "fmt" + "github.com/mgomes/vibescript/internal/capabilitydata" + "github.com/mgomes/vibescript/internal/jobqueueoptions" + "github.com/mgomes/vibescript/vibes/capability/contextcap" "github.com/mgomes/vibescript/vibes/capability/db" "github.com/mgomes/vibescript/vibes/capability/events" @@ -68,26 +71,30 @@ func (c *jobQueueCapability) Bind(binding CapabilityBinding) (map[string]Value, func (c *jobQueueCapability) callEnqueue(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) { name := c.inner.Name method := name + ".enqueue" + budget, reservation, err := newCapabilityDataBudget(exec, receiver, args, kwargs, block) + if err != nil { + return NewNil(), err + } + defer reservation.release() if !exec.capabilityArgsValidated(method) { - if err := c.validateEnqueueContractArgs(args, kwargs, block); err != nil { + if err := c.validateEnqueueContractArgsWithBudget(budget, args, kwargs, block); err != nil { return NewNil(), err } } - // Whether the contract ran (capabilityArgsValidated) or the inline check - // above ran, validateEnqueueContractArgs has already walked kwargs for - // data-only and cycle violations, so use the validated parser to avoid - // traversing the option graph a second time. Direct embedders go through - // the safe jobqueue.ParseEnqueueOptions, which performs that walk. - options, err := jobqueue.ParseEnqueueOptionsValidated(name, kwargs) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{PreserveObjectTags: true}) + options, err := jobqueueoptions.Parse(name, kwargs, budget, cloner, false) + if err != nil { + return NewNil(), err + } + payload, err := cloner.Hash(method+" payload", args[1]) if err != nil { return NewNil(), err } - job := jobqueue.JobQueueJob{ Name: args[0].String(), - Payload: cloneHash(args[1].HashEntryMap()), - Options: options, + Payload: payload, + Options: jobqueue.JobQueueEnqueueOptions{Delay: options.Delay, Key: options.Key, Kwargs: options.Kwargs}, } result, err := c.inner.Queue.Enqueue(exec.Context(), job) @@ -97,7 +104,7 @@ func (c *jobQueueCapability) callEnqueue(exec *Execution, receiver Value, args [ if err := exec.checkContext(); err != nil { return NewNil(), err } - cloned, err := cloneCapabilityMethodResult(method, result) + cloned, err := cloneCapabilityResult(budget, method, result) if err != nil { return NewNil(), err } @@ -113,21 +120,41 @@ func (c *jobQueueCapability) callRetry(exec *Execution, receiver Value, args []V return NewNil(), fmt.Errorf("%s.retry is not supported", name) } method := name + ".retry" + budget, reservation, err := newCapabilityDataBudget(exec, receiver, args, kwargs, block) + if err != nil { + return NewNil(), err + } + defer reservation.release() if !exec.capabilityArgsValidated(method) { - if err := c.validateRetryContractArgs(args, kwargs, block); err != nil { + if err := c.validateRetryContractArgsWithBudget(budget, args, kwargs, block); err != nil { return NewNil(), err } } - options := make(map[string]Value) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{PreserveObjectTags: true}) + var positional map[string]Value if len(args) > 1 { - optsVal := args[1] - if optsVal.Kind() != KindHash && optsVal.Kind() != KindObject { - return NewNil(), fmt.Errorf("%s.retry options must be hash", name) + positional, err = cloner.Hash(method+" options", args[1]) + if err != nil { + return NewNil(), err + } + } + extra, err := cloner.Kwargs(method, kwargs) + if err != nil { + return NewNil(), err + } + if err := budget.ReserveMap(saturatingAdd(len(positional), len(extra))); err != nil { + return NewNil(), err + } + options := make(map[string]Value, len(positional)+len(extra)) + for _, entries := range []map[string]Value{positional, extra} { + for key, item := range entries { + if err := budget.Work(len(key) + 1); err != nil { + return NewNil(), err + } + options[key] = item } - options = mergeHash(options, cloneHash(optsVal.HashEntryMap())) } - options = mergeHash(options, cloneCapabilityKwargs(kwargs)) req := jobqueue.JobQueueRetryRequest{JobID: args[0].String(), Options: options} result, err := c.inner.Retry.Retry(exec.Context(), req) @@ -137,7 +164,7 @@ func (c *jobQueueCapability) callRetry(exec *Execution, receiver Value, args []V if err := exec.checkContext(); err != nil { return NewNil(), err } - cloned, err := cloneCapabilityMethodResult(method, result) + cloned, err := cloneCapabilityResult(budget, method, result) if err != nil { return NewNil(), err } @@ -165,6 +192,10 @@ func (c *jobQueueCapability) CapabilityContracts() map[string]CapabilityMethodCo } func (c *jobQueueCapability) validateEnqueueContractArgs(args []Value, kwargs map[string]Value, block Value) error { + return c.validateEnqueueContractArgsWithBudget(nil, args, kwargs, block) +} + +func (c *jobQueueCapability) validateEnqueueContractArgsWithBudget(budget *capabilitydata.Budget, args []Value, kwargs map[string]Value, block Value) error { method := c.inner.Name + ".enqueue" if len(args) != 2 { @@ -182,14 +213,18 @@ func (c *jobQueueCapability) validateEnqueueContractArgs(args []Value, kwargs ma return fmt.Errorf("%s expects job name as string or symbol", method) } - if err := validateCapabilityHashValue(method+" payload", args[1]); err != nil { + validator := capabilitydata.NewValidator(budget) + if err := validateCapabilityTypedValueWithValidator(validator, method+" payload", args[1], capabilityTypeHash); err != nil { return err } - - return validateCapabilityKwargsDataOnly(method, kwargs) + return validator.Kwargs(method, kwargs) } func (c *jobQueueCapability) validateRetryContractArgs(args []Value, kwargs map[string]Value, block Value) error { + return c.validateRetryContractArgsWithBudget(nil, args, kwargs, block) +} + +func (c *jobQueueCapability) validateRetryContractArgsWithBudget(budget *capabilitydata.Budget, args []Value, kwargs map[string]Value, block Value) error { method := c.inner.Name + ".retry" if len(args) < 1 || len(args) > 2 { @@ -204,13 +239,13 @@ func (c *jobQueueCapability) validateRetryContractArgs(args []Value, kwargs map[ return fmt.Errorf("%s expects job id string", method) } + validator := capabilitydata.NewValidator(budget) if len(args) == 2 { - if err := validateCapabilityHashValue(method+" options", args[1]); err != nil { + if err := validateCapabilityTypedValueWithValidator(validator, method+" options", args[1], capabilityTypeHash); err != nil { return err } } - - return validateCapabilityKwargsDataOnly(method, kwargs) + return validator.Kwargs(method, kwargs) } // ContextCapabilityResolver is an internal alias for contextcap.Resolver @@ -250,6 +285,25 @@ func (a *contextCapabilityAdapter) Bind(binding CapabilityBinding) (map[string]V return a.inner.Bind(binding.Context) } +func (a *contextCapabilityAdapter) bindWithExecution(exec *Execution, binding CapabilityBinding) (map[string]Value, error) { + budget, reservation, err := newCapabilityDataBudget(exec, NewNil(), nil, nil, NewNil()) + if err != nil { + return nil, err + } + defer reservation.release() + return a.inner.Bind(capabilitydata.WithBudget(binding.Context, budget)) +} + +// These adapters enforce their public contracts inside the budgeted call, as +// DB does. Keep the standalone validators available to direct embedders. +func (c *jobQueueCapability) runtimeCapabilityContracts() map[string]CapabilityMethodContract { + return nil +} + +func (c *eventsCapability) runtimeCapabilityContracts() map[string]CapabilityMethodContract { + return nil +} + // Internal aliases for db capability types so runtime code (and tests) // can keep referring to short names that match the public vibes facade. type ( @@ -307,18 +361,22 @@ func (a *dbCapabilityAdapter) CapabilityContracts() map[string]CapabilityMethodC } func (a *dbCapabilityAdapter) wrapCall(method string, fn, validatedFn func(db.ExecutionContext, []Value, map[string]Value, Value) (Value, error)) BuiltinFunc { - return func(exec *Execution, _ Value, args []Value, kwargs map[string]Value, block Value) (Value, error) { + return func(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) { + budget, reservation, err := newCapabilityDataBudget(exec, receiver, args, kwargs, block) + if err != nil { + return NewNil(), err + } + defer reservation.release() call := fn if validatedFn != nil && exec.capabilityArgsValidated(method) { call = validatedFn } - result, err := call(exec, args, kwargs, block) + result, err := call(&capabilityDataExecution{Execution: exec, budget: budget}, args, kwargs, block) if err != nil { return result, err } - // Every db call ends in CloneMethodResult, so the value returned here - // is already validated and isolated from host state; the proof lets - // the dispatcher skip detaching it a second time. + // Every db call validates and isolates its result from host state; + // the proof lets the dispatcher skip detaching it a second time. exec.markValidatedCapabilityReturn(method, result) return result, nil } @@ -376,18 +434,22 @@ func (c *eventsCapability) Bind(binding CapabilityBinding) (map[string]Value, er func (c *eventsCapability) callPublish(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) { method := c.inner.PublishMethodName() + budget, reservation, err := newCapabilityDataBudget(exec, receiver, args, kwargs, block) + if err != nil { + return NewNil(), err + } + defer reservation.release() + ctx := capabilitydata.WithBudget(exec.Context(), budget) var result Value - var err error if exec.capabilityArgsValidated(method) { - result, err = c.inner.PublishValidated(exec.Context(), args, kwargs, !block.IsNil()) + result, err = c.inner.PublishValidated(ctx, args, kwargs, !block.IsNil()) } else { - result, err = c.inner.Publish(exec.Context(), args, kwargs, !block.IsNil()) + result, err = c.inner.Publish(ctx, args, kwargs, !block.IsNil()) } if err != nil { return NewNil(), err } - // Both publish paths validate the host's return value and deep-clone it - // (events.Capability.PublishValidated ends in CloneMethodResult); record + // Both publish paths validate and isolate the host's return value; record // the internal proof so the dispatcher does not validate the result twice. exec.markValidatedCapabilityReturn(method, result) return result, nil diff --git a/internal/runtime/capability_data_budget.go b/internal/runtime/capability_data_budget.go new file mode 100644 index 000000000..afaac9853 --- /dev/null +++ b/internal/runtime/capability_data_budget.go @@ -0,0 +1,50 @@ +package runtime + +import "github.com/mgomes/vibescript/internal/capabilitydata" + +func newCapabilityDataBudget(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (*capabilitydata.Budget, *loopScratchReservation, error) { + reservation := &loopScratchReservation{exec: exec} + budget := capabilitydata.NewBudget(exec.Context(), exec.chargeScanSteps, reservation.reserve) + budget.SetSnapshotRefresh(func() error { + // The previous request or row has crossed its boundary. Release its + // temporary charge before measuring the next snapshot; copies retained + // by script code now belong to the live roots. The operation's Budget + // still counts cumulative work and allocation reservations. + reservation.release() + if exec.memoryQuota <= 0 { + return nil + } + used, walked := exec.hashCallRootUsage(receiver, args, kwargs, block) + reservation.baseline = used + if exec.memoryExceeded(used) { + return exec.memoryQuotaExceededError() + } + return budget.Work(walked) + }) + if err := budget.Refresh(); err != nil { + return nil, nil, err + } + return budget, reservation, nil +} + +type capabilityDataExecution struct { + *Execution + budget *capabilitydata.Budget +} + +// CapabilityDataBudget supplies a call-scoped budget without changing db's +// public ExecutionContext interface or wrapping the context seen by hosts. +func (e *capabilityDataExecution) CapabilityDataBudget() *capabilitydata.Budget { + return e.budget +} + +func cloneCapabilityResult(budget *capabilitydata.Budget, method string, result Value) (Value, error) { + if err := budget.Refresh(); err != nil { + return NewNil(), err + } + label := method + " return value" + if err := capabilitydata.NewValidator(budget).Validate(label, result); err != nil { + return NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{PreserveObjectTags: true}).Clone(label, result) +} diff --git a/internal/runtime/capability_data_budget_test.go b/internal/runtime/capability_data_budget_test.go new file mode 100644 index 000000000..c5f465fab --- /dev/null +++ b/internal/runtime/capability_data_budget_test.go @@ -0,0 +1,394 @@ +package runtime + +import ( + "context" + "strings" + "testing" +) + +type mixedHashMutationDB struct{ dbCapabilityStub } + +func (d *mixedHashMutationDB) Update(_ context.Context, req DBUpdateRequest) (Value, error) { + alias := req.Options["alias"] + delete(req.Attributes, "a") + if err := alias.HashSet(NewString("a"), NewInt(3)); err != nil { + return NewNil(), err + } + req.Attributes["c"] = NewInt(4) + return alias, nil +} + +func capabilityDataCall(t *testing.T, adapter CapabilityAdapter, name, method string, exec *Execution, args []Value, kwargs map[string]Value) (Value, error) { + t.Helper() + bound, err := adapter.Bind(CapabilityBinding{Context: exec.Context()}) + if err != nil { + t.Fatal(err) + } + receiver := bound[name] + return BuiltinOf(receiver.HashEntryMap()[method]).Fn(exec, receiver, args, kwargs, NewNil()) +} + +func TestCapabilityRequestSharesClonesAcrossRoots(t *testing.T) { + t.Parallel() + for _, method := range []string{"db.find", "db.update", "db.query", "db.sum", "events.publish", "jobs.enqueue", "jobs.retry"} { + t.Run(method, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + exec := &Execution{ctx: ctx, quota: 1 << 20, memoryQuota: 1 << 20} + child := NewArray([]Value{NewInt(7)}) + payload := NewHash(map[string]Value{"child": child}) + kwargs := map[string]Value{"first": child, "second": child} + database, publisher, queue := &dbCapabilityStub{}, &eventsCapabilityStub{}, &jobQueueStub{} + var adapter CapabilityAdapter + var args []Value + switch method { + case "db.find": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), child} + case "db.update": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), child, payload} + case "db.query": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items")} + case "db.sum": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), NewString("total")} + case "events.publish": + adapter, args = MustNewEventsCapability("events", publisher), []Value{NewString("items"), payload} + case "jobs.enqueue": + adapter, args = MustNewJobQueueCapability("jobs", queue), []Value{NewString("items"), payload} + case "jobs.retry": + adapter, args = MustNewJobQueueCapability("jobs", queue), []Value{NewString("id"), payload} + } + name, operation, _ := strings.Cut(method, ".") + if _, err := capabilityDataCall(t, adapter, name, operation, exec, args, kwargs); err != nil { + t.Fatal(err) + } + var copies []Value + var hostContext context.Context + switch method { + case "db.find": + r := database.findCalls[0] + copies, hostContext = []Value{r.ID, r.Options["first"], r.Options["second"]}, database.findCtx[0] + case "db.update": + r := database.updateCalls[0] + copies, hostContext = []Value{r.ID, r.Attributes["child"], r.Options["first"], r.Options["second"]}, database.updateCtx[0] + case "db.query": + r := database.queryCalls[0] + copies, hostContext = []Value{r.Options["first"], r.Options["second"]}, database.queryCtx[0] + case "db.sum": + r := database.sumCalls[0] + copies, hostContext = []Value{r.Options["first"], r.Options["second"]}, database.sumCtx[0] + case "events.publish": + r := publisher.publishCalls[0] + copies, hostContext = []Value{r.Payload["child"], r.Options["first"], r.Options["second"]}, publisher.publishCtx[0] + case "jobs.enqueue": + r := queue.enqueueCalls[0] + copies, hostContext = []Value{r.Payload["child"], r.Options.Kwargs["first"], r.Options.Kwargs["second"]}, queue.enqueueCtx[0] + case "jobs.retry": + r := queue.retryCalls[0] + copies, hostContext = []Value{r.Options["child"], r.Options["first"], r.Options["second"]}, queue.retryCtx[0] + } + for _, copy := range copies { + if arrayIdentity(copy) != arrayIdentity(copies[0]) || arrayIdentity(copy) == arrayIdentity(child) { + t.Fatal("request roots must share one isolated child") + } + } + copies[0].Array()[0] = NewInt(9) + if child.Array()[0].Int() != 7 || copies[1].Array()[0].Int() != 9 { + t.Fatal("host mutation did not preserve request aliases and source isolation") + } + if hostContext != ctx { + t.Fatal("host retained a context other than the caller's original context") + } + if exec.reservedScratchBytes != 0 { + t.Fatalf("call retained %d scratch bytes", exec.reservedScratchBytes) + } + }) + } +} + +func TestCapabilityCopyStopsBeforeHostCall(t *testing.T) { + t.Parallel() + for _, method := range []string{"db.update", "events.publish", "jobs.enqueue", "jobs.retry"} { + for _, limit := range []string{"steps", "memory"} { + t.Run(method+"/"+limit, func(t *testing.T) { + t.Parallel() + exec := &Execution{ctx: context.Background(), quota: 1 << 20, memoryQuota: 1 << 20} + database, publisher, queue := &dbCapabilityStub{}, &eventsCapabilityStub{}, &jobQueueStub{} + payload := NewHash(map[string]Value{"data": NewArray(make([]Value, 4096))}) + var adapter CapabilityAdapter + var args []Value + switch method { + case "db.update": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), NewInt(1), payload} + case "events.publish": + adapter, args = MustNewEventsCapability("events", publisher), []Value{NewString("topic"), payload} + default: + adapter, args = MustNewJobQueueCapability("jobs", queue), []Value{NewString("job"), payload} + } + if limit == "steps" { + exec.quota, exec.memoryQuota = 32, 0 + } else { + // The source fits; a second backing array does not. + exec.memoryQuota = exec.hashCallRootBytes(NewNil(), args, nil, NewNil()) + 8192 + } + name, operation, _ := strings.Cut(method, ".") + _, err := capabilityDataCall(t, adapter, name, operation, exec, args, nil) + want := "step quota" + if limit == "memory" { + want = "memory quota" + } + requireErrorContains(t, err, want) + if len(database.updateCalls)+len(publisher.publishCalls)+len(queue.enqueueCalls)+len(queue.retryCalls) != 0 { + t.Fatal("host was called after the input copy exceeded its budget") + } + if exec.reservedScratchBytes != 0 { + t.Fatalf("failed copy retained %d scratch bytes", exec.reservedScratchBytes) + } + }) + } + } +} + +func TestCapabilityRuntimeMetersValidation(t *testing.T) { + t.Parallel() + for _, method := range []string{"events.publish", "jobs.enqueue", "jobs.retry"} { + t.Run(method, func(t *testing.T) { + t.Parallel() + publisher, queue := &eventsCapabilityStub{}, &jobQueueStub{} + var adapter CapabilityAdapter + if strings.HasPrefix(method, "events") { + adapter = MustNewEventsCapability("events", publisher) + } else { + adapter = MustNewJobQueueCapability("jobs", queue) + } + script := compileScriptWithConfig(t, Config{StepQuota: 100, MemoryQuotaBytes: Unlimited}, "def run(payload)\n "+method+"(\"item\", payload)\nend") + payload := NewHash(map[string]Value{"data": NewArray(make([]Value, 16384))}) + _, err := script.Call(context.Background(), "run", []Value{payload}, callOptionsWithCapabilities(adapter)) + requireErrorContains(t, err, "step quota") + if len(publisher.publishCalls)+len(queue.enqueueCalls)+len(queue.retryCalls) != 0 { + t.Fatal("runtime called host after validation exceeded the step quota") + } + }) + } +} + +func TestCapabilityRetryDoesNotIntroduceCycle(t *testing.T) { + t.Parallel() + queue := &jobQueueStub{} + payload := NewHash(map[string]Value{"child": NewArray([]Value{NewInt(1)})}) + exec := &Execution{ctx: context.Background(), quota: 1 << 20} + _, err := capabilityDataCall(t, MustNewJobQueueCapability("jobs", queue), "jobs", "retry", exec, + []Value{NewString("id"), payload}, map[string]Value{"original": payload}) + if err != nil { + t.Fatal(err) + } + options := queue.retryCalls[0].Options + if _, ok := options["original"].HashEntryMap()["original"]; ok { + t.Fatal("merging keyword options created a cycle in the positional snapshot") + } + if arrayIdentity(options["child"]) != arrayIdentity(options["original"].HashEntryMap()["child"]) { + t.Fatal("retry lost a shared child across positional and keyword options") + } +} + +func TestCapabilityReturnCopyUsesRuntimeBudget(t *testing.T) { + t.Parallel() + for _, method := range []string{"db.find", "db.query", "db.update", "db.sum", "events.publish", "jobs.enqueue", "jobs.retry"} { + for _, limit := range []string{"steps", "memory", "control"} { + t.Run(method+"/"+limit, func(t *testing.T) { + t.Parallel() + exec := &Execution{ctx: context.Background(), quota: 1 << 20, memoryQuota: 1 << 20} + child := NewArray(make([]Value, 4096)) + source := NewHash(map[string]Value{"a": child, "b": child}) + database := &dbCapabilityStub{findResult: source, queryResult: source, updateResult: source, sumResult: source} + publisher := &eventsCapabilityStub{publishResult: source} + queue := &sharedReturnQueue{enqueueResult: source, retryResult: source} + var adapter CapabilityAdapter + var args []Value + switch method { + case "db.find": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), NewInt(1)} + case "db.query": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items")} + case "db.update": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), NewInt(1), NewHash(nil)} + case "db.sum": + adapter, args = MustNewDBCapability("db", database), []Value{NewString("items"), NewString("total")} + case "events.publish": + adapter, args = MustNewEventsCapability("events", publisher), []Value{NewString("topic"), NewHash(nil)} + default: + adapter, args = MustNewJobQueueCapability("jobs", queue), []Value{NewString("job"), NewHash(nil)} + } + switch limit { + case "steps": + exec.quota, exec.memoryQuota = 256, 0 + case "memory": + exec.memoryQuota = 32 << 10 + } + name, operation, _ := strings.Cut(method, ".") + got, err := capabilityDataCall(t, adapter, name, operation, exec, args, nil) + switch limit { + case "steps": + requireErrorContains(t, err, "step quota") + requireErrorContains(t, err, "return value") + case "memory": + requireErrorContains(t, err, "memory quota") + requireErrorContains(t, err, "return value") + case "control": + if err != nil { + t.Fatal(err) + } + a, b := got.HashEntryMap()["a"], got.HashEntryMap()["b"] + if arrayIdentity(a) != arrayIdentity(b) || arrayIdentity(a) == arrayIdentity(child) { + t.Fatal("returned graph lost its aliases or host isolation") + } + } + if exec.reservedScratchBytes != 0 { + t.Fatalf("return copy retained %d scratch bytes", exec.reservedScratchBytes) + } + }) + } + } +} + +func TestContextCapabilityCopyUsesRuntimeBudget(t *testing.T) { + t.Parallel() + for _, limit := range []string{"steps", "memory", "control", "standalone"} { + t.Run(limit, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + exec := &Execution{ctx: ctx, quota: 1 << 20, memoryQuota: 1 << 20} + child := NewArray(make([]Value, 4096)) + source := NewHash(map[string]Value{"a": child, "b": child}) + var hostContext context.Context + adapter := MustNewContextCapability("ctx", func(ctx context.Context) (Value, error) { + hostContext = ctx + return source, nil + }).(*contextCapabilityAdapter) + switch limit { + case "steps": + exec.quota, exec.memoryQuota = 32, 0 + case "memory": + exec.memoryQuota = 32 << 10 + } + binding := CapabilityBinding{Context: ctx} + var bound map[string]Value + var err error + if limit == "standalone" { + bound, err = adapter.Bind(binding) + } else { + bound, err = adapter.bindWithExecution(exec, binding) + } + switch limit { + case "steps": + requireErrorContains(t, err, "step quota") + case "memory": + requireErrorContains(t, err, "memory quota") + default: + if err != nil { + t.Fatal(err) + } + a, b := bound["ctx"].HashEntryMap()["a"], bound["ctx"].HashEntryMap()["b"] + if arrayIdentity(a) != arrayIdentity(b) || arrayIdentity(a) == arrayIdentity(child) { + t.Fatal("context graph lost its aliases or host isolation") + } + } + if hostContext != ctx { + t.Fatal("resolver retained the runtime budget carrier instead of the original context") + } + if exec.reservedScratchBytes != 0 { + t.Fatalf("context copy retained %d scratch bytes", exec.reservedScratchBytes) + } + }) + } +} + +func TestJobQueueRequestPreservesDistinctTagPolicies(t *testing.T) { + t.Parallel() + queue := &jobQueueStub{} + child := NewArray([]Value{NewInt(1)}) + tagged := NewTaggedObject(map[string]Value{"child": child}, ObjectTagRescuedError, "original") + parent := NewArray([]Value{tagged, child}) + payload := NewHash(map[string]Value{"parent": parent}) + exec := &Execution{ctx: context.Background(), quota: 1 << 20} + _, err := capabilityDataCall(t, MustNewJobQueueCapability("jobs", queue), "jobs", "enqueue", exec, + []Value{NewString("job"), payload}, map[string]Value{"parent": parent}) + if err != nil { + t.Fatal(err) + } + job := queue.enqueueCalls[0] + preserved, stripped := job.Payload["parent"], job.Options.Kwargs["parent"] + if arrayIdentity(preserved) == arrayIdentity(stripped) { + t.Fatal("payload and options shared an ancestor containing policy-sensitive data") + } + if preserved.Array()[0].ObjectTag() != ObjectTagRescuedError || stripped.Array()[0].ObjectTag() != ObjectTagNone { + t.Fatal("enqueue changed payload or option provenance") + } + if arrayIdentity(preserved.Array()[1]) != arrayIdentity(stripped.Array()[1]) { + t.Fatal("enqueue duplicated a tag-free shared descendant") + } +} + +func TestContextCapabilityRefreshesMemoryAfterResolver(t *testing.T) { + t.Parallel() + exec := &Execution{ctx: context.Background(), root: newEnv(nil), quota: 1 << 20, memoryQuota: 32 << 10} + adapter := MustNewContextCapability("ctx", func(context.Context) (Value, error) { + exec.root.Define("retained", NewString(strings.Repeat("x", 64<<10))) + return NewHash(map[string]Value{"id": NewInt(1)}), nil + }).(*contextCapabilityAdapter) + _, err := adapter.bindWithExecution(exec, CapabilityBinding{Context: exec.Context()}) + requireErrorContains(t, err, "memory quota") + if exec.reservedScratchBytes != 0 { + t.Fatalf("failed refresh retained %d scratch bytes", exec.reservedScratchBytes) + } +} + +func TestDBEachMemoryCountsLiveSnapshots(t *testing.T) { + t.Parallel() + for _, retain := range []bool{false, true} { + name := "discard" + if retain { + name = "retain" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + row := NewArray(make([]Value, 64)) + rows := make([]Value, 500) + for i := range rows { + rows[i] = row + } + stub := &dbCapabilityStub{eachRows: rows} + body := "total = total + 1" + if retain { + body = "kept.push(row)" + } + script := compileScriptWithConfig(t, Config{StepQuota: 1 << 20, MemoryQuotaBytes: 128 << 10}, + "def run()\n total = 0\n kept = []\n db.each(\"items\") do |row|\n "+body+"\n end\n total\nend") + got, err := script.Call(context.Background(), "run", nil, callOptionsWithCapabilities(MustNewDBCapability("db", stub))) + if retain { + requireErrorContains(t, err, "memory quota") + } else if err != nil || got.Int() != 500 { + t.Fatalf("discarding rows = %v, %v; want 500 within live-memory quota", got, err) + } + }) + } +} + +func TestCapabilityExposedHashKeepsCompleteIteration(t *testing.T) { + t.Parallel() + source := NewHash(map[string]Value{"a": NewInt(1), "b": NewInt(2)}) + exec := &Execution{ctx: context.Background(), quota: 1 << 20} + result, err := capabilityDataCall(t, MustNewDBCapability("db", &mixedHashMutationDB{}), "db", "update", exec, + []Value{NewString("items"), NewInt(1), source}, map[string]Value{"alias": source}) + if err != nil { + t.Fatal(err) + } + keys := result.HashKeyOrder() + if len(keys) != 3 || keys[0].String() != "a" || keys[1].String() != "b" || keys[2].String() != "c" { + t.Fatalf("iteration after mixed map/wrapper writes = %v, want [a b c]", keys) + } + if source.HashLen() != 2 || source.HashEntryMap()["a"].Int() != 1 { + t.Fatal("host writes changed the original request") + } +} diff --git a/internal/runtime/memory.go b/internal/runtime/memory.go index 598d2c7c8..b644c88c7 100644 --- a/internal/runtime/memory.go +++ b/internal/runtime/memory.go @@ -2673,6 +2673,11 @@ func targetCollectsRest(target Expression) bool { // build no derived map (the pure iterators) are not charged a map they never // allocate; callers that do build one fold the empty-map overhead in themselves. func (exec *Execution) hashCallRootBytes(receiver Value, args []Value, kwargs map[string]Value, block Value) int { + used, _ := exec.hashCallRootUsage(receiver, args, kwargs, block) + return used +} + +func (exec *Execution) hashCallRootUsage(receiver Value, args []Value, kwargs map[string]Value, block Value) (int, int) { s := exec.beginBaseWalk() used := s.base if receiver.Kind() != KindNil { @@ -2687,9 +2692,9 @@ func (exec *Execution) hashCallRootBytes(receiver Value, args []Value, kwargs ma if !block.IsNil() { used = saturatingAdd(used, s.est.value(block)) } + walked := s.nodes() s.close() - - return used + return used, walked } // projectedHashBaseBytes estimates the live footprint a hash transform holds diff --git a/vibes/capability/contextcap/contextcap.go b/vibes/capability/contextcap/contextcap.go index 9963f2957..445d3e872 100644 --- a/vibes/capability/contextcap/contextcap.go +++ b/vibes/capability/contextcap/contextcap.go @@ -52,6 +52,7 @@ func (c *Capability) Name() string { return c.name } // Bind resolves the underlying value, validates that it is data-only and // non-cyclic, and returns a deep-cloned copy keyed by the capability name. func (c *Capability) Bind(ctx context.Context) (map[string]value.Value, error) { + ctx, budget := capabilitydata.UnpackBudget(ctx) val, err := c.resolver(ctx) if err != nil { return nil, fmt.Errorf("resolve %s capability: %w", c.name, err) @@ -65,7 +66,9 @@ func (c *Capability) Bind(ctx context.Context) (map[string]value.Value, error) { return nil, fmt.Errorf("%s capability resolver must return hash/object", c.name) } label := c.name + " capability value" - budget := capabilitydata.NewBudget(ctx, nil, nil) + if err := budget.Refresh(); err != nil { + return nil, err + } cloned, err := capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(label, val) if err != nil { return nil, err diff --git a/vibes/capability/db/calls.go b/vibes/capability/db/calls.go index 421fce577..03eb9f3c2 100644 --- a/vibes/capability/db/calls.go +++ b/vibes/capability/db/calls.go @@ -3,7 +3,7 @@ package db import ( "fmt" - "github.com/mgomes/vibescript/vibes/internal/capabilitycontract" + "github.com/mgomes/vibescript/internal/capabilitydata" "github.com/mgomes/vibescript/vibes/value" ) @@ -19,11 +19,13 @@ func (c *Capability) CallFind(exec ExecutionContext, args []value.Value, kwargs func (c *Capability) callFindValidated(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { method := c.name + ".find" - id, err := capabilitycontract.CloneDataOnlyValue(method+" id", args[1]) + budget := capabilitydata.ExecutionBudget(exec) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + id, err := cloner.Clone(method+" id", args[1]) if err != nil { return value.NewNil(), err } - options, err := capabilitycontract.CloneKwargsDataOnly(method, kwargs) + options, err := cloner.Kwargs(method, kwargs) if err != nil { return value.NewNil(), err } @@ -39,7 +41,10 @@ func (c *Capability) callFindValidated(exec ExecutionContext, args []value.Value if err := contextErr(exec.Context()); err != nil { return value.NewNil(), err } - return capabilitycontract.CloneMethodResult(method, result) + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(method+" return value", result) } // CallQuery implements the db.query boundary. @@ -52,7 +57,9 @@ func (c *Capability) CallQuery(exec ExecutionContext, args []value.Value, kwargs func (c *Capability) callQueryValidated(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { method := c.name + ".query" - options, err := capabilitycontract.CloneKwargsDataOnly(method, kwargs) + budget := capabilitydata.ExecutionBudget(exec) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + options, err := cloner.Kwargs(method, kwargs) if err != nil { return value.NewNil(), err } @@ -67,7 +74,10 @@ func (c *Capability) callQueryValidated(exec ExecutionContext, args []value.Valu if err := contextErr(exec.Context()); err != nil { return value.NewNil(), err } - return capabilitycontract.CloneMethodResult(method, result) + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(method+" return value", result) } // CallUpdate implements the db.update boundary. @@ -80,15 +90,17 @@ func (c *Capability) CallUpdate(exec ExecutionContext, args []value.Value, kwarg func (c *Capability) callUpdateValidated(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { method := c.name + ".update" - id, err := capabilitycontract.CloneDataOnlyValue(method+" id", args[1]) + budget := capabilitydata.ExecutionBudget(exec) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + id, err := cloner.Clone(method+" id", args[1]) if err != nil { return value.NewNil(), err } - attributes, err := capabilitycontract.CloneHashValue(method+" attributes", args[2]) + attributes, err := cloner.Hash(method+" attributes", args[2]) if err != nil { return value.NewNil(), err } - options, err := capabilitycontract.CloneKwargsDataOnly(method, kwargs) + options, err := cloner.Kwargs(method, kwargs) if err != nil { return value.NewNil(), err } @@ -105,7 +117,10 @@ func (c *Capability) callUpdateValidated(exec ExecutionContext, args []value.Val if err := contextErr(exec.Context()); err != nil { return value.NewNil(), err } - return capabilitycontract.CloneMethodResult(method, result) + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(method+" return value", result) } // CallSum implements the db.sum boundary. @@ -118,7 +133,9 @@ func (c *Capability) CallSum(exec ExecutionContext, args []value.Value, kwargs m func (c *Capability) callSumValidated(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { method := c.name + ".sum" - options, err := capabilitycontract.CloneKwargsDataOnly(method, kwargs) + budget := capabilitydata.ExecutionBudget(exec) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + options, err := cloner.Kwargs(method, kwargs) if err != nil { return value.NewNil(), err } @@ -134,12 +151,15 @@ func (c *Capability) callSumValidated(exec ExecutionContext, args []value.Value, if err := contextErr(exec.Context()); err != nil { return value.NewNil(), err } - return capabilitycontract.CloneMethodResult(method, result) + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(method+" return value", result) } // CallEach implements the db.each boundary. The host returns the row -// set up front; the capability charges one interpreter step per row, -// validates each row is data-only, deep-copies it, and yields it to +// set up front; the capability charges each row and its bounded data-only +// copy, then yields the independent snapshot to // the script-supplied block. func (c *Capability) CallEach(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { if err := c.validateEachCallShapeArgs(args, kwargs, block); err != nil { @@ -150,7 +170,9 @@ func (c *Capability) CallEach(exec ExecutionContext, args []value.Value, kwargs func (c *Capability) callEachValidated(exec ExecutionContext, args []value.Value, kwargs map[string]value.Value, block value.Value) (value.Value, error) { method := c.name + ".each" - options, err := capabilitycontract.CloneKwargsDataOnly(method, kwargs) + budget := capabilitydata.ExecutionBudget(exec) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + options, err := cloner.Kwargs(method, kwargs) if err != nil { return value.NewNil(), err } @@ -168,10 +190,13 @@ func (c *Capability) callEachValidated(exec ExecutionContext, args []value.Value } } for idx, row := range rows { + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } if err := exec.Step(); err != nil { return value.NewNil(), err } - cloned, err := capabilitycontract.CloneDataOnlyValue(fmt.Sprintf("%s row %d", method, idx), row) + cloned, err := capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(fmt.Sprintf("%s row %d", method, idx), row) if err != nil { return value.NewNil(), err } diff --git a/vibes/capability/db/clone_budget_test.go b/vibes/capability/db/clone_budget_test.go new file mode 100644 index 000000000..a9f19ab52 --- /dev/null +++ b/vibes/capability/db/clone_budget_test.go @@ -0,0 +1,96 @@ +package db_test + +import ( + "context" + "errors" + "testing" + + "github.com/mgomes/vibescript/internal/capabilitydata" + "github.com/mgomes/vibescript/vibes/capability/db" + "github.com/mgomes/vibescript/vibes/value" +) + +type cloneExecution struct { + budget *capabilitydata.Budget + step func() error + yield func(value.Value) error +} + +func (e *cloneExecution) Context() context.Context { return context.Background() } +func (e *cloneExecution) Step() error { return e.step() } +func (e *cloneExecution) CapabilityDataBudget() *capabilitydata.Budget { return e.budget } +func (e *cloneExecution) CallBlock(_ value.Value, args []value.Value) (value.Value, error) { + return value.NewNil(), e.yield(args[0]) +} + +func TestEachClonesIndependentSnapshots(t *testing.T) { + t.Parallel() + child := value.NewArray([]value.Value{value.NewInt(1)}) + row := value.NewHash(map[string]value.Value{"a": child, "b": child}) + stub := &dbCapabilityStub{eachRows: []value.Value{row, row}} + capability := db.MustNewCapability("db", stub) + budget := capabilitydata.NewBudget(context.Background(), nil, nil) + refreshes := 0 + budget.SetSnapshotRefresh(func() error { refreshes++; return nil }) + var snapshots []value.Value + exec := &cloneExecution{budget: budget, step: func() error { return nil }} + exec.yield = func(snapshot value.Value) error { + snapshots = append(snapshots, snapshot) + a, b := snapshot.HashEntryMap()["a"], snapshot.HashEntryMap()["b"] + if value.ArrayIdentity(a) != value.ArrayIdentity(b) || value.ArrayIdentity(a) == value.ArrayIdentity(child) { + t.Fatal("row lost its shared child or retained host storage") + } + if a.Array()[0].Int() != int64(len(snapshots)) { + t.Fatal("row reused a stale snapshot after host data changed") + } + a.Array()[0] = value.NewInt(99) + child.Array()[0] = value.NewInt(2) + return nil + } + _, err := capability.CallEach(exec, []value.Value{value.NewString("items")}, nil, value.NewValue(value.KindBlock, nil)) + if err != nil { + t.Fatal(err) + } + if len(snapshots) != 2 || refreshes != 2 { + t.Fatalf("yielded %d snapshots and refreshed %d times, want 2 of each", len(snapshots), refreshes) + } + if value.HashIdentity(snapshots[0]) == value.HashIdentity(snapshots[1]) { + t.Fatal("separate rows share one mutable snapshot") + } +} + +func TestEachChargesCopiesAcrossSnapshots(t *testing.T) { + t.Parallel() + row := value.NewArray(make([]value.Value, 64)) + rows := make([]value.Value, 128) + for i := range rows { + rows[i] = row + } + stub := &dbCapabilityStub{eachRows: rows} + capability := db.MustNewCapability("db", stub) + steps, yields := 1000, 0 + exhausted := errors.New("step quota exceeded") + step := func() error { + steps-- + if steps < 0 { + return exhausted + } + return nil + } + budget := capabilitydata.NewBudget(context.Background(), func(n int) error { + for range n { + if err := step(); err != nil { + return err + } + } + return nil + }, nil) + exec := &cloneExecution{budget: budget, step: step, yield: func(value.Value) error { yields++; return nil }} + _, err := capability.CallEach(exec, []value.Value{value.NewString("items")}, nil, value.NewValue(value.KindBlock, nil)) + if !errors.Is(err, exhausted) { + t.Fatalf("Each error = %v, want step exhaustion from cumulative copy work", err) + } + if yields == 0 || yields >= len(rows) { + t.Fatalf("yielded %d rows, want a nonempty prefix", yields) + } +} diff --git a/vibes/capability/events/events.go b/vibes/capability/events/events.go index 70c7b1399..8adaebadc 100644 --- a/vibes/capability/events/events.go +++ b/vibes/capability/events/events.go @@ -9,7 +9,7 @@ import ( "reflect" "strings" - "github.com/mgomes/vibescript/vibes/internal/capabilitycontract" + "github.com/mgomes/vibescript/internal/capabilitydata" "github.com/mgomes/vibescript/vibes/value" ) @@ -61,6 +61,10 @@ func (c *Capability) PublishMethodName() string { return c.Name + ".publish" } // arguments. The vibes-side adapter wires this into the runtime contract and // Publish calls it when embedders invoke the capability directly. func (c *Capability) ValidatePublishArgs(args []value.Value, kwargs map[string]value.Value, blockProvided bool) error { + return c.validatePublishArgs(nil, args, kwargs, blockProvided) +} + +func (c *Capability) validatePublishArgs(budget *capabilitydata.Budget, args []value.Value, kwargs map[string]value.Value, blockProvided bool) error { method := c.PublishMethodName() if len(args) != 2 { return fmt.Errorf("%s expects topic and payload", method) @@ -71,37 +75,53 @@ func (c *Capability) ValidatePublishArgs(args []value.Value, kwargs map[string]v if _, err := nameArg(method, "topic", args[0]); err != nil { return err } - if err := validateHashValue(method+" payload", args[1]); err != nil { + if args[1].Kind() != value.KindHash && args[1].Kind() != value.KindObject { + return fmt.Errorf("%s payload expected hash, got %s", method, args[1].Kind()) + } + validator := capabilitydata.NewValidator(budget) + if err := validator.Validate(method+" payload", args[1]); err != nil { return err } - return validateKwargsDataOnly(method, kwargs) + return validator.Kwargs(method, kwargs) } // ValidatePublishReturn enforces the data-only contract on host return values. // The vibes-side adapter wires this into CapabilityMethodContract.ValidateReturn. func (c *Capability) ValidatePublishReturn(result value.Value) error { - return validateAnyValue(c.PublishMethodName()+" return value", result) + return capabilitydata.NewValidator(nil).Validate(c.PublishMethodName()+" return value", result) } // Publish runs the full publish path: validates args, builds the // PublishRequest, delegates to the host Publisher, validates the return value, // and deep-clones it so the host can't share mutable state with scripts. func (c *Capability) Publish(ctx context.Context, args []value.Value, kwargs map[string]value.Value, blockProvided bool) (value.Value, error) { - if err := c.ValidatePublishArgs(args, kwargs, blockProvided); err != nil { + ctx, budget := capabilitydata.UnpackBudget(ctx) + if err := c.validatePublishArgs(budget, args, kwargs, blockProvided); err != nil { return value.NewNil(), err } - return c.PublishValidated(ctx, args, kwargs, blockProvided) + return c.publishValidated(ctx, budget, args, kwargs) } // PublishValidated runs events.publish after the runtime has already enforced // ValidatePublishArgs. Direct embedders should call Publish so invalid script // arguments are still rejected before the host publisher runs. func (c *Capability) PublishValidated(ctx context.Context, args []value.Value, kwargs map[string]value.Value, blockProvided bool) (value.Value, error) { - req := PublishRequest{ - Topic: args[0].String(), - Payload: cloneHash(args[1].HashEntryMap()), - Options: cloneKwargs(kwargs), + ctx, budget := capabilitydata.UnpackBudget(ctx) + return c.publishValidated(ctx, budget, args, kwargs) +} + +func (c *Capability) publishValidated(ctx context.Context, budget *capabilitydata.Budget, args []value.Value, kwargs map[string]value.Value) (value.Value, error) { + method := c.PublishMethodName() + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{AllowRuntimeValues: true}) + payload, err := cloner.Hash(method+" payload", args[1]) + if err != nil { + return value.NewNil(), err + } + options, err := cloner.Kwargs(method, kwargs) + if err != nil { + return value.NewNil(), err } + req := PublishRequest{Topic: args[0].String(), Payload: payload, Options: options} result, err := c.Publisher.Publish(ctx, req) if err != nil { return value.NewNil(), err @@ -111,7 +131,10 @@ func (c *Capability) PublishValidated(ctx context.Context, args []value.Value, k return value.NewNil(), err } } - return capabilitycontract.CloneMethodResult(c.PublishMethodName(), result) + if err := budget.Refresh(); err != nil { + return value.NewNil(), err + } + return capabilitydata.NewCloner(budget, capabilitydata.Options{}).Clone(method+" return value", result) } // nameArg coerces a string or symbol argument into its underlying name, @@ -129,42 +152,6 @@ func nameArg(method, label string, val value.Value) (string, error) { } } -// validateHashValue ensures val is hash-like (hash or object) whose -// contents are data-only. The pre-carve validateCapabilityHashValue -// accepted both KindHash and KindObject, and Value.Hash() resolves both, -// so callers that forward host objects as event payloads must continue -// to work. -func validateHashValue(label string, val value.Value) error { - if val.Kind() != value.KindHash && val.Kind() != value.KindObject { - return fmt.Errorf("%s expected hash, got %s", label, val.Kind()) - } - return validateDataOnly(label, val) -} - -// validateAnyValue accepts any kind so long as it is data-only and acyclic. -func validateAnyValue(label string, val value.Value) error { - return validateDataOnly(label, val) -} - -// validateKwargsDataOnly applies validateAnyValue to every kwarg entry. -func validateKwargsDataOnly(method string, kwargs map[string]value.Value) error { - for key, val := range kwargs { - if err := validateAnyValue(fmt.Sprintf("%s keyword %s", method, key), val); err != nil { - return err - } - } - return nil -} - -// cloneKwargs returns nil for empty input; otherwise a deep clone so the host -// cannot mutate the script-side kwargs map. -func cloneKwargs(kwargs map[string]value.Value) map[string]value.Value { - if len(kwargs) == 0 { - return nil - } - return cloneHash(kwargs) -} - // isNilImpl reports whether impl is either an untyped nil or a typed-nil // pointer/interface/etc. value. func isNilImpl(impl any) bool { @@ -179,51 +166,3 @@ func isNilImpl(impl any) bool { return false } } - -// validateDataOnly rejects values that embed callables or cyclic references. -func validateDataOnly(label string, val value.Value) error { - return capabilitycontract.ValidateDataOnlyValue(label, val) -} - -// cloneHash deep-clones a string-keyed map of values, returning an empty map -// for an empty input (matching the existing vibes capability behavior). -func cloneHash(src map[string]value.Value) map[string]value.Value { - if len(src) == 0 { - return map[string]value.Value{} - } - out := make(map[string]value.Value, len(src)) - for k, v := range src { - out[k] = deepClone(v) - } - return out -} - -// deepClone returns a deep copy of val so the host cannot mutate state shared -// with a running script. Non-collection kinds are returned unchanged. -func deepClone(val value.Value) value.Value { - switch val.Kind() { - case value.KindArray: - arr := val.Array() - cloned := make([]value.Value, len(arr)) - for i, elem := range arr { - cloned[i] = deepClone(elem) - } - return value.NewArray(cloned) - case value.KindHash: - hash := val.HashEntryMap() - cloned := make(map[string]value.Value, len(hash)) - for k, v := range hash { - cloned[k] = deepClone(v) - } - return value.NewHashWithTrustedOrder(cloned, val.HashKeyOrder()) - case value.KindObject: - obj := val.HashEntryMap() - cloned := make(map[string]value.Value, len(obj)) - for k, v := range obj { - cloned[k] = deepClone(v) - } - return value.NewObject(cloned) - default: - return val - } -} diff --git a/vibes/capability/events/events_test.go b/vibes/capability/events/events_test.go index 60e0bffbb..257376829 100644 --- a/vibes/capability/events/events_test.go +++ b/vibes/capability/events/events_test.go @@ -212,7 +212,7 @@ func TestCapabilityPublishRejectsCyclicPayload(t *testing.T) { } } -func TestDeepClonePreservesHashInsertionOrder(t *testing.T) { +func TestPublishPreservesReturnedHashInsertionOrder(t *testing.T) { t.Parallel() original := value.NewHash(map[string]value.Value{}) @@ -222,7 +222,11 @@ func TestDeepClonePreservesHashInsertionOrder(t *testing.T) { } } - cloned := deepClone(original) + cap := MustNewCapability("events", &stubPublisher{result: original}) + cloned, err := cap.Publish(context.Background(), []value.Value{value.NewString("topic"), value.NewHash(nil)}, nil, false) + if err != nil { + t.Fatal(err) + } entries := cloned.HashEntries() if len(entries) != 2 { t.Fatalf("deepClone key count = %d, want 2", len(entries)) diff --git a/vibes/capability/jobqueue/jobqueue.go b/vibes/capability/jobqueue/jobqueue.go index f9b108b4c..d071f9608 100644 --- a/vibes/capability/jobqueue/jobqueue.go +++ b/vibes/capability/jobqueue/jobqueue.go @@ -10,7 +10,8 @@ import ( "reflect" "time" - "github.com/mgomes/vibescript/vibes/internal/capabilitycontract" + "github.com/mgomes/vibescript/internal/capabilitydata" + "github.com/mgomes/vibescript/internal/jobqueueoptions" "github.com/mgomes/vibescript/vibes/value" ) @@ -105,66 +106,13 @@ func ParseEnqueueOptionsValidated(name string, kwargs map[string]value.Value) (J } func parseEnqueueOptions(name string, kwargs map[string]value.Value, validate bool) (JobQueueEnqueueOptions, error) { - if len(kwargs) == 0 { - return JobQueueEnqueueOptions{}, nil - } - - var delay *time.Duration - var key *string - extra := make(map[string]value.Value) - - for k, v := range kwargs { - switch k { - case "delay": - d, err := valueToTimeDuration(name, v) - if err != nil { - return JobQueueEnqueueOptions{}, err - } - if d < 0 { - return JobQueueEnqueueOptions{}, fmt.Errorf("%s.enqueue delay must be non-negative", name) - } - delay = &d - case "key": - if v.Kind() != value.KindString { - return JobQueueEnqueueOptions{}, fmt.Errorf("%s.enqueue key must be a string", name) - } - s := v.String() - if s == "" { - return JobQueueEnqueueOptions{}, fmt.Errorf("%s.enqueue key must be non-empty", name) - } - key = &s - default: - if validate { - label := fmt.Sprintf("%s.enqueue keyword %s", name, k) - if err := validateDataOnly(label, v); err != nil { - return JobQueueEnqueueOptions{}, err - } - } - extra[k] = deepCloneValue(v) - } - } - - opts := JobQueueEnqueueOptions{Delay: delay, Key: key} - if len(extra) > 0 { - opts.Kwargs = extra - } - return opts, nil -} - -func valueToTimeDuration(name string, val value.Value) (time.Duration, error) { - switch val.Kind() { - case value.KindDuration: - secs := val.Duration().Seconds() - return time.Duration(secs) * time.Second, nil - case value.KindInt, value.KindFloat: - secs, err := value.ValueToInt64(val) - if err != nil { - return 0, err - } - return time.Duration(secs) * time.Second, nil - default: - return 0, fmt.Errorf("%s.enqueue delay must be duration or numeric seconds", name) + budget := capabilitydata.NewBudget(context.Background(), nil, nil) + cloner := capabilitydata.NewCloner(budget, capabilitydata.Options{}) + options, err := jobqueueoptions.Parse(name, kwargs, budget, cloner, validate) + if err != nil { + return JobQueueEnqueueOptions{}, err } + return JobQueueEnqueueOptions{Delay: options.Delay, Key: options.Key, Kwargs: options.Kwargs}, nil } // isNilImpl reports whether impl is an untyped or typed nil. It is @@ -182,41 +130,3 @@ func isNilImpl(impl any) bool { return false } } - -// deepCloneValue mirrors vibes' deepCloneValue for data-only kinds so -// option parsing can defensively clone hash arguments without reaching -// back into vibes. Runtime-only kinds (block, builtin, class, ...) are -// rejected by validateDataOnly before reaching this clone, so they are -// returned unchanged here rather than silently leaking. -func deepCloneValue(v value.Value) value.Value { - switch v.Kind() { - case value.KindArray: - arr := v.Array() - cloned := make([]value.Value, len(arr)) - for i, elem := range arr { - cloned[i] = deepCloneValue(elem) - } - return value.NewArray(cloned) - case value.KindHash: - hash := v.HashEntryMap() - cloned := make(map[string]value.Value, len(hash)) - for k, val := range hash { - cloned[k] = deepCloneValue(val) - } - return value.NewHashWithTrustedOrder(cloned, v.HashKeyOrder()) - case value.KindObject: - obj := v.HashEntryMap() - cloned := make(map[string]value.Value, len(obj)) - for k, val := range obj { - cloned[k] = deepCloneValue(val) - } - return value.NewObject(cloned) - default: - return v - } -} - -// validateDataOnly rejects values that embed callables or cyclic references. -func validateDataOnly(label string, val value.Value) error { - return capabilitycontract.ValidateDataOnlyValue(label, val) -} diff --git a/vibes/capability/jobqueue/jobqueue_test.go b/vibes/capability/jobqueue/jobqueue_test.go index 6758c9322..f6e4178b8 100644 --- a/vibes/capability/jobqueue/jobqueue_test.go +++ b/vibes/capability/jobqueue/jobqueue_test.go @@ -182,7 +182,7 @@ func TestParseEnqueueOptionsParsesDelayKeyAndExtra(t *testing.T) { } } -func TestDeepCloneValuePreservesHashInsertionOrder(t *testing.T) { +func TestParseEnqueueOptionsPreservesHashInsertionOrder(t *testing.T) { t.Parallel() original := value.NewHash(map[string]value.Value{}) @@ -192,7 +192,11 @@ func TestDeepCloneValuePreservesHashInsertionOrder(t *testing.T) { } } - cloned := deepCloneValue(original) + options, err := ParseEnqueueOptions("jobs", map[string]value.Value{"data": original}) + if err != nil { + t.Fatal(err) + } + cloned := options.Kwargs["data"] entries := cloned.HashEntries() if len(entries) != 2 { t.Fatalf("deepCloneValue key count = %d, want 2", len(entries)) diff --git a/vibes/internal/capabilitycontract/contract.go b/vibes/internal/capabilitycontract/contract.go index ab09d8af2..11dec2fdf 100644 --- a/vibes/internal/capabilitycontract/contract.go +++ b/vibes/internal/capabilitycontract/contract.go @@ -9,7 +9,6 @@ import ( "fmt" "reflect" "strings" - "unsafe" "github.com/mgomes/vibescript/internal/capabilitydata" "github.com/mgomes/vibescript/vibes/value" @@ -19,26 +18,6 @@ import ( // cloning so deeply nested acyclic values cannot exhaust the host stack. const MaxDataOnlyTraversalDepth = capabilitydata.MaxDepth -type limitError struct { - err error -} - -func (e *limitError) Error() string { - return e.err.Error() -} - -func (e *limitError) Unwrap() error { - return e.err -} - -func (e *limitError) LimitError() bool { - return true -} - -func limitErrorf(format string, args ...any) error { - return &limitError{err: fmt.Errorf(format, args...)} -} - // NameArg validates that val is a non-empty string or symbol and returns // its textual form. Used by capability adapters to interpret leading // "name" arguments such as the collection passed to db.find. @@ -168,16 +147,7 @@ func EnsureBlock(block value.Value, name string) error { // it so host code never receives a script-side callable it cannot // safely invoke or an opaque runtime payload that is not plain data. func ValidateDataOnlyValue(label string, val value.Value) error { - if err := validateTraversalDepth(label, val); err != nil { - return err - } - switch validateDataOnly(val, newSeenSet(), newSeenSet()) { - case dataOnlyCallable: - return fmt.Errorf("%s must be data-only", label) - case dataOnlyCycle: - return fmt.Errorf("%s must not contain cyclic references", label) - } - return nil + return capabilitydata.NewValidator(nil).Validate(label, val) } // ValidateHashValue checks that val is a hash (or object) and data-only. @@ -196,12 +166,7 @@ func ValidateHashValue(label string, val value.Value) error { // ValidateKwargsDataOnly applies ValidateDataOnlyValue to every keyword // argument, labeling errors with method and keyword name. func ValidateKwargsDataOnly(method string, kwargs map[string]value.Value) error { - for key, val := range kwargs { - if err := ValidateDataOnlyValue(fmt.Sprintf("%s keyword %s", method, key), val); err != nil { - return err - } - } - return nil + return capabilitydata.NewValidator(nil).Kwargs(method, kwargs) } // ValidateAnyReturn returns the post-call return validator used in @@ -219,171 +184,6 @@ func CloneMethodResult(method string, result value.Value) (value.Value, error) { return CloneDataOnlyValue(method+" return value", result) } -type seenSet struct { - arrays map[value.SliceIdentity]struct{} - maps map[uintptr]struct{} -} - -type seenDepthSet struct { - arrays map[value.SliceIdentity]int - maps map[uintptr]int -} - -func newSeenSet() *seenSet { - return &seenSet{ - arrays: map[value.SliceIdentity]struct{}{}, - maps: map[uintptr]struct{}{}, - } -} - -func newSeenDepthSet() *seenDepthSet { - return &seenDepthSet{ - arrays: map[value.SliceIdentity]int{}, - maps: map[uintptr]int{}, - } -} - -func validateTraversalDepth(label string, val value.Value) error { - return (&traversalDepthScanner{ - visiting: newSeenSet(), - seen: newSeenDepthSet(), - }).check(label, val, 0) -} - -type traversalDepthScanner struct { - visiting *seenSet - seen *seenDepthSet -} - -func (s *traversalDepthScanner) check(label string, val value.Value, depth int) error { - if depth > MaxDataOnlyTraversalDepth { - return limitErrorf("%s exceeds maximum depth %d", label, MaxDataOnlyTraversalDepth) - } - remainingDepth := MaxDataOnlyTraversalDepth - depth - switch val.Kind() { - case value.KindArray: - values := val.Array() - id := sliceIdentity(values) - if seenRemaining, ok := s.seen.arrays[id]; ok && seenRemaining <= remainingDepth { - return nil - } - if _, ok := s.visiting.arrays[id]; ok { - return nil - } - s.visiting.arrays[id] = struct{}{} - for _, item := range values { - if err := s.check(label, item, depth+1); err != nil { - return err - } - } - delete(s.visiting.arrays, id) - if seenRemaining, ok := s.seen.arrays[id]; !ok || remainingDepth < seenRemaining { - s.seen.arrays[id] = remainingDepth - } - case value.KindHash, value.KindObject: - entries := val.HashEntryMap() - ptr := value.HashIdentity(val) - if ptr == 0 { - ptr = reflect.ValueOf(entries).Pointer() - } - if seenRemaining, ok := s.seen.maps[ptr]; ok && seenRemaining <= remainingDepth { - return nil - } - if _, ok := s.visiting.maps[ptr]; ok { - return nil - } - s.visiting.maps[ptr] = struct{}{} - for _, item := range entries { - if err := s.check(label, item, depth+1); err != nil { - return err - } - } - delete(s.visiting.maps, ptr) - if seenRemaining, ok := s.seen.maps[ptr]; !ok || remainingDepth < seenRemaining { - s.seen.maps[ptr] = remainingDepth - } - } - return nil -} - -type dataOnlyResult uint8 - -const ( - dataOnlyOK dataOnlyResult = iota - dataOnlyCallable - dataOnlyCycle -) - -func sliceIdentity(values []value.Value) value.SliceIdentity { - return value.SliceIdentity{ - Ptr: uintptr(unsafe.Pointer(unsafe.SliceData(values))), - Len: len(values), - Cap: cap(values), - } -} - -func validateDataOnly(val value.Value, visiting, seen *seenSet) dataOnlyResult { - switch val.Kind() { - case value.KindFunction, value.KindBuiltin, value.KindBlock, value.KindClass, value.KindInstance, - value.KindShape: - return dataOnlyCallable - case value.KindArray: - values := val.Array() - id := sliceIdentity(values) - if _, ok := seen.arrays[id]; ok { - return dataOnlyOK - } - if _, ok := visiting.arrays[id]; ok { - return dataOnlyCycle - } - visiting.arrays[id] = struct{}{} - issue := dataOnlyOK - for _, item := range values { - switch result := validateDataOnly(item, visiting, seen); result { - case dataOnlyCallable: - return dataOnlyCallable - case dataOnlyCycle: - issue = dataOnlyCycle - } - } - delete(visiting.arrays, id) - seen.arrays[id] = struct{}{} - return issue - case value.KindHash, value.KindObject: - entries := val.HashEntryMap() - // A KindHash's default metadata lives outside its entry map, so two - // wrappers can share one map yet carry different defaults. Key the - // seen/visiting sets on the whole hash wrapper (or the entry-map pointer - // for objects, which never carry defaults) so a second wrapper's callable - // default is not hidden by an earlier wrapper marking the shared map seen. - ptr := value.HashIdentity(val) - if ptr == 0 { - ptr = reflect.ValueOf(entries).Pointer() - } - if _, ok := seen.maps[ptr]; ok { - return dataOnlyOK - } - if _, ok := visiting.maps[ptr]; ok { - return dataOnlyCycle - } - visiting.maps[ptr] = struct{}{} - issue := dataOnlyOK - for _, item := range entries { - switch result := validateDataOnly(item, visiting, seen); result { - case dataOnlyCallable: - return dataOnlyCallable - case dataOnlyCycle: - issue = dataOnlyCycle - } - } - delete(visiting.maps, ptr) - seen.maps[ptr] = struct{}{} - return issue - default: - return dataOnlyOK - } -} - func valueKindName(kind value.ValueKind) string { switch kind { case value.KindNil: From 00c78cbf091b39becbbe151ab79ca747deb1bf6e Mon Sep 17 00:00:00 2001 From: Mauricio Gomes Date: Sun, 6 Sep 2026 18:14:54 -0400 Subject: [PATCH 3/3] Preserve first-party capability contract collision checks --- internal/runtime/call.go | 12 ++++---- internal/runtime/capability_adapters.go | 8 ++--- vibes/checked_call_test.go | 41 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/internal/runtime/call.go b/internal/runtime/call.go index bf6ea89d6..2aaaa9307 100644 --- a/internal/runtime/call.go +++ b/internal/runtime/call.go @@ -1355,11 +1355,6 @@ func (r *callFunctionRebinder) rebindKeywords(kwargs map[string]Value) map[strin // An adapter that declares no contracts costs no walk: no host code runs, so // there is nothing to check ahead of. func hostCapabilityContracts(exec *Execution, adapter CapabilityAdapter) (map[string]CapabilityMethodContract, error) { - if internal, ok := adapter.(interface { - runtimeCapabilityContracts() map[string]CapabilityMethodContract - }); ok { - return internal.runtimeCapabilityContracts(), nil - } provider, ok := adapter.(CapabilityContractProvider) if !ok { return nil, nil @@ -1429,6 +1424,7 @@ func bindCapabilitiesForCall(exec *Execution, root *Env, rebinder *callFunctionR if err != nil { return err } + _, validatesData := adapter.(interface{ validatesCapabilityData() }) for methodName, contract := range contracts { name := strings.TrimSpace(methodName) if name == "" { @@ -1438,7 +1434,11 @@ func bindCapabilitiesForCall(exec *Execution, root *Env, rebinder *callFunctionR return fmt.Errorf("duplicate capability contract for %s", name) } exec.capabilityContractsByName[name] = contract - scope.contracts[name] = contract + // First-party data validation runs inside the adapter's budget, + // but its public contract names still participate in collisions. + if !validatesData { + scope.contracts[name] = contract + } } globals, bindErr, refused := bindHostCapability(exec, adapter, binding) if refused != nil { diff --git a/internal/runtime/capability_adapters.go b/internal/runtime/capability_adapters.go index 242b388e3..af6b48672 100644 --- a/internal/runtime/capability_adapters.go +++ b/internal/runtime/capability_adapters.go @@ -296,13 +296,9 @@ func (a *contextCapabilityAdapter) bindWithExecution(exec *Execution, binding Ca // These adapters enforce their public contracts inside the budgeted call, as // DB does. Keep the standalone validators available to direct embedders. -func (c *jobQueueCapability) runtimeCapabilityContracts() map[string]CapabilityMethodContract { - return nil -} +func (c *jobQueueCapability) validatesCapabilityData() {} -func (c *eventsCapability) runtimeCapabilityContracts() map[string]CapabilityMethodContract { - return nil -} +func (c *eventsCapability) validatesCapabilityData() {} // Internal aliases for db capability types so runtime code (and tests) // can keep referring to short names that match the public vibes facade. diff --git a/vibes/checked_call_test.go b/vibes/checked_call_test.go index aa326a3d2..79be2dd60 100644 --- a/vibes/checked_call_test.go +++ b/vibes/checked_call_test.go @@ -7,9 +7,50 @@ import ( "testing" "github.com/mgomes/vibescript/vibes" + "github.com/mgomes/vibescript/vibes/capability/events" + "github.com/mgomes/vibescript/vibes/capability/jobqueue" "github.com/mgomes/vibescript/vibes/value" ) +type checkedBoundaryHost struct{} + +func (checkedBoundaryHost) Enqueue(context.Context, jobqueue.JobQueueJob) (value.Value, error) { + return value.NewNil(), nil +} + +func (checkedBoundaryHost) Publish(context.Context, events.PublishRequest) (value.Value, error) { + return value.NewNil(), nil +} + +func TestCheckedCallAndCallRejectDuplicateFirstPartyContracts(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + adapter vibes.CapabilityAdapter + }{ + {"jobs.enqueue", vibes.MustNewJobQueueCapability("jobs", checkedBoundaryHost{})}, + {"events.publish", vibes.MustNewEventsCapability("events", checkedBoundaryHost{})}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + engine := vibes.MustNewEngine(vibes.Config{}) + script, err := engine.Compile("def run()\n 1\nend") + if err != nil { + t.Fatal(err) + } + opts := vibes.CallOptions{Capabilities: []vibes.CapabilityAdapter{tc.adapter, tc.adapter}} + _, callErr := script.Call(context.Background(), "run", nil, opts) + _, _, checkedErr := script.CheckedCall(context.Background(), "run", nil, opts) + want := "duplicate capability contract for " + tc.name + for _, err := range []error{callErr, checkedErr} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("Call error = %v; CheckedCall error = %v; want %s for both", callErr, checkedErr, want) + } + } + }) + } +} + func TestCheckedCallGatesOnDiagnostics(t *testing.T) { t.Parallel()