From 972227015a87af3b0ba8bbd61959d72ba901e78c Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Wed, 13 May 2026 13:54:38 -0700 Subject: [PATCH 001/139] Do not strip entitlements when capabilities are assigned to AnyStruct --- interpreter/dynamic_casting_test.go | 47 +++++++++++++++++++++++++++++ interpreter/interpreter.go | 15 +++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/interpreter/dynamic_casting_test.go b/interpreter/dynamic_casting_test.go index 778d942fd1..cd75f5cfa1 100644 --- a/interpreter/dynamic_casting_test.go +++ b/interpreter/dynamic_casting_test.go @@ -4512,6 +4512,11 @@ func TestInterpretDynamicCastingEntitledCapability(t *testing.T) { test(t, "AnyStruct", noError) }) + t.Run("to AnyStruct?", func(t *testing.T) { + t.Parallel() + test(t, "AnyStruct?", noError) + }) + t.Run("nested in Array", func(t *testing.T) { t.Parallel() @@ -4566,4 +4571,46 @@ func TestInterpretDynamicCastingEntitledCapability(t *testing.T) { assert.Equal(t, common.TypeID("[Capability]"), forceCastTypeMismatchError.ExpectedType.ID()) assert.Equal(t, common.TypeID("[Capability<&Int>]"), forceCastTypeMismatchError.ActualType.ID()) }) + + t.Run("nested in [[AnyStruct]]", func(t *testing.T) { + t.Parallel() + + code := ` + entitlement E1 + entitlement E2 + + fun getBorrowType(): Type { + return Type() + } + + fun test(cap: Capability) { + let capArray: [[Capability]] = [[cap]] + let upcastedCapArray: [[AnyStruct]] = capArray + let downcastedCapArray = upcastedCapArray as! [[Capability]] + } + ` + + inter := parseCheckAndPrepare(t, code) + + result, err := inter.Invoke("getBorrowType") + require.NoError(t, err) + + require.IsType(t, interpreter.TypeValue{}, result) + typeValue := result.(interpreter.TypeValue) + borrowType := typeValue.Type + + capabilityValue := interpreter.NewUnmeteredCapabilityValue( + 4, + interpreter.AddressValue{}, + borrowType, + ) + + _, err = inter.Invoke("test", capabilityValue) + + // Similar to how assigning to `AnyStruct` doesn't strip entitlements, + // assigning to an array `[AnyStruct]` (or any nested `AnyStruct`) + // shouldn't also strip entitlements. + // This is mostly for consistency. + require.NoError(t, err) + }) } diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index 6e86f274a6..ab1e5695f6 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -2156,27 +2156,26 @@ func applyTargetTypeAuthorization( return NewReferenceStaticType(typeConverter, targetAuth, innerType) case *CapabilityStaticType: - var targetBorrowType sema.Type + // When the target is `AnyStruct`, the capability is preserved as-is. + // This matches `semaTypeWithStrippedEntitlements`, which does not recurse into `*sema.CapabilityType`. + // Only when the target is itself a `Capability` do we recurse + // into the borrow type to apply the target's authorization. - if targetType == sema.AnyStructType { - targetBorrowType = sema.AnyStructType - } else if targetCapabilityType, isCapabilityType := targetType.(*sema.CapabilityType); isCapabilityType { - targetBorrowType = targetCapabilityType.BorrowType - } else { + targetCapabilityType, isCapabilityType := targetType.(*sema.CapabilityType) + if !isCapabilityType { break } borrowType := applyTargetTypeAuthorization( typeConverter, actual.BorrowType, - targetBorrowType, + targetCapabilityType.BorrowType, ) return NewCapabilityStaticType( typeConverter, borrowType, ) - case FunctionStaticType: var targetReturnType sema.Type From 6bf9c47704b5eb9888f9430a6f218170abcfa6a9 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Wed, 13 May 2026 14:27:29 -0700 Subject: [PATCH 002/139] Update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 7908ae9aaa..2354c4b17a 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3-rc.2" +const Version = "v1.10.3-rc.3" From 6c3d9344db72edd3556e85c759cb91d6785c15b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 14 May 2026 16:34:46 -0700 Subject: [PATCH 003/139] forbid mapping access modifier on non-field members --- cmd/errors/errors.go | 6 ++++ sema/checker.go | 13 +++++++- sema/entitlements_test.go | 67 +++++++++++++++++++++++++++++++-------- sema/errors.go | 38 ++++++++++++++++++++++ version.go | 2 +- 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/cmd/errors/errors.go b/cmd/errors/errors.go index eb3b667f21..bc3a3f7853 100644 --- a/cmd/errors/errors.go +++ b/cmd/errors/errors.go @@ -1241,6 +1241,12 @@ func generateErrors() []namedError { Range: placeholderRange, }, }, + {"sema.InvalidNonFieldMappingAccessError", + &sema.InvalidNonFieldMappingAccessError{ + DeclarationKind: placeholderDeclarationKind, + Pos: placeholderPosition, + }, + }, {"sema.InvalidNonIdentifierFailableResourceDowncast", &sema.InvalidNonIdentifierFailableResourceDowncast{ Range: placeholderRange, diff --git a/sema/checker.go b/sema/checker.go index eea799434c..96ce032e6f 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1840,7 +1840,7 @@ func (checker *Checker) checkDeclarationAccessModifier( case PrimitiveAccess: checker.checkPrimitiveAccess(access, isConstant, declarationKind, startPos) case *EntitlementMapAccess: - checker.checkEntitlementMapAccess(declarationType, containerKind, startPos) + checker.checkEntitlementMapAccess(declarationType, containerKind, declarationKind, startPos) case EntitlementSetAccess: checker.checkEntitlementSetAccess(containerKind, startPos) } @@ -1919,6 +1919,7 @@ func (checker *Checker) checkPrimitiveAccess( func (checker *Checker) checkEntitlementMapAccess( declarationType Type, containerKind *common.CompositeKind, + declarationKind common.DeclarationKind, startPos ast.Position, ) { // Mapping access may only be used inside of structs and resources. @@ -1934,6 +1935,16 @@ func (checker *Checker) checkEntitlementMapAccess( return } + // Mapping access may only be used on fields. + if declarationKind != common.DeclarationKindField { + checker.report( + &InvalidNonFieldMappingAccessError{ + DeclarationKind: declarationKind, + Pos: startPos, + }, + ) + } + if !isValidMappingAccessMemberType(declarationType) { checker.report( &InvalidMappingAccessMemberTypeError{ diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index d603579007..7f0a60c709 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -1045,6 +1045,38 @@ func TestCheckInvalidEntitlementMappingAccess(t *testing.T) { assert.IsType(t, &sema.InvalidAccessModifierError{}, errs[0]) }) + t.Run("invalid struct fun", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + struct S { + access(mapping M) fun foo() {} + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid resource fun", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + resource R { + access(mapping M) fun foo() {} + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + t.Run("missing entitlement mapping declaration fun", func(t *testing.T) { t.Parallel() @@ -2035,11 +2067,12 @@ func TestCheckEntitlementInheritance(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 3) + errs := RequireCheckerErrors(t, err, 4) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) - assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[1]) - assert.IsType(t, &sema.TypeMismatchError{}, errs[2]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) + assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[2]) + assert.IsType(t, &sema.TypeMismatchError{}, errs[3]) }) } @@ -3002,13 +3035,14 @@ func TestCheckEntitlementMapAccess(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 3) + errs := RequireCheckerErrors(t, err, 4) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) - assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[1]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) + assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[2]) var typeMismatchErr *sema.TypeMismatchError - require.ErrorAs(t, errs[2], &typeMismatchErr) + require.ErrorAs(t, errs[3], &typeMismatchErr) assert.Equal(t, "(auth(X, Y) &Int)?", typeMismatchErr.ExpectedType.QualifiedString(), @@ -6522,10 +6556,11 @@ func TestCheckEntitlementMappingEscalation(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 2) + errs := RequireCheckerErrors(t, err, 3) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) - assert.IsType(t, &sema.TypeMismatchError{}, errs[1]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) + assert.IsType(t, &sema.TypeMismatchError{}, errs[2]) }) t.Run("member expression in indexer", func(t *testing.T) { @@ -6760,11 +6795,12 @@ func TestCheckEntitlementMappingComplexFields(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 3) + errs := RequireCheckerErrors(t, err, 4) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) - assert.IsType(t, &sema.InvalidAccessError{}, errs[1]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) assert.IsType(t, &sema.InvalidAccessError{}, errs[2]) + assert.IsType(t, &sema.InvalidAccessError{}, errs[3]) }) t.Run("array mapped field escape", func(t *testing.T) { @@ -6806,10 +6842,11 @@ func TestCheckEntitlementMappingComplexFields(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 2) + errs := RequireCheckerErrors(t, err, 3) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[1]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[2]) }) t.Run("dictionary mapped field", func(t *testing.T) { @@ -6919,9 +6956,10 @@ func TestCheckEntitlementMappingComplexFields(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 1) + errs := RequireCheckerErrors(t, err, 2) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) }) t.Run("lambda mapped array field", func(t *testing.T) { @@ -7013,10 +7051,11 @@ func TestCheckEntitlementMappingComplexFields(t *testing.T) { } `) - errs := RequireCheckerErrors(t, err, 2) + errs := RequireCheckerErrors(t, err, 3) assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[0]) - assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[1]) + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[1]) + assert.IsType(t, &sema.InvalidMappingAuthorizationError{}, errs[2]) }) } diff --git a/sema/errors.go b/sema/errors.go index 4df9c5492e..b01f6191a1 100644 --- a/sema/errors.go +++ b/sema/errors.go @@ -6117,6 +6117,44 @@ func (e *InvalidMappingAccessError) EndPosition(_ common.MemoryGauge) ast.Positi return e.Pos } +// InvalidNonFieldMappingAccessError +type InvalidNonFieldMappingAccessError struct { + DeclarationKind common.DeclarationKind + Pos ast.Position +} + +var _ SemanticError = &InvalidNonFieldMappingAccessError{} +var _ errors.UserError = &InvalidNonFieldMappingAccessError{} +var _ errors.SecondaryError = &InvalidNonFieldMappingAccessError{} +var _ errors.HasDocumentationLink = &InvalidNonFieldMappingAccessError{} + +func (*InvalidNonFieldMappingAccessError) isSemanticError() {} + +func (*InvalidNonFieldMappingAccessError) IsUserError() {} + +func (*InvalidNonFieldMappingAccessError) Error() string { + return "`access(mapping ...)` may only be used on fields" +} + +func (e *InvalidNonFieldMappingAccessError) SecondaryError() string { + return fmt.Sprintf( + "found on %s; entitlement mappings can only project entitlements through fields", + e.DeclarationKind.Name(), + ) +} + +func (*InvalidNonFieldMappingAccessError) DocumentationLink() string { + return "https://cadence-lang.org/docs/language/access-control#entitlement-mappings" +} + +func (e *InvalidNonFieldMappingAccessError) StartPosition() ast.Position { + return e.Pos +} + +func (e *InvalidNonFieldMappingAccessError) EndPosition(_ common.MemoryGauge) ast.Position { + return e.Pos +} + // InvalidMappingAccessMemberTypeError type InvalidMappingAccessMemberTypeError struct { Pos ast.Position diff --git a/version.go b/version.go index 7908ae9aaa..2354c4b17a 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3-rc.2" +const Version = "v1.10.3-rc.3" From 1eebf4d9bc4a09bf4763b61a6a4f48a88c01df77 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 14 May 2026 16:53:40 -0700 Subject: [PATCH 004/139] Implicitly unbox values in switch-statement similar to == operator --- interpreter/interpreter_statement.go | 12 ++-- interpreter/switch_test.go | 100 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index f0153404e5..7bc773a074 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -268,26 +268,22 @@ func (interpreter *Interpreter) VisitSwitchStatement(switchStatement *ast.Switch // If the case has no expression it is the default case. // Evaluate it, i.e. all statements - if switchCase.Expression == nil { return runStatements() } // The case has an expression. // Evaluate it and compare it to the test value - result := interpreter.evalExpression(switchCase.Expression) - caseValue, ok := result.(EquatableValue) - if !ok { continue } - // If the test value and case values are equal, - // evaluate the case's statements - - if testValue.Equal(interpreter, caseValue) { + // If the test value and case values are equal, evaluate the case's statements. + // Use `TestValueEqual` to have the same equality as the `==` operator. + // i.e: equality check should do implicit unboxing. + if TestValueEqual(interpreter, testValue, caseValue) { return runStatements() } diff --git a/interpreter/switch_test.go b/interpreter/switch_test.go index 5216d74ada..ce992b3c73 100644 --- a/interpreter/switch_test.go +++ b/interpreter/switch_test.go @@ -234,3 +234,103 @@ func TestInterpretSwitchStatement(t *testing.T) { } }) } + +func TestInterpretSwitchStatementOptionalUnboxing(t *testing.T) { + + t.Parallel() + + t.Run("optional value with non-optional case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let x: Int? = 1 + switch x { + case 1: return "one" + case 2: return "two" + case nil: return "nil" + default: return "other" + } + } + `) + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("one"), + result, + ) + }) + + t.Run("double optional value with non-optional case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let x: Int?? = 1 + switch x { + case 1: return "one" + case 2: return "two" + case nil: return "nil" + default: return "other" + } + } + `) + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("one"), + result, + ) + }) + + t.Run("nil value with non-optional case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let x: Int? = nil + switch x { + case 1: return "one" + case 2: return "two" + case nil: return "nil" + default: return "other" + } + } + `) + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("nil"), + result, + ) + }) + + t.Run("optional value with optional case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let x: Int? = 1 + switch x { + case 1 as Int?: return "one" + default: return "other" + } + } + `) + + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("one"), + result, + ) + }) +} From fb8879a96ef1afaca115962e8fc9c0f7319fc1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 09:26:54 -0700 Subject: [PATCH 005/139] fix incorrect definite return when potential jump --- sema/check_switch.go | 18 ++++++++++++ sema/check_while.go | 7 +++-- sema/function_activations.go | 28 +++++++++++++++--- sema/return_info.go | 57 +++++++++++++++++++++++++++++++----- sema/switch_test.go | 42 ++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/sema/check_switch.go b/sema/check_switch.go index 5cfd1cedc6..c37c7df817 100644 --- a/sema/check_switch.go +++ b/sema/check_switch.go @@ -20,6 +20,7 @@ package sema import ( "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/errors" ) func (checker *Checker) VisitSwitchStatement(statement *ast.SwitchStatement) (_ struct{}) { @@ -187,4 +188,21 @@ func (checker *Checker) checkSwitchCaseStatements(switchCase *ast.SwitchCase) { ), ) checker.checkBlock(block) + + // If any path within this case body broke out of the switch, + // the case cannot be treated as definitely returning/halting/exiting: + // on the break path control falls through past the switch without + // reaching a terminating statement. + // Clear the corresponding definite flags, + // so the switch-level merge sees an accurate per-case result + functionActivation := checker.functionActivations.Current() + if functionActivation == nil { + panic(errors.NewUnreachableError()) + } + returnInfo := functionActivation.ReturnInfo + if returnInfo.MaybeJumpedSwitch { + returnInfo.DefinitelyReturned = false + returnInfo.DefinitelyHalted = false + returnInfo.DefinitelyExited = false + } } diff --git a/sema/check_while.go b/sema/check_while.go index 8ddf2a3762..4b1da5bda7 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -68,10 +68,12 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st return case ControlKindLoop: - functionActivation.ReturnInfo.DefinitelyJumped = true + functionActivation.ReturnInfo.DefinitelyJumpedLoop = true + functionActivation.ReturnInfo.MaybeJumpedLoop = true case ControlKindSwitch: functionActivation.ReturnInfo.DefinitelyJumpedSwitch = true + functionActivation.ReturnInfo.MaybeJumpedSwitch = true } return @@ -97,7 +99,8 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) } functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) - functionActivation.ReturnInfo.DefinitelyJumped = true + functionActivation.ReturnInfo.DefinitelyJumpedLoop = true + functionActivation.ReturnInfo.MaybeJumpedLoop = true return } diff --git a/sema/function_activations.go b/sema/function_activations.go index 6c90cd3a8c..b449878b71 100644 --- a/sema/function_activations.go +++ b/sema/function_activations.go @@ -63,20 +63,40 @@ func (a *FunctionActivation) popControl() { func (a *FunctionActivation) WithLoop(f func()) { a.pushControl(ControlKindLoop) + // DefinitelyJumpedLoop and MaybeJumpedLoop are scoped to this loop: + // save on entry, restore on exit, so a nested loop's break/continue does not affect an enclosing loop. + // `break` and `continue` statements targeting this loop are consumed by the loop + // and must not leak to an enclosing scope + savedDefinitelyJumpedLoop := a.ReturnInfo.DefinitelyJumpedLoop + savedMaybeJumpedLoop := a.ReturnInfo.MaybeJumpedLoop a.ReturnInfo.WithNewJumpTarget(f) + // If any path within the loop body jumped (break/continue), + // the body did not definitely return/halt/exit on every path: + // clear the corresponding flags so subsequent reasoning sees an + // accurate per-body result + if a.ReturnInfo.MaybeJumpedLoop { + a.ReturnInfo.DefinitelyReturned = false + a.ReturnInfo.DefinitelyHalted = false + a.ReturnInfo.DefinitelyExited = false + } + a.ReturnInfo.DefinitelyJumpedLoop = savedDefinitelyJumpedLoop + a.ReturnInfo.MaybeJumpedLoop = savedMaybeJumpedLoop a.popControl() } func (a *FunctionActivation) WithSwitch(f func()) { // NOTE: new jump-offsets child-set for each case instead of whole switch a.pushControl(ControlKindSwitch) - // A `break` inside a switch only targets the switch, not any enclosing loop. - // DefinitelyJumpedSwitch is scoped to the switch: save on entry, restore on exit. - // `continue` always targets the enclosing loop, so it sets DefinitelyJumped - // (not DefinitelyJumpedSwitch) and therefore propagates past the switch. + // `break` statements inside a switch only target the switch, not any enclosing loop. + // DefinitelyJumpedSwitch and MaybeJumpedSwitch are scoped to the switch: + // save on entry, restore on exit, so a nested switch's break does not affect an enclosing switch. + // (`continue` statements always target the enclosing loop, + // so it sets *JumpedLoop (not *JumpedSwitch) and therefore propagates past the switch) savedDefinitelyJumpedSwitch := a.ReturnInfo.DefinitelyJumpedSwitch + savedMaybeJumpedSwitch := a.ReturnInfo.MaybeJumpedSwitch f() a.ReturnInfo.DefinitelyJumpedSwitch = savedDefinitelyJumpedSwitch + a.ReturnInfo.MaybeJumpedSwitch = savedMaybeJumpedSwitch a.popControl() } diff --git a/sema/return_info.go b/sema/return_info.go index 3a66a91f2c..d89eaac566 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -67,18 +67,47 @@ type ReturnInfo struct { // // DefinitelyHalted = false // // DefinitelyExited = true DefinitelyExited bool - // DefinitelyJumped indicates that (the branch of) the function + // DefinitelyJumpedLoop indicates that (the branch of) the function // contains a definite jump to an enclosing loop // (a `break` targeting a loop, or a `continue`). - DefinitelyJumped bool + DefinitelyJumpedLoop bool + // MaybeJumped indicates that some path within the current loop + // reached a jump targeting that loop (a `break` whose target is the + // loop, or a `continue`). + // + // This is the "Maybe" counterpart to `DefinitelyJumpedLoop`, OR-merged + // across branches and not cleared by subsequent statements within + // the loop. It mirrors `MaybeJumpedSwitch` and is scoped to the loop: + // cleared by `WithLoop` on exit, since such jumps are consumed by + // the loop and are irrelevant outside it. + MaybeJumpedLoop bool // DefinitelyJumpedSwitch indicates that (the branch of) the function // contains a definite `break` whose target is a switch. // - // Tracked separately from DefinitelyJumped because a `break` inside a switch + // Tracked separately from DefinitelyJumpedLoop because a `break` inside a switch // only exits the switch; it must not leak past the switch boundary as a // jump to an outer loop. The flag is observed for unreachability inside the // switch and is cleared by WithSwitch on exit. DefinitelyJumpedSwitch bool + // MaybeJumpedSwitch indicates that some path within the current switch + // reached a `break` statement targeting that switch. + // + // Unlike `DefinitelyJumpedSwitch`, this is OR-merged across branches and + // is not cleared by subsequent statements within the switch. It is used + // at a case body's terminal state to detect the pattern in which only + // some paths reach the case's trailing termination, e.g. + // + // case x: + // if cond { break } + // return ... + // + // Such a case must not be treated as "definitely returns" when merging + // case results at the switch level, because the break path means the + // switch as a whole can still fall through to the code after it. + // + // Like `DefinitelyJumpedSwitch`, the flag is scoped to the switch and + // cleared by WithSwitch on exit. + MaybeJumpedSwitch bool } func NewReturnInfo() *ReturnInfo { @@ -97,13 +126,21 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * thenReturnInfo.MaybeReturned || elseReturnInfo.MaybeReturned + ri.MaybeJumpedLoop = ri.MaybeJumpedLoop || + thenReturnInfo.MaybeJumpedLoop || + elseReturnInfo.MaybeJumpedLoop + + ri.MaybeJumpedSwitch = ri.MaybeJumpedSwitch || + thenReturnInfo.MaybeJumpedSwitch || + elseReturnInfo.MaybeJumpedSwitch + ri.DefinitelyReturned = ri.DefinitelyReturned || (thenReturnInfo.DefinitelyReturned && elseReturnInfo.DefinitelyReturned) - ri.DefinitelyJumped = ri.DefinitelyJumped || - (thenReturnInfo.DefinitelyJumped && - elseReturnInfo.DefinitelyJumped) + ri.DefinitelyJumpedLoop = ri.DefinitelyJumpedLoop || + (thenReturnInfo.DefinitelyJumpedLoop && + elseReturnInfo.DefinitelyJumpedLoop) ri.DefinitelyJumpedSwitch = ri.DefinitelyJumpedSwitch || (thenReturnInfo.DefinitelyJumpedSwitch && @@ -122,6 +159,12 @@ func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInf ri.MaybeReturned = ri.MaybeReturned || temporaryReturnInfo.MaybeReturned + ri.MaybeJumpedLoop = ri.MaybeJumpedLoop || + temporaryReturnInfo.MaybeJumpedLoop + + ri.MaybeJumpedSwitch = ri.MaybeJumpedSwitch || + temporaryReturnInfo.MaybeJumpedSwitch + // NOTE: the definitive return state does not change } @@ -135,7 +178,7 @@ func (ri *ReturnInfo) IsUnreachable() bool { // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, // see DefinitelyExited return ri.DefinitelyExited || - ri.DefinitelyJumped || + ri.DefinitelyJumpedLoop || ri.DefinitelyJumpedSwitch } diff --git a/sema/switch_test.go b/sema/switch_test.go index ffdd7acb26..585980346c 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -712,3 +712,45 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { assert.IsType(t, &sema.ResourceUseAfterInvalidationError{}, errs[0]) }) } + +func TestCheckFoo(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(cond: Bool): Int { + switch 1 { + case 1: + if cond { break } + return 2 + default: + return 0 + } + return 3 // sema (incorrectly) flags this as unreachable + } + `) + + require.NoError(t, err) +} + +func TestCheckSwitchMaybeBreakDoesNotSuppressUnreachableInCase(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(cond: Bool): Int { + switch 1 { + case 1: + if cond { break } + return 2 + let x = 1 + default: + return 0 + } + return 3 + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) +} From 2cd55de301bff230d9647eb7547ae62fe770018d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 14:21:14 -0700 Subject: [PATCH 006/139] add a defensive panic after all unreachable statements --- bbq/compiler/compiler.go | 25 +++++- bbq/compiler/compiler_test.go | 122 ++++++++++++++++++++++++++ bbq/compiler/desugared_elaboration.go | 4 + interpreter/interpreter_statement.go | 11 +++ sema/check_block.go | 7 ++ sema/elaboration.go | 20 +++++ 6 files changed, 188 insertions(+), 1 deletion(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index dac1112336..b5aba04f37 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -598,10 +598,31 @@ func (c *Compiler[_, _]) compileStatement(statement ast.Statement) { func() { c.emit(opcode.InstructionStatement{}) ast.AcceptStatement[struct{}](statement, c) + c.emitUnreachableIfStatementEndsControlFlow(statement) }, ) } +// emitUnreachableIfStatementEndsControlFlow emits an InstructionUnreachable +// after the given statement's bytecode if the type checker determined that +// control flow does not continue past it (halt, exhaustive if/switch). +// If execution nevertheless reaches the emitted instruction at runtime, +// the VM panics with UnreachableInstructionError. +// +// Return, break, and continue statements are skipped: they compile to a +// single terminator opcode (InstructionReturn / InstructionReturnValue / +// InstructionJump) that always transfers control out, so a defensive +// marker after them would be dead bytecode. +func (c *Compiler[_, _]) emitUnreachableIfStatementEndsControlFlow(statement ast.Statement) { + switch statement.(type) { + case *ast.ReturnStatement, *ast.BreakStatement, *ast.ContinueStatement: + return + } + if c.DesugaredElaboration.IsStatementEndingControlFlow(statement) { + c.emit(opcode.InstructionUnreachable{}) + } +} + func (c *Compiler[_, _]) compileExpression(expression ast.Expression) { // Expressions could be inherited. e.g: Inherited default destroy event's default arguments. // Therefore, check whether the expression is inherited. @@ -1574,7 +1595,9 @@ func (c *Compiler[_, _]) VisitGuardStatement(statement *ast.GuardStatement) (_ s nil, ) - // Defensive return: Ensure control flow does not continue after the else block + // Defensive: ensure control flow does not fall through past the else block. + // The checker requires the else block's final statement to end control flow, + // but defensively emit an unreachable here too, in case it does not. c.emit(opcode.InstructionUnreachable{}) // Patch the endJump to continue after the else block diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 7d8bbdd41f..a072243500 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -1127,6 +1127,9 @@ func TestCompileIfLet(t *testing.T) { }, opcode.PrettyInstructionReturnValue{}, + // Defensive unreachable after the exhaustive if-statement + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionReturn{}, }, prettyInstructions(functions[0].Code, program), @@ -14083,3 +14086,122 @@ func TestCompileReferenceMethod(t *testing.T) { program.Globals, ) } + +func TestCompileExhaustiveIfNoFollowingStatement(t *testing.T) { + + t.Parallel() + + checker, err := ParseAndCheck(t, ` + fun test(): Int { + if true { return 1 } else { return 2 } + } + `) + require.NoError(t, err) + + comp := compiler.NewInstructionCompiler( + interpreter.ProgramFromChecker(checker), + checker.Location, + ) + program := comp.Compile() + + functions := program.Functions + require.Len(t, functions, 1) + + assert.Equal(t, + []opcode.PrettyInstruction{ + // if true + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionTrue{}, + opcode.PrettyInstructionJumpIfFalse{Target: 8}, + + // return 1 (then branch) + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionGetConstant{ + Constant: constant.DecodedConstant{ + Data: interpreter.NewUnmeteredIntValueFromInt64(1), + Kind: constant.Int, + }, + }, + opcode.PrettyInstructionTransferAndConvert{ + ValueType: interpreter.PrimitiveStaticTypeInt, + TargetType: interpreter.PrimitiveStaticTypeInt, + }, + opcode.PrettyInstructionReturnValue{}, + + opcode.PrettyInstructionJump{Target: 12}, + + // return 2 (else branch) + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionGetConstant{ + Constant: constant.DecodedConstant{ + Data: interpreter.NewUnmeteredIntValueFromInt64(2), + Kind: constant.Int, + }, + }, + opcode.PrettyInstructionTransferAndConvert{ + ValueType: interpreter.PrimitiveStaticTypeInt, + TargetType: interpreter.PrimitiveStaticTypeInt, + }, + opcode.PrettyInstructionReturnValue{}, + + // Defensive unreachable after the exhaustive if-statement. + opcode.PrettyInstructionUnreachable{}, + + opcode.PrettyInstructionReturn{}, + }, + prettyInstructions(functions[0].Code, program), + ) +} + +func TestCompileUnreachableStatementAfterExhaustiveIf(t *testing.T) { + + t.Parallel() + + // The checker reports an UnreachableStatementError for the trailing `return`, + // but the compiler still walks and emits bytecode for it. The defensive + // InstructionUnreachable emitted after the exhaustive if-statement guarantees + // that the unreachable return is never reached at runtime. + checker, err := ParseAndCheck(t, ` + fun test() { + if true { return } else { return } + return + } + `) + errs := RequireCheckerErrors(t, err, 1) + require.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + + comp := compiler.NewInstructionCompiler( + interpreter.ProgramFromChecker(checker), + checker.Location, + ) + program := comp.Compile() + + functions := program.Functions + require.Len(t, functions, 1) + + assert.Equal(t, + []opcode.PrettyInstruction{ + // if true + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionTrue{}, + opcode.PrettyInstructionJumpIfFalse{Target: 6}, + + // return (then branch) + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionReturn{}, + + opcode.PrettyInstructionJump{Target: 8}, + + // return (else branch) + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionReturn{}, + + // Defensive unreachable after the exhaustive if-statement. + opcode.PrettyInstructionUnreachable{}, + + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionReturn{}, + }, + prettyInstructions(functions[0].Code, program), + ) +} diff --git a/bbq/compiler/desugared_elaboration.go b/bbq/compiler/desugared_elaboration.go index b21795fde8..6d65ba6efd 100644 --- a/bbq/compiler/desugared_elaboration.go +++ b/bbq/compiler/desugared_elaboration.go @@ -422,6 +422,10 @@ func (e *DesugaredElaboration) IsNestedResourceMoveExpression(expression ast.Exp return e.elaboration.IsNestedResourceMoveExpression(expression) } +func (e *DesugaredElaboration) IsStatementEndingControlFlow(statement ast.Statement) bool { + return e.elaboration.IsStatementEndingControlFlow(statement) +} + func (e *DesugaredElaboration) EnumLookupFunctionType(enumType *sema.CompositeType) *sema.FunctionType { return e.elaboration.EnumLookupFunctionType(enumType) } diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index f0153404e5..4316098da7 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -57,11 +57,22 @@ func (interpreter *Interpreter) evalStatement(statement ast.Statement) Statement func (interpreter *Interpreter) visitStatements(statements []ast.Statement) StatementResult { + elaboration := interpreter.Program.Elaboration + for _, statement := range statements { result := interpreter.evalStatement(statement) if result, ok := result.(controlResult); ok { return result } + + // Defensive: the type checker may have determined that control flow + // does not continue past this statement. If execution nevertheless + // reaches this point, panic instead of silently continuing. + if elaboration.IsStatementEndingControlFlow(statement) { + panic(&UnreachableInstructionError{ + Range: ast.NewRangeFromPositioned(interpreter, statement), + }) + } } return nil diff --git a/sema/check_block.go b/sema/check_block.go index 294620edd1..8f8239c8e8 100644 --- a/sema/check_block.go +++ b/sema/check_block.go @@ -61,6 +61,13 @@ func (checker *Checker) visitStatements(statements []ast.Statement) { // check statement ast.AcceptStatement[struct{}](statement, checker) + + // If the return info is now unreachable, the statement ends control flow. + // Mark it so the interpreter and compiler can insert a defensive runtime + // check that aborts execution if control nevertheless falls through. + if functionActivation.ReturnInfo.IsUnreachable() { + checker.Elaboration.SetStatementEndsControlFlow(statement) + } } } diff --git a/sema/elaboration.go b/sema/elaboration.go index 25d97ba973..a01c6180b8 100644 --- a/sema/elaboration.go +++ b/sema/elaboration.go @@ -173,6 +173,11 @@ type Elaboration struct { // nestedResourceMoveExpressions indicates the index or member expression // is implicitly moving a resource out of the container, e.g. in a shift or swap statement. nestedResourceMoveExpressions map[ast.Expression]struct{} + // statementsEndingControlFlow contains the statements after which + // control flow does not continue normally (return, halt, definite jump). + // Used to insert defensive runtime checks that abort execution + // if control nevertheless falls through. + statementsEndingControlFlow map[ast.Statement]struct{} compositeNestedDeclarations map[ast.CompositeLikeDeclaration]map[string]ast.Declaration interfaceNestedDeclarations map[*ast.InterfaceDeclaration]map[string]ast.Declaration defaultDestroyDeclarations map[ast.Declaration]*ast.CompositeDeclaration @@ -556,6 +561,21 @@ func (e *Elaboration) SetIsNestedResourceMoveExpression(expression ast.Expressio e.nestedResourceMoveExpressions[expression] = struct{}{} } +func (e *Elaboration) IsStatementEndingControlFlow(statement ast.Statement) bool { + if e.statementsEndingControlFlow == nil { + return false + } + _, ok := e.statementsEndingControlFlow[statement] + return ok +} + +func (e *Elaboration) SetStatementEndsControlFlow(statement ast.Statement) { + if e.statementsEndingControlFlow == nil { + e.statementsEndingControlFlow = map[ast.Statement]struct{}{} + } + e.statementsEndingControlFlow[statement] = struct{}{} +} + func (e *Elaboration) GetGlobalType(name string) (*Variable, bool) { if e.globalTypes == nil { return nil, false From 82798bb1456cef471ccaab0572bcdeeb9f999a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 15:44:49 -0700 Subject: [PATCH 007/139] consider break and continue for definitely exited --- sema/check_while.go | 7 +++++++ sema/return_info.go | 38 +++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/sema/check_while.go b/sema/check_while.go index 4b1da5bda7..d186604418 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -76,6 +76,11 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st functionActivation.ReturnInfo.MaybeJumpedSwitch = true } + // `break` is a kind of definite exit (see DefinitelyExited). + // Set it so that an if-else where one branch breaks and the other + // returns/halts/jumps still propagates as "definitely terminated". + functionActivation.ReturnInfo.DefinitelyExited = true + return } @@ -101,6 +106,8 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) functionActivation.ReturnInfo.DefinitelyJumpedLoop = true functionActivation.ReturnInfo.MaybeJumpedLoop = true + // `continue` is a kind of definite exit (see DefinitelyExited). + functionActivation.ReturnInfo.DefinitelyExited = true return } diff --git a/sema/return_info.go b/sema/return_info.go index d89eaac566..6f86616f0f 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -41,31 +41,47 @@ type ReturnInfo struct { // DefinitelyHalted indicates that (the branch of) the function // contains a definite halt (a function call with a Never return type) DefinitelyHalted bool - // DefinitelyExited indicates that (the branch of) - // the function either contains a definite return statement, - // contains a definite halt (a function call with a Never return type), - // or both. + // DefinitelyExited indicates that (the branch of) the function + // definitely terminated control flow on every path — by + // - `return`, + // - halt (a function call with a `Never` return type), + // - `break` (targeting a loop or a switch), or + // - `continue`. // - // NOTE: this is NOT the same DefinitelyReturned || DefinitelyHalted: - // For example, for the following program: + // This is the generic "every path terminated" flag + // and is the one observed by `IsUnreachable()`. + // + // NOTEL: It is intentionally NOT just + // `DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch`, + // because AND-merging each kind-specific flag separately would lose + // the case where both branches of an if-else terminate but via + // different kinds. For example: // // if ... { // return // // // DefinitelyReturned = true - // // DefinitelyHalted = false - // // DefinitelyExited = true + // // DefinitelyHalted = false + // // DefinitelyExited = true // } else { // panic(...) // // // DefinitelyReturned = false - // // DefinitelyHalted = true - // // DefinitelyExited = true + // // DefinitelyHalted = true + // // DefinitelyExited = true // } - // + // // (AND-merges of flags in both branches) // // DefinitelyReturned = false // // DefinitelyHalted = false // // DefinitelyExited = true + // + // The same logic applies to `if break else return`, + // `if continue else return`, etc. + // + // At a case body's terminal state and at a loop body's terminal state, + // `DefinitelyExited` is cleared alongside `DefinitelyReturned` and `DefinitelyHalted` + // when a corresponding `MaybeJumped*` is true, + // because the maybe-jumping path escapes the construct without terminating the function. DefinitelyExited bool // DefinitelyJumpedLoop indicates that (the branch of) the function // contains a definite jump to an enclosing loop From ff79f14c926563778ba49047bb5b8ce965e7c2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 15:46:24 -0700 Subject: [PATCH 008/139] use MaybeJumpedLoop and MaybeJumpedSwitch --- sema/checker.go | 4 +++- sema/return_info.go | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/sema/checker.go b/sema/checker.go index eea799434c..18de79888a 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -2686,7 +2686,9 @@ func (checker *Checker) maybeAddResourceInvalidation(resource Resource, invalida var onlyPotential bool switch { case resource.Member != nil: - onlyPotential = returnInfo.MaybeReturned || returnInfo.MaybeJumped() + onlyPotential = returnInfo.MaybeReturned || + returnInfo.MaybeJumpedLoop || + returnInfo.MaybeJumpedSwitch case resource.Variable != nil && resource.Variable.DeclarationKind != common.DeclarationKindSelf: diff --git a/sema/return_info.go b/sema/return_info.go index 6f86616f0f..9c29541b06 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -132,11 +132,6 @@ func NewReturnInfo() *ReturnInfo { } } -func (ri *ReturnInfo) MaybeJumped() bool { - return ri.JumpOffsets != nil && - !ri.JumpOffsets.IsEmpty() -} - func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { ri.MaybeReturned = ri.MaybeReturned || thenReturnInfo.MaybeReturned || From c9de61d1e1a1bbc902c6359efdc06da3b0cbfe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 15:54:44 -0700 Subject: [PATCH 009/139] fix lint --- sema/elaboration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sema/elaboration.go b/sema/elaboration.go index a01c6180b8..67e01f6c0d 100644 --- a/sema/elaboration.go +++ b/sema/elaboration.go @@ -172,7 +172,7 @@ type Elaboration struct { variableDeclarationTypes map[*ast.VariableDeclaration]VariableDeclarationTypes // nestedResourceMoveExpressions indicates the index or member expression // is implicitly moving a resource out of the container, e.g. in a shift or swap statement. - nestedResourceMoveExpressions map[ast.Expression]struct{} + nestedResourceMoveExpressions map[ast.Expression]struct{} // statementsEndingControlFlow contains the statements after which // control flow does not continue normally (return, halt, definite jump). // Used to insert defensive runtime checks that abort execution From 99ca4679a9bf4e13bc4593e0f61e4c8d0fe274a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 15 May 2026 16:40:45 -0700 Subject: [PATCH 010/139] add more tests for improved return info analysis --- sema/conditional_test.go | 98 +++++++++ sema/for_test.go | 436 ++++++++++++++++++++++++++++++++++++++ sema/switch_test.go | 401 +++++++++++++++++++++++++++++++++++ sema/while_test.go | 438 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 1373 insertions(+) diff --git a/sema/conditional_test.go b/sema/conditional_test.go index c7ae88d591..7b2370613d 100644 --- a/sema/conditional_test.go +++ b/sema/conditional_test.go @@ -384,6 +384,54 @@ func TestCheckInvalidGuardStatementElseBlockMustExit(t *testing.T) { require.NoError(t, err) }) + + t.Run("else with switch-break only does not exit", func(t *testing.T) { + t.Parallel() + + // The break inside the switch is consumed by the switch and falls + // past the guard's else, so the else block does NOT definitely + // exit. The check must reject this — failing to do so is a + // soundness bug (a shadowing `guard let x = ...` could let an + // outer-typed value be used at the inner type after the guard). + _, err := ParseAndCheck(t, ` + fun test() { + guard true else { + switch 1 { default: break } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.GuardStatementElseBlockMustExitError{}, errs[0]) + }) + + t.Run("else with nested guard-break in switch case does not exit", func(t *testing.T) { + t.Parallel() + + // The inner guard's `break` exits only the surrounding switch, + // not the outer guard. When the inner guard fails and switch + // case 1 is taken, control falls past the switch and past the + // outer guard's else — the outer else does NOT definitely exit. + _, err := ParseAndCheck(t, ` + fun test(): Int { + guard true else { + switch 1 { + case 1: + guard let y = (nil as Int?) else { + break + } + return 2 + default: + return 0 + } + } + return 3 + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.GuardStatementElseBlockMustExitError{}, errs[0]) + }) } func TestCheckGuardStatementResourceTracking(t *testing.T) { @@ -637,3 +685,53 @@ func TestCheckGuardStatementWithResourceFailableCast(t *testing.T) { assert.IsType(t, &sema.ResourceLossError{}, errs[0]) }) } + +func TestCheckInvalidForGuardStatementElseBlockMustExit(t *testing.T) { + t.Parallel() + + t.Run("simple", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + guard true else { + switch false { default: break } + } + return 1 + } + return 2 + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.GuardStatementElseBlockMustExitError{}, errs[0]) + }) + + t.Run("nested", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + guard true else { + switch 1 { + case 1: + guard true else { + break + } + return 2 + default: + return 0 + } + } + return 1 + } + return 3 + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.GuardStatementElseBlockMustExitError{}, errs[0]) + }) +} diff --git a/sema/for_test.go b/sema/for_test.go index 1d2937278d..c2f651b475 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -788,3 +788,439 @@ func TestCheckForDictionary(t *testing.T) { require.NoError(t, err) }) } + +func TestCheckBreakInForLoopBodyDoesNotPreventOuterReturn(t *testing.T) { + + t.Parallel() + + // A `break` inside the for-loop body targets the loop, not the enclosing function. + // The trailing `return 1` must therefore still mark the function as definitely returning. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + break + } + return 1 + } + `) + + require.NoError(t, err) +} + +func TestCheckContinueInForLoopBodyDoesNotPreventOuterReturn(t *testing.T) { + + t.Parallel() + + // A `continue` inside the for-loop body targets the loop, not the enclosing function. + // The trailing `return 1` must therefore still mark the function as definitely returning. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + continue + } + return 1 + } + `) + + require.NoError(t, err) +} + +// TestCheckForLoopBodyMixedExitVariants exercises every unique pair of distinct exit kinds +// (return, halt, break, continue) used as the two branches of an `if-else` inside the for-loop body. +// Every path through the if-else terminates control flow (in some way), +// so any trailing statement must be reported as unreachable. +func TestCheckForLoopBodyMixedExitVariants(t *testing.T) { + + t.Parallel() + + t.Run("break and continue", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + if true { break } else { continue } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("break and halt", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { break } else { panic("x") } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("break and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + if true { break } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("continue and halt", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { continue } else { panic("x") } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("continue and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + if true { continue } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("halt and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { panic("x") } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +// TestCheckForLoopConditionalJumpThenTermination covers the +// "maybe-jump on one path, definite terminator on the other" pattern in +// a for-loop body. +// +// For each (JUMP, TERMINATOR) combination, two assertions: +// - Code AFTER the loop is reachable: the jump path falls past the +// loop, so the loop body's `DefinitelyReturned`/`DefinitelyHalted` +// claim must NOT propagate to the function. +// - A statement AFTER the terminator inside the body is unreachable: +// within the body, every path through the if-else does terminate. +func TestCheckForLoopConditionalJumpThenTermination(t *testing.T) { + + t.Parallel() + + t.Run("if break then return; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + if true { break } + return 1 + } + return 2 + } + `) + require.NoError(t, err) + }) + + t.Run("if break then return; statement after return is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + if true { break } + return + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if break then halt; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { break } + panic("x") + } + let y = 1 + } + `) + require.NoError(t, err) + }) + + t.Run("if break then halt; statement after halt is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { break } + panic("x") + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if continue then return; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + if true { continue } + return 1 + } + return 2 + } + `) + require.NoError(t, err) + }) + + t.Run("if continue then return; statement after return is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + if true { continue } + return + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if continue then halt; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { continue } + panic("x") + } + let y = 1 + } + `) + require.NoError(t, err) + }) + + t.Run("if continue then halt; statement after halt is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + for _ in [1] { + if true { continue } + panic("x") + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +func TestCheckNestedForLoopBreakDoesNotEscapeOuterLoop(t *testing.T) { + + t.Parallel() + + // A `break` inside the inner for-loop targets the inner loop only. + // Code after the inner loop, but still in the outer loop body, + // must remain reachable. + + _, err := ParseAndCheck(t, ` + fun test() { + for i in [1] { + for j in [2] { + break + } + let x = 1 + } + } + `) + + require.NoError(t, err) +} + +func TestCheckNestedForLoopMaybeJumpedDoesNotEscape(t *testing.T) { + + t.Parallel() + + // A `MaybeJumpedLoop` set inside an inner for-loop body must not leak into the outer loop's body state. + // WithLoop save/restores both `DefinitelyJumpedLoop` and `MaybeJumpedLoop`. + _, err := ParseAndCheck(t, ` + fun test(): Int { + for i in [1] { + for j in [2] { + if true { break } + return 1 + } + let x = 1 + } + return 2 + } + `) + + require.NoError(t, err) +} + +// TestCheckForLoopWithSwitchInBody verifies that a switch nested in a for-loop body +// interacts correctly with the loop's control flow: +// switch-targeting `break` is consumed by the switch, +// `continue` propagates past the switch to the enclosing loop. +func TestCheckForLoopWithSwitchInBody(t *testing.T) { + + t.Parallel() + + t.Run("switch break does not escape loop body", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + switch 1 { + case 1: + break + default: + break + } + let x = 1 + } + } + `) + require.NoError(t, err) + }) + + t.Run("all-cases continue makes post-switch in loop unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + for _ in [1] { + switch 1 { + case 1: + continue + default: + continue + } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("nested switch case with maybe-break does not affect outer return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + switch 1 { + case 1: + if true { break } + return 1 + default: + return 2 + } + } + return 3 + } + `) + require.NoError(t, err) + }) + + t.Run("nested switch case with maybe-continue does not over-claim", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + switch 1 { + case 1: + if true { continue } + return 1 + default: + return 2 + } + } + return 3 + } + `) + require.NoError(t, err) + }) +} + +func TestCheckResourceInForLoopBodyMaybeBreak(t *testing.T) { + + t.Parallel() + + // A for-loop body whose destroy/return path is guarded by a maybe-break: + // on the break path, the resource is not destroyed and the loop is exited, + // so the resource is potentially lost. + + _, err := ParseAndCheck(t, ` + resource R {} + fun test(cond: Bool) { + let r <- create R() + for _ in [1] { + if cond { break } + destroy r + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) +} + +func TestCheckGuardElseBreakInForLoop(t *testing.T) { + + t.Parallel() + + // A `guard ... else { break }` inside a for-loop body + // must propagate the potential loop-targeting jump out of the (potentially-unevaluated) else block, + // so code after the loop remains reachable. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + guard let y = (nil as Int?) else { break } + return y + } + return 3 + } + `) + + require.NoError(t, err) +} diff --git a/sema/switch_test.go b/sema/switch_test.go index 585980346c..07c820a307 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -754,3 +754,404 @@ func TestCheckSwitchMaybeBreakDoesNotSuppressUnreachableInCase(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) } + +func TestCheckSwitchConditionalBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(): Int { + switch 1 { + case 1: + if true { break } + return 2 + default: + return 0 + } + return 3 // Shouldn't be marked as unreachable + } + `) + + require.NoError(t, err) +} + +func TestCheckSwitchConditionalHalt(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + switch 1 { + case 1: + if true { break } + panic("unreachable") + default: + return + } + let x = 1 // Shouldn't be marked as unreachable + } + `) + + require.NoError(t, err) +} + +func TestCheckSwitchGuardElseBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(): Int { + switch 1 { + case 1: + guard let y = (nil as Int?) else { break } + return y + default: + return 0 + } + return 3 // Shouldn't be marked as unreachable + } + `) + + require.NoError(t, err) +} + +func TestCheckSwitchUnreachableAfterReturnFollowingConditionalBreak(t *testing.T) { + + t.Parallel() + + // Inside a single case body, `if cond { break }; return` exits via either path. + // Any statement following the return must therefore be reported as unreachable, + // even though the case as a whole is not DefinitelyReturned (one path broke from the switch). + + _, err := ParseAndCheck(t, ` + fun test(): Int { + switch 1 { + case 1: + if true { break } + return 2 + let x = 1 + default: + return 0 + } + return 3 + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) +} + +func TestCheckSwitchUnreachableAfterMixedExits(t *testing.T) { + + t.Parallel() + + // Inside a case body, both branches of an `if-else` exit, + // but in different ways: the `then` branch breaks from the switch, + // while the `else` branch returns from the function. + // Neither DefinitelyReturned nor DefinitelyJumpedSwitch holds for both branches, + // but every path has exited, so subsequent statements must be reported as unreachable. + _, err := ParseAndCheck(t, ` + fun test() { + switch 1 { + case 1: + if true { + break + } else { + return + } + let x = 1 + default: + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) +} + +// TestCheckSwitchMixedExitVariants exercises the various combinations +// of mixed exits inside a single switch case body. +// In each case, every path through the if-else terminates, +// so any trailing statement must be reported as unreachable, +// but the switch as a whole does not definitely terminate (one path breaks out), +// so code after the switch remains reachable. +func TestCheckSwitchMixedExitVariants(t *testing.T) { + + t.Parallel() + + t.Run("return then break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test() { + switch 1 { + case 1: + if true { return } else { break } + let x = 1 + default: + return + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("break then halt", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + switch 1 { + case 1: + if true { break } else { panic("x") } + let x = 1 + default: + return + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("halt then break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + switch 1 { + case 1: + if true { panic("x") } else { break } + let x = 1 + default: + return + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("return then halt", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + switch 1 { + case 1: + if true { return } else { panic("x") } + let x = 1 + default: + return + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +// TestCheckSwitchCaseConditionalJumpThenTermination covers the "maybe-jump on one path, +// definite terminator on the other" pattern in a switch case body. +// The case is not a definite-return for the switch merge (the break path falls past the switch), +// and any statement after the trailing terminator is unreachable. +func TestCheckSwitchCaseConditionalJumpThenTermination(t *testing.T) { + + t.Parallel() + + t.Run("if break then halt; code after switch reachable", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test(): Int { + switch 1 { + case 1: + if true { break } + panic("x") + default: + return 0 + } + return 3 + } + `) + require.NoError(t, err) + }) + + t.Run("if break then halt; statement after halt is unreachable", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + switch 1 { + case 1: + if true { break } + panic("x") + let x = 1 + default: + return + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +// TestCheckSwitchAllCasesMaybeBreak verifies that when every case body "maybe breaks" (and otherwise returns), +// the switch is correctly not treated as a definite return, so code after it remains reachable. +func TestCheckSwitchAllCasesMaybeBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(cond: Bool): Int { + switch 1 { + case 1: + if cond { break } + return 1 + case 2: + if cond { break } + return 2 + default: + if cond { break } + return 3 + } + return 4 + } + `) + + require.NoError(t, err) +} + +// TestCheckSwitchNestedSwitchInnerBreak verifies that a break inside an inner switch +// is consumed by that inner switch and does not affect the outer case body's "definitely returns" status. +// The outer case definitely returns because the inner switch's break-path falls through to the trailing `return`. +func TestCheckSwitchNestedSwitchInnerBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(a: Int, b: Int): Int { + switch a { + case 1: + switch b { + case 1: + break + default: + return 10 + } + return 1 + default: + return 2 + } + return 3 // unreachable: outer case 1 and default both return + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) +} + +// TestCheckSwitchLoopInCaseInnerBreak verifies that a `break` inside a loop nested in a switch case +// targets the loop (the innermost construct), not the switch. +// The case is therefore a definite return. +func TestCheckSwitchLoopInCaseInnerBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + fun test(): Int { + switch 1 { + case 1: + while true { + break + } + return 1 + default: + return 2 + } + return 3 // unreachable + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) +} + +// TestCheckSwitchContinueInSwitchInLoop verifies that a `continue` inside a switch case +// (where the switch is inside a loop) targets the enclosing loop, +// not the switch — the continue propagates past the switch as a loop-targeting jump. +func TestCheckSwitchContinueInSwitchInLoop(t *testing.T) { + + t.Parallel() + + t.Run("continue in all cases makes post-switch unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + switch 1 { + case 1: + continue + default: + continue + } + let x = 1 // unreachable + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("mixed break-switch and continue: post-switch reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + switch 1 { + case 1: + break + default: + continue + } + let x = 1 // reachable on the break path + } + } + `) + require.NoError(t, err) + }) +} + +// TestCheckSwitchResourceMaybeBreak verifies that a switch case whose body destroys a resource +// on the non-break path correctly reports the resource as potentially lost: +// the break path leaves the resource undestroyed. +func TestCheckSwitchResourceMaybeBreak(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + fun test(cond: Bool) { + let r <- create R() + switch 1 { + case 1: + if cond { break } + destroy r + return + default: + destroy r + return + } + } + `) + + // The break path in case 1 does not destroy r, + // so r escapes the switch on that path. + // After the switch, r may still be alive. + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) +} diff --git a/sema/while_test.go b/sema/while_test.go index 3922b94315..b67689fd1c 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -165,3 +166,440 @@ func TestCheckInvalidContinueStatement(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.ControlStatementError{}, errs[0]) } + +func TestCheckBreakInWhileLoopBodyDoesNotPreventOuterReturn(t *testing.T) { + + t.Parallel() + + // A `break` inside the while-loop body targets the loop, not the enclosing function. + // The trailing `return 1` must therefore still mark the function as definitely returning. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + break + } + return 1 + } + `) + + require.NoError(t, err) +} + +func TestCheckContinueInWhileLoopBodyDoesNotPreventOuterReturn(t *testing.T) { + + t.Parallel() + + // A `continue` inside the while-loop body targets the loop, not the enclosing function. + // The trailing `return 1` must therefore still mark the function as definitely returning. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + continue + } + return 1 + } + `) + + require.NoError(t, err) +} + +// TestCheckWhileLoopBodyMixedExitVariants exercises every unique pair of distinct exit kinds +// (return, halt, break, continue) used as the two branches of an `if-else` inside the while-loop body. +// Every path through the if-else terminates control flow (in some way), +// so any trailing statement must be reported as unreachable. +func TestCheckWhileLoopBodyMixedExitVariants(t *testing.T) { + + t.Parallel() + + t.Run("break and continue", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + if true { break } else { continue } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("break and halt", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { break } else { panic("x") } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("break and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + if true { break } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("continue and halt", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { continue } else { panic("x") } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("continue and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + if true { continue } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("halt and return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { panic("x") } else { return } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +// TestCheckWhileLoopConditionalJumpThenTermination covers the +// "maybe-jump on one path, definite terminator on the other" pattern in +// a while-loop body. +// +// For each (JUMP, TERMINATOR) combination, two assertions: +// - Code AFTER the loop is reachable: the jump path falls past the +// loop, so the loop body's `DefinitelyReturned`/`DefinitelyHalted` +// claim must NOT propagate to the function. +// - A statement AFTER the terminator inside the body is unreachable: +// within the body, every path through the if-else does terminate. +func TestCheckWhileLoopConditionalJumpThenTermination(t *testing.T) { + + t.Parallel() + + t.Run("if break then return; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + if true { break } + return 1 + } + return 2 + } + `) + require.NoError(t, err) + }) + + t.Run("if break then return; statement after return is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + if true { break } + return + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if break then halt; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { break } + panic("x") + } + let y = 1 + } + `) + require.NoError(t, err) + }) + + t.Run("if break then halt; statement after halt is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { break } + panic("x") + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if continue then return; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + if true { continue } + return 1 + } + return 2 + } + `) + require.NoError(t, err) + }) + + t.Run("if continue then return; statement after return is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + if true { continue } + return + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("if continue then halt; code after loop reachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { continue } + panic("x") + } + let y = 1 + } + `) + require.NoError(t, err) + }) + + t.Run("if continue then halt; statement after halt is unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheckWithPanic(t, ` + fun test() { + while true { + if true { continue } + panic("x") + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) +} + +func TestCheckNestedWhileLoopBreakDoesNotEscapeOuterLoop(t *testing.T) { + + t.Parallel() + + // A `break` inside the inner while-loop targets the inner loop only. + // Code after the inner loop, but still in the outer loop body, + // must remain reachable. + + _, err := ParseAndCheck(t, ` + fun test() { + while true { + while true { + break + } + let x = 1 + } + } + `) + + require.NoError(t, err) +} + +func TestCheckNestedWhileLoopMaybeJumpedDoesNotEscape(t *testing.T) { + + t.Parallel() + + // A `MaybeJumpedLoop` set inside an inner while-loop body must not leak into the outer loop's body state. + // WithLoop save/restores both `DefinitelyJumpedLoop` and `MaybeJumpedLoop`. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + while true { + if true { break } + return 1 + } + let x = 1 + } + return 2 + } + `) + + require.NoError(t, err) +} + +// TestCheckWhileLoopWithSwitchInBody verifies that a switch nested in a while-loop body +// interacts correctly with the loop's control flow: +// switch-targeting `break` is consumed by the switch, +// `continue` propagates past the switch to the enclosing loop. +func TestCheckWhileLoopWithSwitchInBody(t *testing.T) { + + t.Parallel() + + t.Run("switch break does not escape loop body", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + switch 1 { + case 1: + break + default: + break + } + let x = 1 + } + } + `) + require.NoError(t, err) + }) + + t.Run("all-cases continue makes post-switch in loop unreachable", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test() { + while true { + switch 1 { + case 1: + continue + default: + continue + } + let x = 1 + } + } + `) + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.UnreachableStatementError{}, errs[0]) + }) + + t.Run("nested switch case with maybe-break does not affect outer return", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + switch 1 { + case 1: + if true { break } + return 1 + default: + return 2 + } + } + return 3 + } + `) + require.NoError(t, err) + }) + + t.Run("nested switch case with maybe-continue does not over-claim", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + switch 1 { + case 1: + if true { continue } + return 1 + default: + return 2 + } + } + return 3 + } + `) + require.NoError(t, err) + }) +} + +func TestCheckResourceInWhileLoopBodyMaybeBreak(t *testing.T) { + + t.Parallel() + + // A while-loop body whose destroy/return path is guarded by a maybe-break: + // on the break path, the resource is not destroyed and the loop is exited, + // so the resource is potentially lost. + + _, err := ParseAndCheck(t, ` + resource R {} + fun test(cond: Bool) { + let r <- create R() + while true { + if cond { break } + destroy r + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) +} + +func TestCheckGuardElseBreakInWhileLoop(t *testing.T) { + + t.Parallel() + + // A `guard ... else { break }` inside a while-loop body + // must propagate the potential loop-targeting jump out of the (potentially-unevaluated) else block, + // so code after the loop remains reachable. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + guard let y = (nil as Int?) else { break } + return y + } + return 3 + } + `) + + require.NoError(t, err) +} From b017a6d0e2a3663d1a08ebb77bb21e37bb40e206 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Mon, 18 May 2026 14:44:33 -0700 Subject: [PATCH 011/139] Add reproducer --- sema/for_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++ sema/while_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/sema/for_test.go b/sema/for_test.go index 1d2937278d..9d101ffe15 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -788,3 +788,62 @@ func TestCheckForDictionary(t *testing.T) { require.NoError(t, err) }) } + +func TestCheckResourceInvalidationInForLoop(t *testing.T) { + + t.Parallel() + + t.Run("break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + continue + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} diff --git a/sema/while_test.go b/sema/while_test.go index 3922b94315..b6df3103eb 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -21,10 +21,9 @@ package sema_test import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" + "github.com/stretchr/testify/assert" ) func TestCheckInvalidWhileTest(t *testing.T) { @@ -165,3 +164,62 @@ func TestCheckInvalidContinueStatement(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.ControlStatementError{}, errs[0]) } + +func TestCheckResourceInvalidationInWhileLoop(t *testing.T) { + + t.Parallel() + + t.Run("break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + continue + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} From a4d86a4e8c5487f448267d443fa04d1bc1dafd32 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Tue, 19 May 2026 08:37:46 -0700 Subject: [PATCH 012/139] Clear git module cache --- .github/workflows/compatibility-check-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compatibility-check-template.yml b/.github/workflows/compatibility-check-template.yml index e7256cdf6d..50e6fd5984 100644 --- a/.github/workflows/compatibility-check-template.yml +++ b/.github/workflows/compatibility-check-template.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: ./tools/compatibility-check/go.mod - cache: true + cache: false - name: Make output dirs run: | From 2f17e2b0bd9face00b7d9757f442cbf293c3413e Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Tue, 19 May 2026 09:18:58 -0700 Subject: [PATCH 013/139] Turn off go caching in CI --- .github/workflows/compatibility-check-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compatibility-check-template.yml b/.github/workflows/compatibility-check-template.yml index e7256cdf6d..50e6fd5984 100644 --- a/.github/workflows/compatibility-check-template.yml +++ b/.github/workflows/compatibility-check-template.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: ./tools/compatibility-check/go.mod - cache: true + cache: false - name: Make output dirs run: | From 6b7adea4151f91b4170d39b0dab148493fdae229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 19 May 2026 12:02:00 -0700 Subject: [PATCH 014/139] update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 7908ae9aaa..2354c4b17a 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3-rc.2" +const Version = "v1.10.3-rc.3" From 6e363d651dd82a8a2830e3e7ac3d763e1f9e0224 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Tue, 19 May 2026 14:42:30 -0700 Subject: [PATCH 015/139] Skip resource loss check only if definitely-exited, but not jumped --- sema/checker.go | 16 +++++++++++++++- sema/while_test.go | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/sema/checker.go b/sema/checker.go index eea799434c..c6de3c3ac7 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1477,7 +1477,21 @@ func (checker *Checker) leaveValueScope(getEndPosition EndPositionGetter, checkR func (checker *Checker) checkResourceLoss(depth int) { returnInfo := checker.functionActivations.Current().ReturnInfo - if returnInfo.IsUnreachable() { + + // Skip the check only if the function has definitely exited + // (i.e. via a `return` statement, or via a definite halt such as `panic(...)`): + // + // - `return` invokes `checkResourceLoss` itself before marking the function + // as exited, so the variables it would catch have already been reported. + // - A definite halt intentionally does not lead to a resource-loss error, + // because the program would terminate with an error. + // + // In particular, the check must NOT be skipped after `break` / `continue` + // (`DefinitelyJumped` / `DefinitelyJumpedSwitch`): those statements do not + // invalidate resources, so resources declared inside the loop body or switch + // case but not moved/destroyed before the jump must still be reported when + // the enclosing scope is left. + if returnInfo.DefinitelyExited { return } diff --git a/sema/while_test.go b/sema/while_test.go index b6df3103eb..afb7cb8668 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -21,9 +21,10 @@ package sema_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" - "github.com/stretchr/testify/assert" ) func TestCheckInvalidWhileTest(t *testing.T) { From 1a33874f413cd2cc2442a45b20039bd7e9b63fc0 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Tue, 19 May 2026 14:58:23 -0700 Subject: [PATCH 016/139] Add more tests --- sema/for_test.go | 163 +++++++++++++++++++++++++ sema/switch_test.go | 155 ++++++++++++++++++++++++ sema/while_test.go | 288 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 606 insertions(+) diff --git a/sema/for_test.go b/sema/for_test.go index 9d101ffe15..648ad78b9c 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -847,3 +847,166 @@ func TestCheckResourceInvalidationInForLoop(t *testing.T) { assert.IsType(t, &sema.ResourceLossError{}, errs[0]) }) } + +func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { + + t.Parallel() + + t.Run("if break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + continue + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if-else both break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break else destroy", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the else path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } else { + destroy r + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else destroy break", func(t *testing.T) { + t.Parallel() + + // `r` is destroyed in both branches before the `break`, so no loss. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + destroy r + break + } else { + destroy r + } + } + } + `) + + require.NoError(t, err) + }) + + t.Run("resource outside loop, if break", func(t *testing.T) { + t.Parallel() + + // `r` is declared outside the loop and destroyed after the loop, + // so the `break` does not leak it. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + for i in [2] { + if true { + break + } + } + destroy r + } + `) + + require.NoError(t, err) + }) +} diff --git a/sema/switch_test.go b/sema/switch_test.go index ffdd7acb26..dd3f920a89 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -712,3 +712,158 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { assert.IsType(t, &sema.ResourceUseAfterInvalidationError{}, errs[0]) }) } + +func TestCheckResourceInvalidationInSwitch(t *testing.T) { + + t.Parallel() + + t.Run("break in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("break in default case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + default: + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("resource outside switch, break in case", func(t *testing.T) { + t.Parallel() + + // `break` only exits the switch, not any enclosing scope. + // `r` is declared outside and destroyed after the switch, + // so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + switch true { + case true: + break + } + destroy r + } + `) + + require.NoError(t, err) + }) + + t.Run("break in nested if, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("break in switch inside loop, resource in loop body", func(t *testing.T) { + t.Parallel() + + // `break` targets the innermost switch (not the loop), + // so the loop's `r` is destroyed at the end of each iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + case true: + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("break in switch inside loop, resource in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + switch true { + case true: + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} diff --git a/sema/while_test.go b/sema/while_test.go index afb7cb8668..6e9984f573 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -224,3 +225,290 @@ func TestCheckResourceInvalidationInWhileLoop(t *testing.T) { assert.IsType(t, &sema.ResourceLossError{}, errs[0]) }) } + +func TestCheckResourceInvalidationInWhileLoopWithIfElse(t *testing.T) { + + t.Parallel() + + t.Run("if break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + continue + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if-else both break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break else destroy", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the else path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } else { + destroy r + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else break", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the then path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + destroy r + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else destroy break", func(t *testing.T) { + t.Parallel() + + // `r` is destroyed in both branches before the `break`, so no loss. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + destroy r + break + } else { + destroy r + } + } + } + `) + + require.NoError(t, err) + }) + + t.Run("nested if break in inner if", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + if true { + break + } + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("resource outside loop, if break", func(t *testing.T) { + t.Parallel() + + // `r` is declared outside the loop and destroyed after the loop, + // so the `break` does not leak it. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + while true { + if true { + break + } + } + destroy r + } + `) + + require.NoError(t, err) + }) +} + +func TestCheckResourceInvalidationInNestedWhileLoops(t *testing.T) { + + t.Parallel() + + t.Run("break in inner loop, destroy in outer", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the outer loop and destroyed there. + // The inner `break` only exits the inner loop, so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + while true { + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("resource in inner loop, inner break", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the inner loop body and the `break` exits it + // without destroying `r`. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + while true { + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("outer break inside inner loop", func(t *testing.T) { + t.Parallel() + + // `break` always targets the innermost loop, + // so the inner `break` only exits the inner loop. + // The outer loop's `r` is destroyed at the end of each outer iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + while true { + if true { + break + } + } + destroy r + } + } + `) + + require.NoError(t, err) + }) +} From 6e1b719335eee8cf91da3afa3f25ae04805bc0bc Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Tue, 19 May 2026 16:57:29 -0700 Subject: [PATCH 017/139] Merge jump offsets when merging return info --- sema/return_info.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/sema/return_info.go b/sema/return_info.go index 3a66a91f2c..4764141040 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -116,6 +116,25 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * ri.DefinitelyExited = ri.DefinitelyExited || (thenReturnInfo.DefinitelyExited && elseReturnInfo.DefinitelyExited) + + ri.mergeJumpOffsets(thenReturnInfo, elseReturnInfo) +} + +func (ri *ReturnInfo) mergeJumpOffsets(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { + // Propagate jump offsets recorded in either branch. + // Each branch has its own cloned JumpOffsets set (see Clone), + // so jumps in one branch do not contaminate the sibling branch's view. + // After the conditional, both branches' jumps are potential + // from the perspective of code that follows. + addAll := func(other *ReturnInfo) { + _ = other.JumpOffsets.ForEach(func(offset int) error { + ri.JumpOffsets.Add(offset) + return nil + }) + } + + addAll(thenReturnInfo) + addAll(elseReturnInfo) } func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInfo) { @@ -128,6 +147,9 @@ func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInf func (ri *ReturnInfo) Clone() *ReturnInfo { result := NewReturnInfo() *result = *ri + // Clone JumpOffsets so that jumps recorded in this clone + // do not leak into sibling clones (e.g. then vs. else branch). + result.JumpOffsets = ri.JumpOffsets.Clone() return result } From bd00d5d2e65510147a5811451c6c1d8bff3c3a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 12:55:22 -0700 Subject: [PATCH 018/139] align comments --- sema/check_while.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sema/check_while.go b/sema/check_while.go index d186604418..0b9eb32d93 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -77,8 +77,8 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st } // `break` is a kind of definite exit (see DefinitelyExited). - // Set it so that an if-else where one branch breaks and the other - // returns/halts/jumps still propagates as "definitely terminated". + // Set it so that an if-else where one branch breaks + // and the other returns/halts/jumps still propagates as "definitely terminated". functionActivation.ReturnInfo.DefinitelyExited = true return @@ -107,6 +107,8 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) functionActivation.ReturnInfo.DefinitelyJumpedLoop = true functionActivation.ReturnInfo.MaybeJumpedLoop = true // `continue` is a kind of definite exit (see DefinitelyExited). + // Set it so that an if-else where one branch continues + // and the other returns/halts/jumps still propagates as "definitely terminated". functionActivation.ReturnInfo.DefinitelyExited = true return From 77084e2f637b89937843cb3223b7ed5cdfc5958f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 13:04:23 -0700 Subject: [PATCH 019/139] improve IsUnreachable and comments for DefinitelyExited --- sema/check_function.go | 2 +- sema/return_info.go | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sema/check_function.go b/sema/check_function.go index dfc4477684..d0232b4ee2 100644 --- a/sema/check_function.go +++ b/sema/check_function.go @@ -251,7 +251,7 @@ func (checker *Checker) checkFunctionExits(functionBlock *ast.FunctionBlock, ret functionActivation := checker.functionActivations.Current() - // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, + // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch, // see DefinitelyExited if functionActivation.ReturnInfo.DefinitelyExited { return diff --git a/sema/return_info.go b/sema/return_info.go index 9c29541b06..cf3cad57f1 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -51,11 +51,11 @@ type ReturnInfo struct { // This is the generic "every path terminated" flag // and is the one observed by `IsUnreachable()`. // - // NOTEL: It is intentionally NOT just + // NOTE: It is intentionally NOT just // `DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch`, // because AND-merging each kind-specific flag separately would lose - // the case where both branches of an if-else terminate but via - // different kinds. For example: + // the case where both branches of an if-else terminate but via different kinds. + // For example: // // if ... { // return @@ -186,11 +186,9 @@ func (ri *ReturnInfo) Clone() *ReturnInfo { } func (ri *ReturnInfo) IsUnreachable() bool { - // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, + // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch, // see DefinitelyExited - return ri.DefinitelyExited || - ri.DefinitelyJumpedLoop || - ri.DefinitelyJumpedSwitch + return ri.DefinitelyExited } func (ri *ReturnInfo) AddJumpOffset(offset int) { From f73ebbf132ecfed7adf0eb23fb2cd3157c4e2c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 14:01:06 -0700 Subject: [PATCH 020/139] remove unnecessary DefinitelyJumpedLoop and DefinitelyJumpedSwitch --- sema/check_function.go | 2 +- sema/check_while.go | 3 -- sema/for_test.go | 5 +-- sema/function_activations.go | 21 ++++++------- sema/return_info.go | 60 ++++++++++++------------------------ sema/switch_test.go | 5 +-- sema/while_test.go | 5 +-- 7 files changed, 39 insertions(+), 62 deletions(-) diff --git a/sema/check_function.go b/sema/check_function.go index d0232b4ee2..dfc4477684 100644 --- a/sema/check_function.go +++ b/sema/check_function.go @@ -251,7 +251,7 @@ func (checker *Checker) checkFunctionExits(functionBlock *ast.FunctionBlock, ret functionActivation := checker.functionActivations.Current() - // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch, + // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, // see DefinitelyExited if functionActivation.ReturnInfo.DefinitelyExited { return diff --git a/sema/check_while.go b/sema/check_while.go index 0b9eb32d93..d83be78cdb 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -68,11 +68,9 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st return case ControlKindLoop: - functionActivation.ReturnInfo.DefinitelyJumpedLoop = true functionActivation.ReturnInfo.MaybeJumpedLoop = true case ControlKindSwitch: - functionActivation.ReturnInfo.DefinitelyJumpedSwitch = true functionActivation.ReturnInfo.MaybeJumpedSwitch = true } @@ -104,7 +102,6 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) } functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) - functionActivation.ReturnInfo.DefinitelyJumpedLoop = true functionActivation.ReturnInfo.MaybeJumpedLoop = true // `continue` is a kind of definite exit (see DefinitelyExited). // Set it so that an if-else where one branch continues diff --git a/sema/for_test.go b/sema/for_test.go index c2f651b475..4249db7258 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -1077,8 +1077,9 @@ func TestCheckNestedForLoopMaybeJumpedDoesNotEscape(t *testing.T) { t.Parallel() - // A `MaybeJumpedLoop` set inside an inner for-loop body must not leak into the outer loop's body state. - // WithLoop save/restores both `DefinitelyJumpedLoop` and `MaybeJumpedLoop`. + // A `MaybeJumpedLoop` set inside an inner for-loop body must not leak + // into the outer loop's body state — `WithLoop` save/restores + // `MaybeJumpedLoop`. _, err := ParseAndCheck(t, ` fun test(): Int { for i in [1] { diff --git a/sema/function_activations.go b/sema/function_activations.go index b449878b71..09a3acd452 100644 --- a/sema/function_activations.go +++ b/sema/function_activations.go @@ -63,23 +63,21 @@ func (a *FunctionActivation) popControl() { func (a *FunctionActivation) WithLoop(f func()) { a.pushControl(ControlKindLoop) - // DefinitelyJumpedLoop and MaybeJumpedLoop are scoped to this loop: - // save on entry, restore on exit, so a nested loop's break/continue does not affect an enclosing loop. - // `break` and `continue` statements targeting this loop are consumed by the loop - // and must not leak to an enclosing scope - savedDefinitelyJumpedLoop := a.ReturnInfo.DefinitelyJumpedLoop + // MaybeJumpedLoop is scoped to this loop: save on entry, restore on exit, + // so a nested loop's break/continue does not affect an enclosing loop. + // `break` and `continue` statements targeting this loop + // are consumed by the loop and must not leak to an enclosing scope. savedMaybeJumpedLoop := a.ReturnInfo.MaybeJumpedLoop a.ReturnInfo.WithNewJumpTarget(f) // If any path within the loop body jumped (break/continue), // the body did not definitely return/halt/exit on every path: // clear the corresponding flags so subsequent reasoning sees an - // accurate per-body result + // accurate per-body result. if a.ReturnInfo.MaybeJumpedLoop { a.ReturnInfo.DefinitelyReturned = false a.ReturnInfo.DefinitelyHalted = false a.ReturnInfo.DefinitelyExited = false } - a.ReturnInfo.DefinitelyJumpedLoop = savedDefinitelyJumpedLoop a.ReturnInfo.MaybeJumpedLoop = savedMaybeJumpedLoop a.popControl() } @@ -88,14 +86,13 @@ func (a *FunctionActivation) WithSwitch(f func()) { // NOTE: new jump-offsets child-set for each case instead of whole switch a.pushControl(ControlKindSwitch) // `break` statements inside a switch only target the switch, not any enclosing loop. - // DefinitelyJumpedSwitch and MaybeJumpedSwitch are scoped to the switch: - // save on entry, restore on exit, so a nested switch's break does not affect an enclosing switch. + // MaybeJumpedSwitch is scoped to this switch: save on entry, restore on exit, + // so a nested switch's break does not affect an enclosing switch. // (`continue` statements always target the enclosing loop, - // so it sets *JumpedLoop (not *JumpedSwitch) and therefore propagates past the switch) - savedDefinitelyJumpedSwitch := a.ReturnInfo.DefinitelyJumpedSwitch + // so it sets MaybeJumpedLoop (not MaybeJumpedSwitch) + // and therefore propagates past the switch.) savedMaybeJumpedSwitch := a.ReturnInfo.MaybeJumpedSwitch f() - a.ReturnInfo.DefinitelyJumpedSwitch = savedDefinitelyJumpedSwitch a.ReturnInfo.MaybeJumpedSwitch = savedMaybeJumpedSwitch a.popControl() } diff --git a/sema/return_info.go b/sema/return_info.go index cf3cad57f1..d2fa862ac1 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -51,8 +51,7 @@ type ReturnInfo struct { // This is the generic "every path terminated" flag // and is the one observed by `IsUnreachable()`. // - // NOTE: It is intentionally NOT just - // `DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch`, + // NOTE: It is intentionally NOT just `DefinitelyReturned || DefinitelyHalted`, // because AND-merging each kind-specific flag separately would lose // the case where both branches of an if-else terminate but via different kinds. // For example: @@ -83,46 +82,35 @@ type ReturnInfo struct { // when a corresponding `MaybeJumped*` is true, // because the maybe-jumping path escapes the construct without terminating the function. DefinitelyExited bool - // DefinitelyJumpedLoop indicates that (the branch of) the function - // contains a definite jump to an enclosing loop - // (a `break` targeting a loop, or a `continue`). - DefinitelyJumpedLoop bool - // MaybeJumped indicates that some path within the current loop + // MaybeJumpedLoop indicates that some path within the current loop // reached a jump targeting that loop (a `break` whose target is the // loop, or a `continue`). // - // This is the "Maybe" counterpart to `DefinitelyJumpedLoop`, OR-merged - // across branches and not cleared by subsequent statements within - // the loop. It mirrors `MaybeJumpedSwitch` and is scoped to the loop: - // cleared by `WithLoop` on exit, since such jumps are consumed by - // the loop and are irrelevant outside it. - MaybeJumpedLoop bool - // DefinitelyJumpedSwitch indicates that (the branch of) the function - // contains a definite `break` whose target is a switch. + // OR-merged across branches and not cleared by subsequent statements + // within the loop. Scoped to the loop: cleared by `WithLoop` on exit, + // since such jumps are consumed by the loop and are irrelevant + // outside it. // - // Tracked separately from DefinitelyJumpedLoop because a `break` inside a switch - // only exits the switch; it must not leak past the switch boundary as a - // jump to an outer loop. The flag is observed for unreachability inside the - // switch and is cleared by WithSwitch on exit. - DefinitelyJumpedSwitch bool + // At a loop body's terminal state, used to clear DR/DH/DE so that the + // surrounding scope's "definitely returns/halts/exits" claim is not + // over-claimed (the jumping path falls past the loop, not the function). + MaybeJumpedLoop bool // MaybeJumpedSwitch indicates that some path within the current switch // reached a `break` statement targeting that switch. // - // Unlike `DefinitelyJumpedSwitch`, this is OR-merged across branches and - // is not cleared by subsequent statements within the switch. It is used - // at a case body's terminal state to detect the pattern in which only - // some paths reach the case's trailing termination, e.g. + // OR-merged across branches and not cleared by subsequent statements + // within the switch. Scoped to the switch: cleared by `WithSwitch` on + // exit, since such breaks are consumed by the switch and are irrelevant + // outside it. + // + // At a case body's terminal state, used to clear DR/DH/DE so that the + // switch-level merge does not over-claim definite-return — the break + // path means the switch as a whole can still fall through to the code + // after it. For example: // // case x: // if cond { break } // return ... - // - // Such a case must not be treated as "definitely returns" when merging - // case results at the switch level, because the break path means the - // switch as a whole can still fall through to the code after it. - // - // Like `DefinitelyJumpedSwitch`, the flag is scoped to the switch and - // cleared by WithSwitch on exit. MaybeJumpedSwitch bool } @@ -149,14 +137,6 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * (thenReturnInfo.DefinitelyReturned && elseReturnInfo.DefinitelyReturned) - ri.DefinitelyJumpedLoop = ri.DefinitelyJumpedLoop || - (thenReturnInfo.DefinitelyJumpedLoop && - elseReturnInfo.DefinitelyJumpedLoop) - - ri.DefinitelyJumpedSwitch = ri.DefinitelyJumpedSwitch || - (thenReturnInfo.DefinitelyJumpedSwitch && - elseReturnInfo.DefinitelyJumpedSwitch) - ri.DefinitelyHalted = ri.DefinitelyHalted || (thenReturnInfo.DefinitelyHalted && elseReturnInfo.DefinitelyHalted) @@ -186,7 +166,7 @@ func (ri *ReturnInfo) Clone() *ReturnInfo { } func (ri *ReturnInfo) IsUnreachable() bool { - // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted || DefinitelyJumpedLoop || DefinitelyJumpedSwitch, + // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, // see DefinitelyExited return ri.DefinitelyExited } diff --git a/sema/switch_test.go b/sema/switch_test.go index 07c820a307..47fa844343 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -848,8 +848,9 @@ func TestCheckSwitchUnreachableAfterMixedExits(t *testing.T) { // Inside a case body, both branches of an `if-else` exit, // but in different ways: the `then` branch breaks from the switch, // while the `else` branch returns from the function. - // Neither DefinitelyReturned nor DefinitelyJumpedSwitch holds for both branches, - // but every path has exited, so subsequent statements must be reported as unreachable. + // Neither branch alone gives DefinitelyReturned, but every path has + // exited (DefinitelyExited holds via AND-merge), so subsequent + // statements must be reported as unreachable. _, err := ParseAndCheck(t, ` fun test() { switch 1 { diff --git a/sema/while_test.go b/sema/while_test.go index b67689fd1c..ffc1e0df91 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -455,8 +455,9 @@ func TestCheckNestedWhileLoopMaybeJumpedDoesNotEscape(t *testing.T) { t.Parallel() - // A `MaybeJumpedLoop` set inside an inner while-loop body must not leak into the outer loop's body state. - // WithLoop save/restores both `DefinitelyJumpedLoop` and `MaybeJumpedLoop`. + // A `MaybeJumpedLoop` set inside an inner while-loop body must not leak + // into the outer loop's body state — `WithLoop` save/restores + // `MaybeJumpedLoop`. _, err := ParseAndCheck(t, ` fun test(): Int { From b45da139730ecf7226a6be36be2c6271a0e70ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 16:21:34 -0700 Subject: [PATCH 021/139] improve comments --- sema/check_invocation_expression.go | 10 +++++- sema/check_return_statement.go | 36 ++++++++++++++++---- sema/check_while.go | 36 ++++++++++++++++---- sema/checker.go | 52 +++++++++++++++++++++-------- sema/return_info.go | 26 +++++++++++---- 5 files changed, 127 insertions(+), 33 deletions(-) diff --git a/sema/check_invocation_expression.go b/sema/check_invocation_expression.go index 8b6c6f1aae..072b3618b3 100644 --- a/sema/check_invocation_expression.go +++ b/sema/check_invocation_expression.go @@ -209,7 +209,15 @@ func (checker *Checker) checkInvocationExpression(invocationExpression *ast.Invo checker.checkMemberInvocationResourceInvalidation(invokedExpression) - // Update the return info for invocations that do not return (i.e. have a `Never` return type) + // Update the return info for invocations that do not return + // (i.e. have a `Never` return type — a "halt", e.g. `panic(...)`). + // + // NOTE: unlike `VisitReturnStatement`, no explicit + // `checkResourceLoss` call here. A halting function intentionally + // does NOT lead to a resource-loss error: the program aborts, so + // the "leak" never materializes. The scope-leave skip in + // `checkResourceLoss` observes `DefinitelyExited` (with no + // `MaybeJumped*` set) and skips, preserving that. if returnType == NeverType { returnInfo := checker.functionActivations.Current().ReturnInfo diff --git a/sema/check_return_statement.go b/sema/check_return_statement.go index b83fe2adc4..85d5822c4e 100644 --- a/sema/check_return_statement.go +++ b/sema/check_return_statement.go @@ -24,13 +24,37 @@ func (checker *Checker) VisitReturnStatement(statement *ast.ReturnStatement) (_ functionActivation := checker.functionActivations.Current() defer func() { - // NOTE: check for resource loss before declaring the function - // as having definitely returned. + // Check for resource loss at the return site, BEFORE marking + // the function as having definitely exited. // - // Check all variables declared *inside* of the function. - // The function activation's value activation depth is where the *function* is declared ("parent scope"), - // and two value activation scopes are defined for the function itself: for the parameters and the body. - + // This is the only termination kind that calls `checkResourceLoss` + // explicitly. The reason is twofold: + // + // 1. Reporting at the return statement (rather than at the + // end-of-function scope-leave) makes the error point at the + // return — the source location the programmer expects. + // + // 2. Return is a single-site termination: the value scopes + // being abandoned are unambiguous, and there are no sibling + // branches that might each try to report the same leak. + // By contrast, break/continue can occur in multiple sibling + // branches (e.g. `if cond { break } else { break }`) whose + // resource scopes are cloned independently — having them + // report at their site would double-report. Halt + // intentionally suppresses leak reports (see the skip in + // `checkResourceLoss`). + // + // `checkResourceLoss` checks all variables declared inside the + // function. The function activation's `ValueActivationDepth` is + // where the *function* is declared (its parent scope); two + // value-activation scopes are defined for the function itself + // (parameters, then body), so `+ 1` covers both. + // + // The check runs BEFORE `DefinitelyExited` is set, so the + // scope-leave skip in `checkResourceLoss` does not yet + // suppress it. After this point, `DefinitelyExited = true` + // causes subsequent scope-leaves to skip and avoid + // double-reporting. checker.checkResourceLoss(functionActivation.ValueActivationDepth + 1) functionActivation.ReturnInfo.MaybeReturned = true functionActivation.ReturnInfo.DefinitelyReturned = true diff --git a/sema/check_while.go b/sema/check_while.go index d83be78cdb..78d2a29525 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -74,11 +74,28 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st functionActivation.ReturnInfo.MaybeJumpedSwitch = true } - // `break` is a kind of definite exit (see DefinitelyExited). - // Set it so that an if-else where one branch breaks - // and the other returns/halts/jumps still propagates as "definitely terminated". + // `break` is a kind of definite exit (see `DefinitelyExited`). + // Setting it means a subsequent statement is correctly reported as + // unreachable, and an if-else where one branch breaks and the other + // returns/halts/jumps still propagates as "every path terminated". functionActivation.ReturnInfo.DefinitelyExited = true + // NOTE: unlike `VisitReturnStatement`, no explicit `checkResourceLoss` + // call here. + // + // Break can occur in multiple sibling branches (e.g. + // `if cond { break } else { break }`) whose resource scopes are + // cloned independently. If each break reported leaks at its site, + // the same resource would be reported twice — once per branch — and + // the clones share no "already-reported" state to deduplicate. + // + // Instead, the leak is reported by the surrounding scope's + // `leaveValueScope` → `checkResourceLoss`. After the if-else merge, + // the merged resource state reflects both branches, and the scope- + // leave runs the check exactly once because the + // `MaybeJumpedLoop`/`MaybeJumpedSwitch` flag tells the skip + // condition NOT to skip (see `checkResourceLoss`). + return } @@ -103,10 +120,17 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) functionActivation.ReturnInfo.MaybeJumpedLoop = true - // `continue` is a kind of definite exit (see DefinitelyExited). - // Set it so that an if-else where one branch continues - // and the other returns/halts/jumps still propagates as "definitely terminated". + // `continue` is a kind of definite exit (see `DefinitelyExited`). + // Setting it means a subsequent statement is correctly reported as + // unreachable, and an if-else where one branch continues and the + // other returns/halts/jumps still propagates as "every path + // terminated". functionActivation.ReturnInfo.DefinitelyExited = true + // NOTE: like `break` and unlike `VisitReturnStatement`, no explicit + // `checkResourceLoss` here — the surrounding scope's + // `leaveValueScope` reports the leak. See the matching note in + // `VisitBreakStatement`. + return } diff --git a/sema/checker.go b/sema/checker.go index 4bcfdbecb2..65093e2d98 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1473,25 +1473,51 @@ func (checker *Checker) leaveValueScope(getEndPosition EndPositionGetter, checkR // when detecting resource use after invalidation in loops // checkResourceLoss reports an error if there is a variable in the current scope -// that has a resource type and which was not moved or destroyed +// that has a resource type and which was not moved or destroyed. +// +// Called from two places: +// - explicitly by `VisitReturnStatement` at the return site, so the +// reported leak points at the `return`; +// - implicitly by `leaveValueScope` whenever a block scope ends. +// +// The skip condition below resolves the interaction between those two +// sites so each leak is reported exactly once, and matches the +// historical halt-suppression behavior. func (checker *Checker) checkResourceLoss(depth int) { returnInfo := checker.functionActivations.Current().ReturnInfo - // Skip the check only if the function has definitely exited - // (i.e. via a `return` statement, or via a definite halt such as `panic(...)`): + // Skip only when control flow has definitely exited via return or + // halt — that is, `DefinitelyExited` is set, AND no path through + // the current scope reached a `break`/`continue`. + // + // `DefinitelyExited` is set by every termination kind (return, halt, + // break, continue) and AND-merged across branches, so by itself it + // captures "every path terminated somehow". The skip narrows that + // to "via return or halt": + // + // - return: `VisitReturnStatement` already called + // `checkResourceLoss` at the return site. Skipping here + // prevents the surrounding scope-leave from re-reporting the + // same leak. // - // - `return` invokes `checkResourceLoss` itself before marking the function - // as exited, so the variables it would catch have already been reported. - // - A definite halt intentionally does not lead to a resource-loss error, - // because the program would terminate with an error. + // - halt (a call returning `Never`, e.g. `panic(...)`): leak + // reporting is intentionally suppressed — the program aborts, + // so the resource "leak" never materializes. (Historical + // behavior — see e.g. the "panic statement" test in + // `TestCheckResourceInvalidationOfCopy`.) // - // In particular, the check must NOT be skipped after `break` / `continue` - // (`DefinitelyJumped` / `DefinitelyJumpedSwitch`): those statements do not - // invalidate resources, so resources declared inside the loop body or switch - // case but not moved/destroyed before the jump must still be reported when - // the enclosing scope is left. - if returnInfo.DefinitelyExited { + // - break / continue: do NOT skip. They set `DefinitelyExited` + // (so subsequent statements are correctly unreachable) and a + // corresponding `MaybeJumped*` flag, but they do NOT report at + // their site (multi-branch double-report problem; see + // `VisitBreakStatement`). Resources declared in the loop/switch + // case body that are not destroyed before the jump are real + // leaks that this scope-leave must report — so when a + // `MaybeJumped*` is set, do not skip. + if returnInfo.DefinitelyExited && + !returnInfo.MaybeJumpedLoop && + !returnInfo.MaybeJumpedSwitch { return } diff --git a/sema/return_info.go b/sema/return_info.go index a26be6a1aa..307b394746 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -48,8 +48,9 @@ type ReturnInfo struct { // - `break` (targeting a loop or a switch), or // - `continue`. // - // This is the generic "every path terminated" flag - // and is the one observed by `IsUnreachable()`. + // This is the generic "every path terminated" flag and is the one + // observed by `IsUnreachable()` to decide whether subsequent + // statements are reachable. // // NOTE: It is intentionally NOT just `DefinitelyReturned || DefinitelyHalted`, // because AND-merging each kind-specific flag separately would lose @@ -77,6 +78,14 @@ type ReturnInfo struct { // The same logic applies to `if break else return`, // `if continue else return`, etc. // + // Because `DefinitelyExited` is broadened to also include break and + // continue, it can be true even when the function itself has not + // exited (the break/continue exits only the enclosing loop/switch). + // Sites that care specifically about "did the function exit" (notably + // `checkResourceLoss`) recover that by checking + // `DefinitelyExited && !MaybeJumpedLoop && !MaybeJumpedSwitch` — + // see `checkResourceLoss` for the rationale. + // // At a case body's terminal state and at a loop body's terminal state, // `DefinitelyExited` is cleared alongside `DefinitelyReturned` and `DefinitelyHalted` // when a corresponding `MaybeJumped*` is true, @@ -148,12 +157,15 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * ri.mergeJumpOffsets(thenReturnInfo, elseReturnInfo) } +// mergeJumpOffsets propagates the jump offsets recorded in either branch +// up into the merged ReturnInfo. Each branch's `ReturnInfo.Clone()` makes +// its own child JumpOffsets set, so jumps in one branch do not pollute +// the sibling branch's view (which would incorrectly downgrade +// invalidations there, since `maybeAddResourceInvalidation` uses jump +// offsets to detect "a jump occurred between declaration and use"). +// After the conditional, both branches' jumps are potential from the +// perspective of subsequent code, so we OR them into the parent. func (ri *ReturnInfo) mergeJumpOffsets(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { - // Propagate jump offsets recorded in either branch. - // Each branch has its own cloned JumpOffsets set (see Clone), - // so jumps in one branch do not contaminate the sibling branch's view. - // After the conditional, both branches' jumps are potential - // from the perspective of code that follows. addAll := func(other *ReturnInfo) { _ = other.JumpOffsets.ForEach(func(offset int) error { ri.JumpOffsets.Add(offset) From d0e808b7f26b77a068aee78088bc94b97044d61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 16:45:45 -0700 Subject: [PATCH 022/139] document that checking resource loss at return is important for another reason simplifying to no resource loss check at return and only skip for DefinitelyHalted does NOT work --- sema/check_return_statement.go | 33 +++++++++++++++++++++------------ sema/checker.go | 3 ++- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/sema/check_return_statement.go b/sema/check_return_statement.go index 85d5822c4e..d15de81260 100644 --- a/sema/check_return_statement.go +++ b/sema/check_return_statement.go @@ -28,20 +28,29 @@ func (checker *Checker) VisitReturnStatement(statement *ast.ReturnStatement) (_ // the function as having definitely exited. // // This is the only termination kind that calls `checkResourceLoss` - // explicitly. The reason is twofold: + // explicitly. Three reasons: // - // 1. Reporting at the return statement (rather than at the + // 1. Resource state at return time. After the branches of an + // `if-else` where both sides return, `mergeResourceInfos` + // NO-OPs (neither invalidation is effective past the merge + // because neither branch falls through). The outer scope + // therefore loses the per-branch move/destroy information. + // Running `checkResourceLoss` HERE — while + // `checker.resources` is still the branch's cloned resource + // state — catches leaks that would be missed if we relied + // only on the surrounding scope-leave. + // + // 2. Reporting at the return statement (rather than at the // end-of-function scope-leave) makes the error point at the // return — the source location the programmer expects. // - // 2. Return is a single-site termination: the value scopes - // being abandoned are unambiguous, and there are no sibling - // branches that might each try to report the same leak. - // By contrast, break/continue can occur in multiple sibling - // branches (e.g. `if cond { break } else { break }`) whose - // resource scopes are cloned independently — having them - // report at their site would double-report. Halt - // intentionally suppresses leak reports (see the skip in + // 3. Return is a single-site termination: no sibling branches + // could each try to report the same leak. By contrast, + // break/continue can occur in multiple sibling branches + // (e.g. `if cond { break } else { break }`) whose resource + // scopes are cloned independently — having them report at + // their site would double-report. Halt intentionally + // suppresses leak reports (see the skip in // `checkResourceLoss`). // // `checkResourceLoss` checks all variables declared inside the @@ -53,8 +62,8 @@ func (checker *Checker) VisitReturnStatement(statement *ast.ReturnStatement) (_ // The check runs BEFORE `DefinitelyExited` is set, so the // scope-leave skip in `checkResourceLoss` does not yet // suppress it. After this point, `DefinitelyExited = true` - // causes subsequent scope-leaves to skip and avoid - // double-reporting. + // causes the surrounding scope-leave to skip (via the + // `DE && !MJL && !MJS` condition) and avoid double-reporting. checker.checkResourceLoss(functionActivation.ValueActivationDepth + 1) functionActivation.ReturnInfo.MaybeReturned = true functionActivation.ReturnInfo.DefinitelyReturned = true diff --git a/sema/checker.go b/sema/checker.go index 65093e2d98..2587e91e0c 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1477,7 +1477,8 @@ func (checker *Checker) leaveValueScope(getEndPosition EndPositionGetter, checkR // // Called from two places: // - explicitly by `VisitReturnStatement` at the return site, so the -// reported leak points at the `return`; +// reported leak points at the `return` and the per-branch resource +// state is still available (see the comment there); // - implicitly by `leaveValueScope` whenever a block scope ends. // // The skip condition below resolves the interaction between those two From 6e3f9420c5b841f26f1c6fea67855e1a3b17b003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 16:50:04 -0700 Subject: [PATCH 023/139] add MaybeJumped --- sema/checker.go | 7 ++----- sema/return_info.go | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/sema/checker.go b/sema/checker.go index 2587e91e0c..590caedc6d 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1516,9 +1516,7 @@ func (checker *Checker) checkResourceLoss(depth int) { // case body that are not destroyed before the jump are real // leaks that this scope-leave must report — so when a // `MaybeJumped*` is set, do not skip. - if returnInfo.DefinitelyExited && - !returnInfo.MaybeJumpedLoop && - !returnInfo.MaybeJumpedSwitch { + if returnInfo.DefinitelyExited && !returnInfo.MaybeJumped() { return } @@ -2728,8 +2726,7 @@ func (checker *Checker) maybeAddResourceInvalidation(resource Resource, invalida switch { case resource.Member != nil: onlyPotential = returnInfo.MaybeReturned || - returnInfo.MaybeJumpedLoop || - returnInfo.MaybeJumpedSwitch + returnInfo.MaybeJumped() case resource.Variable != nil && resource.Variable.DeclarationKind != common.DeclarationKindSelf: diff --git a/sema/return_info.go b/sema/return_info.go index 307b394746..e295b31a8b 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -83,8 +83,8 @@ type ReturnInfo struct { // exited (the break/continue exits only the enclosing loop/switch). // Sites that care specifically about "did the function exit" (notably // `checkResourceLoss`) recover that by checking - // `DefinitelyExited && !MaybeJumpedLoop && !MaybeJumpedSwitch` — - // see `checkResourceLoss` for the rationale. + // `DefinitelyExited && !MaybeJumped()` — see `checkResourceLoss` + // for the rationale. // // At a case body's terminal state and at a loop body's terminal state, // `DefinitelyExited` is cleared alongside `DefinitelyReturned` and `DefinitelyHalted` @@ -205,6 +205,19 @@ func (ri *ReturnInfo) IsUnreachable() bool { return ri.DefinitelyExited } +// MaybeJumped reports whether some path within the current loop or +// switch reached a `break` or `continue`. It is the union of +// `MaybeJumpedLoop` and `MaybeJumpedSwitch`. +// +// Used to distinguish "function exited via return/halt" (no maybe-jump) +// from "construct exited via break/continue" (maybe-jump set), notably +// in `checkResourceLoss` (to decide whether to skip) and in +// `maybeAddResourceInvalidation` (to decide whether a member resource +// invalidation is only potential). +func (ri *ReturnInfo) MaybeJumped() bool { + return ri.MaybeJumpedLoop || ri.MaybeJumpedSwitch +} + func (ri *ReturnInfo) AddJumpOffset(offset int) { ri.JumpOffsets.Add(offset) } From 10718db001efc83fea944bb55a3618b82902a6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 20 May 2026 17:07:01 -0700 Subject: [PATCH 024/139] consolidated case-body clearing --- sema/check_return_statement.go | 2 +- sema/check_switch.go | 18 -------------- sema/function_activations.go | 33 +++++++++++++------------ sema/return_info.go | 44 +++++++++++++++++++--------------- 4 files changed, 42 insertions(+), 55 deletions(-) diff --git a/sema/check_return_statement.go b/sema/check_return_statement.go index d15de81260..310dc8e1ed 100644 --- a/sema/check_return_statement.go +++ b/sema/check_return_statement.go @@ -63,7 +63,7 @@ func (checker *Checker) VisitReturnStatement(statement *ast.ReturnStatement) (_ // scope-leave skip in `checkResourceLoss` does not yet // suppress it. After this point, `DefinitelyExited = true` // causes the surrounding scope-leave to skip (via the - // `DE && !MJL && !MJS` condition) and avoid double-reporting. + // `DE && !MaybeJumped()` condition) and avoid double-reporting. checker.checkResourceLoss(functionActivation.ValueActivationDepth + 1) functionActivation.ReturnInfo.MaybeReturned = true functionActivation.ReturnInfo.DefinitelyReturned = true diff --git a/sema/check_switch.go b/sema/check_switch.go index c37c7df817..5cfd1cedc6 100644 --- a/sema/check_switch.go +++ b/sema/check_switch.go @@ -20,7 +20,6 @@ package sema import ( "github.com/onflow/cadence/ast" - "github.com/onflow/cadence/errors" ) func (checker *Checker) VisitSwitchStatement(statement *ast.SwitchStatement) (_ struct{}) { @@ -188,21 +187,4 @@ func (checker *Checker) checkSwitchCaseStatements(switchCase *ast.SwitchCase) { ), ) checker.checkBlock(block) - - // If any path within this case body broke out of the switch, - // the case cannot be treated as definitely returning/halting/exiting: - // on the break path control falls through past the switch without - // reaching a terminating statement. - // Clear the corresponding definite flags, - // so the switch-level merge sees an accurate per-case result - functionActivation := checker.functionActivations.Current() - if functionActivation == nil { - panic(errors.NewUnreachableError()) - } - returnInfo := functionActivation.ReturnInfo - if returnInfo.MaybeJumpedSwitch { - returnInfo.DefinitelyReturned = false - returnInfo.DefinitelyHalted = false - returnInfo.DefinitelyExited = false - } } diff --git a/sema/function_activations.go b/sema/function_activations.go index 09a3acd452..cb3f8a3545 100644 --- a/sema/function_activations.go +++ b/sema/function_activations.go @@ -61,38 +61,37 @@ func (a *FunctionActivation) popControl() { a.ControlStack = a.ControlStack[:lastIndex] } +// WithLoop runs f within a new loop scope. `MaybeJumpedLoop` is +// save/restored — break/continue targeting this loop are consumed by it +// and must not leak. If any path jumped, the body's DR/DH/DE are +// cleared (they over-claim — the jumping path didn't terminate the +// function). func (a *FunctionActivation) WithLoop(f func()) { a.pushControl(ControlKindLoop) - // MaybeJumpedLoop is scoped to this loop: save on entry, restore on exit, - // so a nested loop's break/continue does not affect an enclosing loop. - // `break` and `continue` statements targeting this loop - // are consumed by the loop and must not leak to an enclosing scope. savedMaybeJumpedLoop := a.ReturnInfo.MaybeJumpedLoop a.ReturnInfo.WithNewJumpTarget(f) - // If any path within the loop body jumped (break/continue), - // the body did not definitely return/halt/exit on every path: - // clear the corresponding flags so subsequent reasoning sees an - // accurate per-body result. if a.ReturnInfo.MaybeJumpedLoop { - a.ReturnInfo.DefinitelyReturned = false - a.ReturnInfo.DefinitelyHalted = false - a.ReturnInfo.DefinitelyExited = false + a.ReturnInfo.clearDefiniteExits() } a.ReturnInfo.MaybeJumpedLoop = savedMaybeJumpedLoop a.popControl() } +// WithSwitch runs f within a new switch scope. Mirrors `WithLoop`: +// `MaybeJumpedSwitch` is save/restored (break targeting this switch is +// consumed by it; continue targets the enclosing loop and propagates +// past via `MaybeJumpedLoop`). If any case body broke, clear DR/DH/DE +// — the break path falls past the switch without terminating the +// function, so the merged "every case definitely terminated" claim +// would over-promise. func (a *FunctionActivation) WithSwitch(f func()) { // NOTE: new jump-offsets child-set for each case instead of whole switch a.pushControl(ControlKindSwitch) - // `break` statements inside a switch only target the switch, not any enclosing loop. - // MaybeJumpedSwitch is scoped to this switch: save on entry, restore on exit, - // so a nested switch's break does not affect an enclosing switch. - // (`continue` statements always target the enclosing loop, - // so it sets MaybeJumpedLoop (not MaybeJumpedSwitch) - // and therefore propagates past the switch.) savedMaybeJumpedSwitch := a.ReturnInfo.MaybeJumpedSwitch f() + if a.ReturnInfo.MaybeJumpedSwitch { + a.ReturnInfo.clearDefiniteExits() + } a.ReturnInfo.MaybeJumpedSwitch = savedMaybeJumpedSwitch a.popControl() } diff --git a/sema/return_info.go b/sema/return_info.go index e295b31a8b..4ec3b53955 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -154,27 +154,22 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * (thenReturnInfo.DefinitelyExited && elseReturnInfo.DefinitelyExited) - ri.mergeJumpOffsets(thenReturnInfo, elseReturnInfo) + // Propagate jump offsets from both branches. Each branch's + // `Clone()` gave it a separate child JumpOffsets set, so jumps in + // one branch did not pollute the sibling's view of "did a jump + // occur between this resource's declaration and its use?" + // (see `maybeAddResourceInvalidation`). Now that both branches + // are joining back into the parent, their jumps are potential + // from the perspective of subsequent code. + ri.addJumpOffsetsFrom(thenReturnInfo) + ri.addJumpOffsetsFrom(elseReturnInfo) } -// mergeJumpOffsets propagates the jump offsets recorded in either branch -// up into the merged ReturnInfo. Each branch's `ReturnInfo.Clone()` makes -// its own child JumpOffsets set, so jumps in one branch do not pollute -// the sibling branch's view (which would incorrectly downgrade -// invalidations there, since `maybeAddResourceInvalidation` uses jump -// offsets to detect "a jump occurred between declaration and use"). -// After the conditional, both branches' jumps are potential from the -// perspective of subsequent code, so we OR them into the parent. -func (ri *ReturnInfo) mergeJumpOffsets(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { - addAll := func(other *ReturnInfo) { - _ = other.JumpOffsets.ForEach(func(offset int) error { - ri.JumpOffsets.Add(offset) - return nil - }) - } - - addAll(thenReturnInfo) - addAll(elseReturnInfo) +func (ri *ReturnInfo) addJumpOffsetsFrom(other *ReturnInfo) { + _ = other.JumpOffsets.ForEach(func(offset int) error { + ri.JumpOffsets.Add(offset) + return nil + }) } func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInfo) { @@ -218,6 +213,17 @@ func (ri *ReturnInfo) MaybeJumped() bool { return ri.MaybeJumpedLoop || ri.MaybeJumpedSwitch } +// clearDefiniteExits clears the kind-specific "definitely exited" +// flags. Used by `WithLoop`/`WithSwitch` when a path through the +// construct took a break/continue — the construct as a whole did NOT +// terminate the function on every path, so a "definitely returned/ +// halted/exited" claim would over-promise. +func (ri *ReturnInfo) clearDefiniteExits() { + ri.DefinitelyReturned = false + ri.DefinitelyHalted = false + ri.DefinitelyExited = false +} + func (ri *ReturnInfo) AddJumpOffset(offset int) { ri.JumpOffsets.Add(offset) } From e071b494b714b840006632ec9577f274b71fef10 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Wed, 20 May 2026 19:09:58 -0700 Subject: [PATCH 025/139] Add more tests --- sema/for_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/sema/for_test.go b/sema/for_test.go index 648ad78b9c..66dd75d7b9 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -963,6 +963,31 @@ func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { assert.IsType(t, &sema.ResourceLossError{}, errs[0]) }) + t.Run("if destroy else break", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the then path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + destroy r + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + t.Run("if destroy else destroy break", func(t *testing.T) { t.Parallel() @@ -987,6 +1012,28 @@ func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { require.NoError(t, err) }) + t.Run("nested if break in inner if", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + if true { + break + } + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + t.Run("resource outside loop, if break", func(t *testing.T) { t.Parallel() @@ -1010,3 +1057,80 @@ func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { require.NoError(t, err) }) } + +func TestCheckResourceInvalidationInNestedForLoops(t *testing.T) { + + t.Parallel() + + t.Run("break in inner loop, destroy in outer", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the outer loop and destroyed there. + // The inner `break` only exits the inner loop, so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + for j in [2] { + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("resource in inner loop, inner break", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the inner loop body and the `break` exits it + // without destroying `r`. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + for j in [2] { + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("outer break inside inner loop", func(t *testing.T) { + t.Parallel() + + // `break` always targets the innermost loop, + // so the inner `break` only exits the inner loop. + // The outer loop's `r` is destroyed at the end of each outer iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + for j in [2] { + if true { + break + } + } + destroy r + } + } + `) + + require.NoError(t, err) + }) +} From 263c85427a276fdeda6ebb246daecbb819383bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 08:19:47 -0700 Subject: [PATCH 026/139] handle division by zero in [U]Fix128 saturatingDivide --- interpreter/arithmetic_test.go | 52 ++++++++++++++++++++++++++++++++++ interpreter/value_fix128.go | 2 ++ interpreter/value_ufix128.go | 2 ++ 3 files changed, 56 insertions(+) diff --git a/interpreter/arithmetic_test.go b/interpreter/arithmetic_test.go index 6fd7bd040d..ae3e0b496d 100644 --- a/interpreter/arithmetic_test.go +++ b/interpreter/arithmetic_test.go @@ -1032,3 +1032,55 @@ func TestInterpretDivisionByZero(t *testing.T) { }) } } + +func TestInterpretSaturatingDivisionByZero(t *testing.T) { + t.Parallel() + + numericTypes := common.Concat( + sema.AllUnsignedIntegerTypes, + sema.AllSignedIntegerTypes, + sema.AllUnsignedFixedPointTypes, + sema.AllSignedFixedPointTypes, + ) + + for _, typ := range numericTypes { + typ := typ + + saturatingType, ok := typ.(sema.SaturatingArithmeticType) + if !ok || !saturatingType.SupportsSaturatingDivide() { + continue + } + + typeName := typ.String() + + t.Run(typeName, func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, + fmt.Sprintf( + ` + fun test(): %[1]s { + return %[1]s(4).saturatingDivide(%[1]s(0)) + } + `, + typeName, + ), + ) + + _, err := inter.Invoke("test") + RequireError(t, err) + + require.True(t, errors.IsUserError(err)) + + switch typ { + case sema.IntType: + // Int is implemented in the values package. + divisionByZero := values.DivisionByZeroError{} + require.ErrorAs(t, err, &divisionByZero) + default: + divisionByZero := &interpreter.DivisionByZeroError{} + require.ErrorAs(t, err, &divisionByZero) + } + }) + } +} diff --git a/interpreter/value_fix128.go b/interpreter/value_fix128.go index b04a62a333..68714c4767 100644 --- a/interpreter/value_fix128.go +++ b/interpreter/value_fix128.go @@ -670,6 +670,8 @@ func fix128SaturationArithmaticResult( return fix.Fix128Min case fix.UnderflowError: return fix.Fix128Zero + case fix.DivisionByZeroError: + panic(&DivisionByZeroError{}) default: panic(err) } diff --git a/interpreter/value_ufix128.go b/interpreter/value_ufix128.go index fffbe4f5ee..3f2518b6a4 100644 --- a/interpreter/value_ufix128.go +++ b/interpreter/value_ufix128.go @@ -636,6 +636,8 @@ func ufix128SaturationArithmaticResult( return fixedpoint.UFix128TypeMin case fix.UnderflowError: return fix.UFix128Zero + case fix.DivisionByZeroError: + panic(&DivisionByZeroError{}) default: panic(err) } From 0dda878843973a0a9f78a0a0826935f3bc924c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 10:10:19 -0700 Subject: [PATCH 027/139] track composite value ID at creation time --- interpreter/atree_validation_disabled_test.go | 202 ++++++++++++++++++ interpreter/value_composite.go | 15 +- 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 interpreter/atree_validation_disabled_test.go diff --git a/interpreter/atree_validation_disabled_test.go b/interpreter/atree_validation_disabled_test.go new file mode 100644 index 0000000000..abdc2a9701 --- /dev/null +++ b/interpreter/atree_validation_disabled_test.go @@ -0,0 +1,202 @@ +package interpreter_test + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/onflow/cadence/activations" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" + "github.com/onflow/cadence/stdlib" + "github.com/onflow/cadence/test_utils" + . "github.com/onflow/cadence/test_utils/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" +) + +func TestAtreeSlabSplit(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) entitlement Withdraw + access(all) resource Vault { + access(all) var balance: UFix64 + + init(balance: UFix64) { + self.balance = balance + } + + access(Withdraw) fun withdraw(amount: UFix64): @Vault { + self.balance = self.balance - amount + return <- create Vault(balance: amount) + } + + access(all) fun deposit(from: @Vault) { + self.balance = self.balance + from.balance + destroy from + } + } + + access(all) attachment A1 for Vault { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for Vault { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for Vault { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for Vault { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for Vault { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for Vault { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + access(all) fun double(_ original: @Vault): @Vault { + let empty <- original.withdraw(amount: 0.0) + let stash <- original.withdraw(amount: 0.0) + + // Preparatory step: Attach a bunch of small and empty attachments + let r1 <- attach A1() to <-original + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r <- attach A6() to <-r5 + + // Create two EphemeralReferenceValues pointing to two different CompositeValues + // which point to the same underlying dictionary + var arr: @[Vault] <- [<-r] + let ref = &arr[0] as auth(Withdraw) &Vault + let ref2 = &arr[0] as auth(Withdraw) &Vault + + + // Trigger an atree slab split on the underlying dictionary of Vault + // by "inflating" those attachments + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + // At this point, the CompositeValue inside "ref" has been properly updated such + // that its dictionary.root points to the newly created root slab. However, + // the CompositeValue inside "ref2" did not get updated and its dictionary.root + // still points to the old slab which is no longer the root node. The atree slab + // split reassigned slab IDs such that the OLD root (still pointed to by ref2's + // dictionary) now has a different slab ID, while the NEW root inherits the + // original slab ID. + + // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. + // Without the stable cached valueID on CompositeValue, this reference would be + // tracked under the (now-different) live ValueID of the stale view, which would + // let it bypass invalidation when the vault is moved. + let immortalRef = (ref2 as auth(Withdraw) &AnyResource) as! auth(Withdraw) &Vault + // Move the vault. Reference invalidation must void "immortalRef" alongside + // "ref" and "ref2": the cached valueID ensures all three are tracked under the + // same stable ID. + var extracted <- arr[0] <- empty + + stash.deposit(from: <- extracted) + // This second withdraw must panic with InvalidatedResourceReferenceError, + // because immortalRef was invalidated when the vault was moved above. + stash.deposit(from: <- immortalRef.withdraw(amount: immortalRef.balance)) + + destroy arr + return <- stash + } + + access(all) fun main() { + let original <- create Vault(balance: 100.0) + let second <- double(<- double(<- original)) + log("Succesfully withdrawn: \(second.balance)") + destroy second + } + `, + test_utils.ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + // After an atree slab split, a stale view of the resource's dictionary can + // produce a different live ValueID than the original. Without the cached + // valueID on CompositeValue, an EphemeralReferenceValue created from such + // a stale view would register under a different ID, bypassing invalidation + // when the resource is moved, and allow the balance to be withdrawn twice. + // The cached valueID ensures the stale reference is invalidated alongside + // the others, so the second withdraw must fail. + _, err = inter.Invoke("main") + RequireError(t, err) + var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError + assert.ErrorAs(t, err, &invalidatedResourceReferenceError) +} diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 3c9391d145..885767a8bb 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -53,6 +53,17 @@ type CompositeValue struct { dictionary *atree.OrderedMap typeID TypeID + // valueID is the atree value ID captured at construction time. + // It is used for reference tracking and invalidation, and must remain + // stable even if the dictionary's runtime root slab ID changes. + // Specifically, when multiple CompositeValue instances share the same + // underlying atree map (e.g. created by repeated element access via + // `arr[i]`), an atree slab split through one instance reassigns the + // other instance's root pointer's slab ID. Using the dictionary's live + // ValueID in that scenario would let a stale view register a reference + // under a different ID, bypassing invalidation. + valueID atree.ValueID + // attachments also have a reference to their base value. This field is set in three cases: // 1) when an attachment `A` is accessed off `v` using `v[A]`, this is set to `&v` // 2) When a resource `r`'s destructor is invoked, all of `r`'s attachments' destructors will also run, and @@ -250,6 +261,7 @@ func NewCompositeValueFromAtreeMap( return &CompositeValue{ dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), Location: typeInfo.Location, QualifiedIdentifier: typeInfo.QualifiedIdentifier, Kind: typeInfo.Kind, @@ -1620,6 +1632,7 @@ func (v *CompositeValue) Clone(context ValueCloneContext) Value { return &CompositeValue{ dictionary: dictionary, + valueID: dictionary.ValueID(), Location: v.Location, QualifiedIdentifier: v.QualifiedIdentifier, Kind: v.Kind, @@ -1796,7 +1809,7 @@ func (v *CompositeValue) StorageAddress() atree.Address { } func (v *CompositeValue) ValueID() atree.ValueID { - return v.dictionary.ValueID() + return v.valueID } func (v *CompositeValue) RemoveField( From 0e326baf7ff407c58e6c559858016376f7389f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 10:18:18 -0700 Subject: [PATCH 028/139] move test --- interpreter/atree_validation_disabled_test.go | 202 ------------------ interpreter/composite_value_test.go | 185 ++++++++++++++++ 2 files changed, 185 insertions(+), 202 deletions(-) delete mode 100644 interpreter/atree_validation_disabled_test.go diff --git a/interpreter/atree_validation_disabled_test.go b/interpreter/atree_validation_disabled_test.go deleted file mode 100644 index abdc2a9701..0000000000 --- a/interpreter/atree_validation_disabled_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package interpreter_test - -import ( - "fmt" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/onflow/cadence/activations" - "github.com/onflow/cadence/common" - "github.com/onflow/cadence/interpreter" - "github.com/onflow/cadence/sema" - "github.com/onflow/cadence/stdlib" - "github.com/onflow/cadence/test_utils" - . "github.com/onflow/cadence/test_utils/common_utils" - . "github.com/onflow/cadence/test_utils/sema_utils" -) - -func TestAtreeSlabSplit(t *testing.T) { - t.Parallel() - - logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { - fmt.Fprintln(os.Stderr, message) - return nil - })) - - baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) - baseValueActivation.DeclareValue(logFunction) - - baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) - interpreter.Declare(baseActivation, logFunction) - - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( - t, - ` - access(all) entitlement Withdraw - access(all) resource Vault { - access(all) var balance: UFix64 - - init(balance: UFix64) { - self.balance = balance - } - - access(Withdraw) fun withdraw(amount: UFix64): @Vault { - self.balance = self.balance - amount - return <- create Vault(balance: amount) - } - - access(all) fun deposit(from: @Vault) { - self.balance = self.balance + from.balance - destroy from - } - } - - access(all) attachment A1 for Vault { - access(all) var a1: String; access(all) var a2: String - access(all) var a3: String; access(all) var a4: String - init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } - access(all) fun inflate() { - self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 - } - } - access(all) attachment A2 for Vault { - access(all) var b1: String; access(all) var b2: String - access(all) var b3: String; access(all) var b4: String - init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } - access(all) fun inflate() { - self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 - } - } - access(all) attachment A3 for Vault { - access(all) var d1: String; access(all) var d2: String - access(all) var d3: String; access(all) var d4: String - init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } - access(all) fun inflate() { - self.d1 = "dddddddddddddddddddddddddddddddddddddd" - self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 - } - } - access(all) attachment A4 for Vault { - access(all) var e1: String; access(all) var e2: String - access(all) var e3: String; access(all) var e4: String - init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } - access(all) fun inflate() { - self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 - } - } - access(all) attachment A5 for Vault { - access(all) var g1: String; access(all) var g2: String - access(all) var g3: String; access(all) var g4: String - init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } - access(all) fun inflate() { - self.g1 = "gggggggggggggggggggggggggggggggggggggg" - self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 - } - } - access(all) attachment A6 for Vault { - access(all) var h1: String; access(all) var h2: String - access(all) var h3: String; access(all) var h4: String - init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } - access(all) fun inflate() { - self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" - self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 - } - } - - access(all) fun double(_ original: @Vault): @Vault { - let empty <- original.withdraw(amount: 0.0) - let stash <- original.withdraw(amount: 0.0) - - // Preparatory step: Attach a bunch of small and empty attachments - let r1 <- attach A1() to <-original - let r2 <- attach A2() to <-r1 - let r3 <- attach A3() to <-r2 - let r4 <- attach A4() to <-r3 - let r5 <- attach A5() to <-r4 - let r <- attach A6() to <-r5 - - // Create two EphemeralReferenceValues pointing to two different CompositeValues - // which point to the same underlying dictionary - var arr: @[Vault] <- [<-r] - let ref = &arr[0] as auth(Withdraw) &Vault - let ref2 = &arr[0] as auth(Withdraw) &Vault - - - // Trigger an atree slab split on the underlying dictionary of Vault - // by "inflating" those attachments - ref[A1]!.inflate() - ref[A2]!.inflate() - ref[A3]!.inflate() - ref[A4]!.inflate() - ref[A5]!.inflate() - ref[A6]!.inflate() - - // At this point, the CompositeValue inside "ref" has been properly updated such - // that its dictionary.root points to the newly created root slab. However, - // the CompositeValue inside "ref2" did not get updated and its dictionary.root - // still points to the old slab which is no longer the root node. The atree slab - // split reassigned slab IDs such that the OLD root (still pointed to by ref2's - // dictionary) now has a different slab ID, while the NEW root inherits the - // original slab ID. - - // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. - // Without the stable cached valueID on CompositeValue, this reference would be - // tracked under the (now-different) live ValueID of the stale view, which would - // let it bypass invalidation when the vault is moved. - let immortalRef = (ref2 as auth(Withdraw) &AnyResource) as! auth(Withdraw) &Vault - // Move the vault. Reference invalidation must void "immortalRef" alongside - // "ref" and "ref2": the cached valueID ensures all three are tracked under the - // same stable ID. - var extracted <- arr[0] <- empty - - stash.deposit(from: <- extracted) - // This second withdraw must panic with InvalidatedResourceReferenceError, - // because immortalRef was invalidated when the vault was moved above. - stash.deposit(from: <- immortalRef.withdraw(amount: immortalRef.balance)) - - destroy arr - return <- stash - } - - access(all) fun main() { - let original <- create Vault(balance: 100.0) - let second <- double(<- double(<- original)) - log("Succesfully withdrawn: \(second.balance)") - destroy second - } - `, - test_utils.ParseCheckAndInterpretOptions{ - ParseAndCheckOptions: &ParseAndCheckOptions{ - CheckerConfig: &sema.Config{ - BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { - return baseValueActivation - }, - }, - }, - InterpreterConfig: &interpreter.Config{ - BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { - return baseActivation - }, - }, - }, - ) - require.NoError(t, err) - - // After an atree slab split, a stale view of the resource's dictionary can - // produce a different live ValueID than the original. Without the cached - // valueID on CompositeValue, an EphemeralReferenceValue created from such - // a stale view would register under a different ID, bypassing invalidation - // when the resource is moved, and allow the balance to be withdrawn twice. - // The cached valueID ensures the stale reference is invalidated alongside - // the others, so the second withdraw must fail. - _, err = inter.Invoke("main") - RequireError(t, err) - var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) -} diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 9c4991e7cc..97c17911d8 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -20,6 +20,7 @@ package interpreter_test import ( "fmt" + "os" "testing" "github.com/stretchr/testify/assert" @@ -30,6 +31,7 @@ import ( "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" "github.com/onflow/cadence/stdlib" + "github.com/onflow/cadence/test_utils" . "github.com/onflow/cadence/test_utils/common_utils" . "github.com/onflow/cadence/test_utils/interpreter_utils" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -410,3 +412,186 @@ func TestInterpretSimpleCompositeTypeFunctionMember(t *testing.T) { require.ErrorAs(t, err, &dereferenceError) }) } + +func TestInterpretCompositeValueIDTracking(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) entitlement Withdraw + access(all) resource Vault { + access(all) var balance: UFix64 + + init(balance: UFix64) { + self.balance = balance + } + + access(Withdraw) fun withdraw(amount: UFix64): @Vault { + self.balance = self.balance - amount + return <- create Vault(balance: amount) + } + + access(all) fun deposit(from: @Vault) { + self.balance = self.balance + from.balance + destroy from + } + } + + access(all) attachment A1 for Vault { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for Vault { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for Vault { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for Vault { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for Vault { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for Vault { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + access(all) fun double(_ original: @Vault): @Vault { + let empty <- original.withdraw(amount: 0.0) + let stash <- original.withdraw(amount: 0.0) + + // Preparatory step: Attach a bunch of small and empty attachments + let r1 <- attach A1() to <-original + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r <- attach A6() to <-r5 + + // Create two EphemeralReferenceValues pointing to two different CompositeValues + // which point to the same underlying dictionary + var arr: @[Vault] <- [<-r] + let ref = &arr[0] as auth(Withdraw) &Vault + let ref2 = &arr[0] as auth(Withdraw) &Vault + + + // Trigger an atree slab split on the underlying dictionary of Vault + // by "inflating" those attachments + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + // At this point, the CompositeValue inside "ref" has been properly updated such + // that its dictionary.root points to the newly created root slab. However, + // the CompositeValue inside "ref2" did not get updated and its dictionary.root + // still points to the old slab which is no longer the root node. The atree slab + // split reassigned slab IDs such that the OLD root (still pointed to by ref2's + // dictionary) now has a different slab ID, while the NEW root inherits the + // original slab ID. + + // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. + // Without the stable cached valueID on CompositeValue, this reference would be + // tracked under the (now-different) live ValueID of the stale view, which would + // let it bypass invalidation when the vault is moved. + let immortalRef = (ref2 as auth(Withdraw) &AnyResource) as! auth(Withdraw) &Vault + // Move the vault. Reference invalidation must void "immortalRef" alongside + // "ref" and "ref2": the cached valueID ensures all three are tracked under the + // same stable ID. + var extracted <- arr[0] <- empty + + stash.deposit(from: <- extracted) + // This second withdraw must panic with InvalidatedResourceReferenceError, + // because immortalRef was invalidated when the vault was moved above. + stash.deposit(from: <- immortalRef.withdraw(amount: immortalRef.balance)) + + destroy arr + return <- stash + } + + access(all) fun main() { + let original <- create Vault(balance: 100.0) + let second <- double(<- double(<- original)) + log("Successfully withdrawn: \(second.balance)") + destroy second + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + // After an atree slab split, a stale view of the resource's dictionary can + // produce a different live ValueID than the original. Without the cached + // valueID on CompositeValue, an EphemeralReferenceValue created from such + // a stale view would register under a different ID, bypassing invalidation + // when the resource is moved, and allow the balance to be withdrawn twice. + // The cached valueID ensures the stale reference is invalidated alongside + // the others, so the second withdraw must fail. + _, err = inter.Invoke("main") + RequireError(t, err) + var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError + assert.ErrorAs(t, err, &invalidatedResourceReferenceError) +} From cfd26c8450acd44b44295896936dfc5b2ff4e727 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 21 May 2026 22:59:53 +0530 Subject: [PATCH 029/139] Revert "Skip resource loss check only if the block definitely-exited, but not jumped" --- sema/checker.go | 16 +- sema/for_test.go | 346 ------------------------------------------- sema/return_info.go | 22 --- sema/switch_test.go | 155 -------------------- sema/while_test.go | 347 -------------------------------------------- 5 files changed, 1 insertion(+), 885 deletions(-) diff --git a/sema/checker.go b/sema/checker.go index 5f870d8160..96ce032e6f 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1477,21 +1477,7 @@ func (checker *Checker) leaveValueScope(getEndPosition EndPositionGetter, checkR func (checker *Checker) checkResourceLoss(depth int) { returnInfo := checker.functionActivations.Current().ReturnInfo - - // Skip the check only if the function has definitely exited - // (i.e. via a `return` statement, or via a definite halt such as `panic(...)`): - // - // - `return` invokes `checkResourceLoss` itself before marking the function - // as exited, so the variables it would catch have already been reported. - // - A definite halt intentionally does not lead to a resource-loss error, - // because the program would terminate with an error. - // - // In particular, the check must NOT be skipped after `break` / `continue` - // (`DefinitelyJumped` / `DefinitelyJumpedSwitch`): those statements do not - // invalidate resources, so resources declared inside the loop body or switch - // case but not moved/destroyed before the jump must still be reported when - // the enclosing scope is left. - if returnInfo.DefinitelyExited { + if returnInfo.IsUnreachable() { return } diff --git a/sema/for_test.go b/sema/for_test.go index 66dd75d7b9..1d2937278d 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -788,349 +788,3 @@ func TestCheckForDictionary(t *testing.T) { require.NoError(t, err) }) } - -func TestCheckResourceInvalidationInForLoop(t *testing.T) { - - t.Parallel() - - t.Run("break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - break - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("continue", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - continue - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("return", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - return - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) -} - -func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { - - t.Parallel() - - t.Run("if break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if continue", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - continue - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if-else both break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - break - } else { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if break, destroy after", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the non-break path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - break - } - destroy r - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if break else destroy", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the else path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - break - } else { - destroy r - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if destroy else break", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the then path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - destroy r - } else { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if destroy else destroy break", func(t *testing.T) { - t.Parallel() - - // `r` is destroyed in both branches before the `break`, so no loss. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - destroy r - break - } else { - destroy r - } - } - } - `) - - require.NoError(t, err) - }) - - t.Run("nested if break in inner if", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - if true { - if true { - break - } - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("resource outside loop, if break", func(t *testing.T) { - t.Parallel() - - // `r` is declared outside the loop and destroyed after the loop, - // so the `break` does not leak it. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - let r <- create R() - for i in [2] { - if true { - break - } - } - destroy r - } - `) - - require.NoError(t, err) - }) -} - -func TestCheckResourceInvalidationInNestedForLoops(t *testing.T) { - - t.Parallel() - - t.Run("break in inner loop, destroy in outer", func(t *testing.T) { - t.Parallel() - - // `r` is declared in the outer loop and destroyed there. - // The inner `break` only exits the inner loop, so no leak. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - for j in [2] { - break - } - destroy r - } - } - `) - - require.NoError(t, err) - }) - - t.Run("resource in inner loop, inner break", func(t *testing.T) { - t.Parallel() - - // `r` is declared in the inner loop body and the `break` exits it - // without destroying `r`. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - for j in [2] { - let r <- create R() - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("outer break inside inner loop", func(t *testing.T) { - t.Parallel() - - // `break` always targets the innermost loop, - // so the inner `break` only exits the inner loop. - // The outer loop's `r` is destroyed at the end of each outer iteration. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - for i in [2] { - let r <- create R() - for j in [2] { - if true { - break - } - } - destroy r - } - } - `) - - require.NoError(t, err) - }) -} diff --git a/sema/return_info.go b/sema/return_info.go index 4764141040..3a66a91f2c 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -116,25 +116,6 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * ri.DefinitelyExited = ri.DefinitelyExited || (thenReturnInfo.DefinitelyExited && elseReturnInfo.DefinitelyExited) - - ri.mergeJumpOffsets(thenReturnInfo, elseReturnInfo) -} - -func (ri *ReturnInfo) mergeJumpOffsets(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { - // Propagate jump offsets recorded in either branch. - // Each branch has its own cloned JumpOffsets set (see Clone), - // so jumps in one branch do not contaminate the sibling branch's view. - // After the conditional, both branches' jumps are potential - // from the perspective of code that follows. - addAll := func(other *ReturnInfo) { - _ = other.JumpOffsets.ForEach(func(offset int) error { - ri.JumpOffsets.Add(offset) - return nil - }) - } - - addAll(thenReturnInfo) - addAll(elseReturnInfo) } func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInfo) { @@ -147,9 +128,6 @@ func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInf func (ri *ReturnInfo) Clone() *ReturnInfo { result := NewReturnInfo() *result = *ri - // Clone JumpOffsets so that jumps recorded in this clone - // do not leak into sibling clones (e.g. then vs. else branch). - result.JumpOffsets = ri.JumpOffsets.Clone() return result } diff --git a/sema/switch_test.go b/sema/switch_test.go index dd3f920a89..ffdd7acb26 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -712,158 +712,3 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { assert.IsType(t, &sema.ResourceUseAfterInvalidationError{}, errs[0]) }) } - -func TestCheckResourceInvalidationInSwitch(t *testing.T) { - - t.Parallel() - - t.Run("break in case", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - switch true { - case true: - let r <- create R() - break - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("break in default case", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - switch true { - default: - let r <- create R() - break - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("return in case", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - switch true { - case true: - let r <- create R() - return - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("resource outside switch, break in case", func(t *testing.T) { - t.Parallel() - - // `break` only exits the switch, not any enclosing scope. - // `r` is declared outside and destroyed after the switch, - // so no leak. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - let r <- create R() - switch true { - case true: - break - } - destroy r - } - `) - - require.NoError(t, err) - }) - - t.Run("break in nested if, destroy after", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the non-break path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - switch true { - case true: - let r <- create R() - if true { - break - } - destroy r - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("break in switch inside loop, resource in loop body", func(t *testing.T) { - t.Parallel() - - // `break` targets the innermost switch (not the loop), - // so the loop's `r` is destroyed at the end of each iteration. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - switch true { - case true: - break - } - destroy r - } - } - `) - - require.NoError(t, err) - }) - - t.Run("break in switch inside loop, resource in case", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - switch true { - case true: - let r <- create R() - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) -} diff --git a/sema/while_test.go b/sema/while_test.go index 6e9984f573..3922b94315 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -22,7 +22,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -166,349 +165,3 @@ func TestCheckInvalidContinueStatement(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.ControlStatementError{}, errs[0]) } - -func TestCheckResourceInvalidationInWhileLoop(t *testing.T) { - - t.Parallel() - - t.Run("break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - break - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("continue", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - continue - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("return", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - return - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) -} - -func TestCheckResourceInvalidationInWhileLoopWithIfElse(t *testing.T) { - - t.Parallel() - - t.Run("if break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if continue", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - continue - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if-else both break", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - break - } else { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if break, destroy after", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the non-break path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - break - } - destroy r - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if break else destroy", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the else path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - break - } else { - destroy r - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if destroy else break", func(t *testing.T) { - t.Parallel() - - // The `destroy r` only runs on the then path, - // so `r` leaks if the `break` is taken. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - destroy r - } else { - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("if destroy else destroy break", func(t *testing.T) { - t.Parallel() - - // `r` is destroyed in both branches before the `break`, so no loss. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - destroy r - break - } else { - destroy r - } - } - } - `) - - require.NoError(t, err) - }) - - t.Run("nested if break in inner if", func(t *testing.T) { - t.Parallel() - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - if true { - if true { - break - } - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("resource outside loop, if break", func(t *testing.T) { - t.Parallel() - - // `r` is declared outside the loop and destroyed after the loop, - // so the `break` does not leak it. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - let r <- create R() - while true { - if true { - break - } - } - destroy r - } - `) - - require.NoError(t, err) - }) -} - -func TestCheckResourceInvalidationInNestedWhileLoops(t *testing.T) { - - t.Parallel() - - t.Run("break in inner loop, destroy in outer", func(t *testing.T) { - t.Parallel() - - // `r` is declared in the outer loop and destroyed there. - // The inner `break` only exits the inner loop, so no leak. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - while true { - break - } - destroy r - } - } - `) - - require.NoError(t, err) - }) - - t.Run("resource in inner loop, inner break", func(t *testing.T) { - t.Parallel() - - // `r` is declared in the inner loop body and the `break` exits it - // without destroying `r`. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - while true { - let r <- create R() - break - } - } - } - `) - - errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.ResourceLossError{}, errs[0]) - }) - - t.Run("outer break inside inner loop", func(t *testing.T) { - t.Parallel() - - // `break` always targets the innermost loop, - // so the inner `break` only exits the inner loop. - // The outer loop's `r` is destroyed at the end of each outer iteration. - - _, err := ParseAndCheck(t, ` - resource R {} - - fun test() { - while true { - let r <- create R() - while true { - if true { - break - } - } - destroy r - } - } - `) - - require.NoError(t, err) - }) -} From 4672c8481b0f6b6aca2416225f8a732b1a592aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 10:38:33 -0700 Subject: [PATCH 030/139] assert live value IDs change (atree slab split) after inflation operations --- interpreter/composite_value_test.go | 51 +++++++++++++++++++++++++++++ interpreter/value_composite.go | 11 +++++++ 2 files changed, 62 insertions(+) diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 97c17911d8..6eadbad10d 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -421,11 +421,50 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { return nil })) + // liveValueID exposes the underlying atree map's current value ID for a + // composite resource, used by the Cadence code to assert that the slab + // split actually occurred. + liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueID", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + }, + sema.StringTypeAnnotation, + ), + "", + func( + context interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + ref := args[0].(*interpreter.EphemeralReferenceValue) + composite := ref.Value.(*interpreter.CompositeValue) + return interpreter.NewUnmeteredStringValue(composite.LiveValueID().String()) + }, + ) + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( t, @@ -522,6 +561,11 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { let ref = &arr[0] as auth(Withdraw) &Vault let ref2 = &arr[0] as auth(Withdraw) &Vault + // Both refs initially see the same root slab in the underlying atree map. + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) // Trigger an atree slab split on the underlying dictionary of Vault // by "inflating" those attachments @@ -539,6 +583,13 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // split reassigned slab IDs such that the OLD root (still pointed to by ref2's // dictionary) now has a different slab ID, while the NEW root inherits the // original slab ID. + // Confirm the split actually happened: ref's live value ID is the preserved + // original root ID, while ref2's live value ID is the freshly assigned ID of + // the now-demoted slab. + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. // Without the stable cached valueID on CompositeValue, this reference would be diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 885767a8bb..99b5c189dd 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1812,6 +1812,17 @@ func (v *CompositeValue) ValueID() atree.ValueID { return v.valueID } +// LiveValueID returns the underlying atree map's current value ID. +// In contrast to ValueID, which returns a stable value ID cached at +// construction, LiveValueID reflects mutations to the atree map's root, +// including slab ID reassignments caused by splits triggered through other +// CompositeValue instances wrapping the same underlying atree map. +// Intended for testing only; production code must use ValueID for resource +// tracking and invalidation. +func (v *CompositeValue) LiveValueID() atree.ValueID { + return v.dictionary.ValueID() +} + func (v *CompositeValue) RemoveField( context ValueRemoveContext, name string, From 4305d76ca9974fc5194927b0a73eb0e67fa575e6 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 21 May 2026 23:15:49 +0530 Subject: [PATCH 031/139] Revert "Revert "Skip resource loss check only if the block definitely-exited, but not jumped"" --- sema/checker.go | 16 +- sema/for_test.go | 346 +++++++++++++++++++++++++++++++++++++++++++ sema/return_info.go | 22 +++ sema/switch_test.go | 155 ++++++++++++++++++++ sema/while_test.go | 347 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 885 insertions(+), 1 deletion(-) diff --git a/sema/checker.go b/sema/checker.go index 96ce032e6f..5f870d8160 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1477,7 +1477,21 @@ func (checker *Checker) leaveValueScope(getEndPosition EndPositionGetter, checkR func (checker *Checker) checkResourceLoss(depth int) { returnInfo := checker.functionActivations.Current().ReturnInfo - if returnInfo.IsUnreachable() { + + // Skip the check only if the function has definitely exited + // (i.e. via a `return` statement, or via a definite halt such as `panic(...)`): + // + // - `return` invokes `checkResourceLoss` itself before marking the function + // as exited, so the variables it would catch have already been reported. + // - A definite halt intentionally does not lead to a resource-loss error, + // because the program would terminate with an error. + // + // In particular, the check must NOT be skipped after `break` / `continue` + // (`DefinitelyJumped` / `DefinitelyJumpedSwitch`): those statements do not + // invalidate resources, so resources declared inside the loop body or switch + // case but not moved/destroyed before the jump must still be reported when + // the enclosing scope is left. + if returnInfo.DefinitelyExited { return } diff --git a/sema/for_test.go b/sema/for_test.go index 1d2937278d..66dd75d7b9 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -788,3 +788,349 @@ func TestCheckForDictionary(t *testing.T) { require.NoError(t, err) }) } + +func TestCheckResourceInvalidationInForLoop(t *testing.T) { + + t.Parallel() + + t.Run("break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + continue + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} + +func TestCheckResourceInvalidationInForLoopWithIfElse(t *testing.T) { + + t.Parallel() + + t.Run("if break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + continue + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if-else both break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break else destroy", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the else path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + break + } else { + destroy r + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else break", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the then path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + destroy r + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else destroy break", func(t *testing.T) { + t.Parallel() + + // `r` is destroyed in both branches before the `break`, so no loss. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + destroy r + break + } else { + destroy r + } + } + } + `) + + require.NoError(t, err) + }) + + t.Run("nested if break in inner if", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + if true { + if true { + break + } + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("resource outside loop, if break", func(t *testing.T) { + t.Parallel() + + // `r` is declared outside the loop and destroyed after the loop, + // so the `break` does not leak it. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + for i in [2] { + if true { + break + } + } + destroy r + } + `) + + require.NoError(t, err) + }) +} + +func TestCheckResourceInvalidationInNestedForLoops(t *testing.T) { + + t.Parallel() + + t.Run("break in inner loop, destroy in outer", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the outer loop and destroyed there. + // The inner `break` only exits the inner loop, so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + for j in [2] { + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("resource in inner loop, inner break", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the inner loop body and the `break` exits it + // without destroying `r`. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + for j in [2] { + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("outer break inside inner loop", func(t *testing.T) { + t.Parallel() + + // `break` always targets the innermost loop, + // so the inner `break` only exits the inner loop. + // The outer loop's `r` is destroyed at the end of each outer iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + for i in [2] { + let r <- create R() + for j in [2] { + if true { + break + } + } + destroy r + } + } + `) + + require.NoError(t, err) + }) +} diff --git a/sema/return_info.go b/sema/return_info.go index 3a66a91f2c..4764141040 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -116,6 +116,25 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * ri.DefinitelyExited = ri.DefinitelyExited || (thenReturnInfo.DefinitelyExited && elseReturnInfo.DefinitelyExited) + + ri.mergeJumpOffsets(thenReturnInfo, elseReturnInfo) +} + +func (ri *ReturnInfo) mergeJumpOffsets(thenReturnInfo *ReturnInfo, elseReturnInfo *ReturnInfo) { + // Propagate jump offsets recorded in either branch. + // Each branch has its own cloned JumpOffsets set (see Clone), + // so jumps in one branch do not contaminate the sibling branch's view. + // After the conditional, both branches' jumps are potential + // from the perspective of code that follows. + addAll := func(other *ReturnInfo) { + _ = other.JumpOffsets.ForEach(func(offset int) error { + ri.JumpOffsets.Add(offset) + return nil + }) + } + + addAll(thenReturnInfo) + addAll(elseReturnInfo) } func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInfo) { @@ -128,6 +147,9 @@ func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInf func (ri *ReturnInfo) Clone() *ReturnInfo { result := NewReturnInfo() *result = *ri + // Clone JumpOffsets so that jumps recorded in this clone + // do not leak into sibling clones (e.g. then vs. else branch). + result.JumpOffsets = ri.JumpOffsets.Clone() return result } diff --git a/sema/switch_test.go b/sema/switch_test.go index ffdd7acb26..dd3f920a89 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -712,3 +712,158 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { assert.IsType(t, &sema.ResourceUseAfterInvalidationError{}, errs[0]) }) } + +func TestCheckResourceInvalidationInSwitch(t *testing.T) { + + t.Parallel() + + t.Run("break in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("break in default case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + default: + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("resource outside switch, break in case", func(t *testing.T) { + t.Parallel() + + // `break` only exits the switch, not any enclosing scope. + // `r` is declared outside and destroyed after the switch, + // so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + switch true { + case true: + break + } + destroy r + } + `) + + require.NoError(t, err) + }) + + t.Run("break in nested if, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + switch true { + case true: + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("break in switch inside loop, resource in loop body", func(t *testing.T) { + t.Parallel() + + // `break` targets the innermost switch (not the loop), + // so the loop's `r` is destroyed at the end of each iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + case true: + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("break in switch inside loop, resource in case", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + switch true { + case true: + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} diff --git a/sema/while_test.go b/sema/while_test.go index 3922b94315..6e9984f573 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -165,3 +166,349 @@ func TestCheckInvalidContinueStatement(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.ControlStatementError{}, errs[0]) } + +func TestCheckResourceInvalidationInWhileLoop(t *testing.T) { + + t.Parallel() + + t.Run("break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + break + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + continue + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("return", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + return + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) +} + +func TestCheckResourceInvalidationInWhileLoopWithIfElse(t *testing.T) { + + t.Parallel() + + t.Run("if break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if continue", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + continue + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if-else both break", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break, destroy after", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the non-break path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if break else destroy", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the else path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } else { + destroy r + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else break", func(t *testing.T) { + t.Parallel() + + // The `destroy r` only runs on the then path, + // so `r` leaks if the `break` is taken. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + destroy r + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("if destroy else destroy break", func(t *testing.T) { + t.Parallel() + + // `r` is destroyed in both branches before the `break`, so no loss. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + destroy r + break + } else { + destroy r + } + } + } + `) + + require.NoError(t, err) + }) + + t.Run("nested if break in inner if", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + if true { + break + } + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("resource outside loop, if break", func(t *testing.T) { + t.Parallel() + + // `r` is declared outside the loop and destroyed after the loop, + // so the `break` does not leak it. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + let r <- create R() + while true { + if true { + break + } + } + destroy r + } + `) + + require.NoError(t, err) + }) +} + +func TestCheckResourceInvalidationInNestedWhileLoops(t *testing.T) { + + t.Parallel() + + t.Run("break in inner loop, destroy in outer", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the outer loop and destroyed there. + // The inner `break` only exits the inner loop, so no leak. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + while true { + break + } + destroy r + } + } + `) + + require.NoError(t, err) + }) + + t.Run("resource in inner loop, inner break", func(t *testing.T) { + t.Parallel() + + // `r` is declared in the inner loop body and the `break` exits it + // without destroying `r`. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + while true { + let r <- create R() + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("outer break inside inner loop", func(t *testing.T) { + t.Parallel() + + // `break` always targets the innermost loop, + // so the inner `break` only exits the inner loop. + // The outer loop's `r` is destroyed at the end of each outer iteration. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + while true { + if true { + break + } + } + destroy r + } + } + `) + + require.NoError(t, err) + }) +} From 556e307480b2f27a7f89a9cc867c038ddbd1a501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 11:00:11 -0700 Subject: [PATCH 032/139] track dictionary value ID at creation time --- interpreter/dictionary_test.go | 156 ++++++++++++++++++++++++++++++++ interpreter/value_dictionary.go | 18 ++++ 2 files changed, 174 insertions(+) diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index 6f371fa278..a94ab1ef15 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -19,9 +19,21 @@ package interpreter_test import ( + "fmt" + "os" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/onflow/cadence/activations" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" + "github.com/onflow/cadence/stdlib" + "github.com/onflow/cadence/test_utils" + . "github.com/onflow/cadence/test_utils/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" ) func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { @@ -112,3 +124,147 @@ func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { require.NoError(t, err) }) } + +// TestInterpretDictionaryValueIDTracking is the DictionaryValue counterpart to +// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split +// stale-view scenario, where two DictionaryValue instances wrap the same +// underlying atree map (created by accessing the same outer dictionary key +// twice) and a split through one instance leaves the other with a different +// live value ID. Without the cached valueID on DictionaryValue, an +// EphemeralReferenceValue created from the stale view would register under +// that different ID, bypass invalidation when the inner dictionary is moved, +// and survive as a dangling ref. +func TestInterpretDictionaryValueIDTracking(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + // liveValueID exposes the underlying atree map's current value ID so the + // Cadence code can confirm the slab split actually occurred. + liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueID", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + }, + sema.StringTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + ref := args[0].(*interpreter.EphemeralReferenceValue) + dictValue := ref.Value.(*interpreter.DictionaryValue) + return interpreter.NewUnmeteredStringValue(dictValue.LiveValueID().String()) + }, + ) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + // Outer dictionary mapping to inner resource dictionaries; outer["a"] + // is the inner dictionary we will take two references to. + let outer: @{String: {String: Vault}} <- {"a": <-{"k0": <-create Vault(balance: 0.0)}} + + // Two EphemeralReferenceValues pointing to two different DictionaryValues + // which wrap the same underlying atree map. + let ref = (&outer["a"] as auth(Mutate) &{String: Vault}?)! + let ref2 = (&outer["a"] as auth(Mutate) &{String: Vault}?)! + + // Both refs initially see the same root slab in the underlying atree map. + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + // Insert enough entries via ref to grow the inner map past one slab + // and trigger an atree map slab split. + var i: Int = 0 + while i < 200 { + let old <- ref.insert(key: "k".concat(i.toString()), <-create Vault(balance: UFix64(i))) + destroy old + i = i + 1 + } + + // After the split, ref's dictionary.root points to the new root slab while + // ref2's dictionary.root still points to the old root slab (with a freshly + // assigned slab ID), so their live value IDs diverge. + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue + // from ref2. Without the stable cached valueID on DictionaryValue, this + // reference would be tracked under the (now-different) live ValueID of the + // stale view. + let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &{String: Vault} + + // Replace the inner dictionary with an empty one. The move invalidates + // all tracked references to the old inner dictionary. + var empty: @{String: Vault} <- {} + var extracted <- outer["a"] <- empty + destroy extracted + + // immortalRef must be invalidated; touching it must panic with + // InvalidatedResourceReferenceError. + log(immortalRef.length.toString()) + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + RequireError(t, err) + var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError + assert.ErrorAs(t, err, &invalidatedResourceReferenceError) +} diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 6320ac47b7..2306c20720 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -39,6 +39,12 @@ type DictionaryValue struct { dictionary *atree.OrderedMap isDestroyed bool elementSize uint + + // valueID is the atree value ID captured at construction time. + // It is used for reference tracking and invalidation, and must remain + // stable even if the dictionary's runtime root slab ID changes. + // See the equivalent field on CompositeValue for the underlying scenario. + valueID atree.ValueID } func NewDictionaryValue( @@ -293,6 +299,7 @@ func newDictionaryValueFromAtreeMap( return &DictionaryValue{ Type: staticType, dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), elementSize: elementSize, } } @@ -1769,6 +1776,17 @@ func (v *DictionaryValue) StorageAddress() atree.Address { } func (v *DictionaryValue) ValueID() atree.ValueID { + return v.valueID +} + +// LiveValueID returns the underlying atree map's current value ID. +// In contrast to ValueID, which returns a stable value ID cached at +// construction, LiveValueID reflects mutations to the atree map's root, +// including slab ID reassignments caused by splits triggered through other +// DictionaryValue instances wrapping the same underlying atree map. +// Intended for testing only; production code must use ValueID for resource +// tracking and invalidation. +func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } From 103a36604c7072331c4a34ad75bf5908b1e9956c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 11:00:20 -0700 Subject: [PATCH 033/139] track array value ID at creation time --- interpreter/array_test.go | 151 +++++++++++++++++++++++++++++++++++++ interpreter/value_array.go | 18 +++++ 2 files changed, 169 insertions(+) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 758721b85e..56bdff37c1 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -19,11 +19,21 @@ package interpreter_test import ( + "fmt" + "os" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/onflow/cadence/activations" + "github.com/onflow/cadence/common" "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" + "github.com/onflow/cadence/stdlib" + "github.com/onflow/cadence/test_utils" + . "github.com/onflow/cadence/test_utils/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" ) func TestInterpretArrayFunctionEntitlements(t *testing.T) { @@ -155,3 +165,144 @@ func TestCheckArrayReferenceTypeInferenceWithDowncasting(t *testing.T) { var forceCastTypeMismatchError *interpreter.ForceCastTypeMismatchError require.ErrorAs(t, err, &forceCastTypeMismatchError) } + +// TestInterpretArrayValueIDTracking is the ArrayValue counterpart to +// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split +// stale-view scenario, where two ArrayValue instances wrap the same underlying +// atree array (created by accessing the same outer array element twice) and a +// split through one instance leaves the other with a different live value ID. +// Without the cached valueID on ArrayValue, an EphemeralReferenceValue created +// from the stale view would register under that different ID, bypass +// invalidation when the inner array is moved, and survive as a dangling ref. +func TestInterpretArrayValueIDTracking(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + // liveValueID exposes the underlying atree array's current value ID so the + // Cadence code can confirm the slab split actually occurred. + liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueID", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + }, + sema.StringTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + ref := args[0].(*interpreter.EphemeralReferenceValue) + arrayValue := ref.Value.(*interpreter.ArrayValue) + return interpreter.NewUnmeteredStringValue(arrayValue.LiveValueID().String()) + }, + ) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + // Outer array of inner resource arrays; outer[0] is the inner array we + // will take two references to. + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + // Two EphemeralReferenceValues pointing to two different ArrayValues + // which wrap the same underlying atree array. + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + // Both refs initially see the same root slab in the underlying atree array. + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + // Append enough vaults via ref to grow the inner array past one slab + // and trigger an atree array slab split. + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // After the split, ref's array.root points to the new root slab while + // ref2's array.root still points to the old root slab (with a freshly + // assigned slab ID), so their live value IDs diverge. + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue + // from ref2. Without the stable cached valueID on ArrayValue, this reference + // would be tracked under the (now-different) live ValueID of the stale view. + let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &[Vault] + + // Replace the inner array with an empty one. The move invalidates all + // tracked references to the old inner array. + var empty: @[Vault] <- [] + var extracted <- outer[0] <- empty + destroy extracted + + // immortalRef must be invalidated; touching it must panic with + // InvalidatedResourceReferenceError. + log(immortalRef.length.toString()) + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + RequireError(t, err) + var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError + assert.ErrorAs(t, err, &invalidatedResourceReferenceError) +} diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 0cd406b910..0d49150d3f 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -39,6 +39,12 @@ type ArrayValue struct { isResourceKinded *bool elementSize uint isDestroyed bool + + // valueID is the atree value ID captured at construction time. + // It is used for reference tracking and invalidation, and must remain + // stable even if the array's runtime root slab ID changes. + // See the equivalent field on CompositeValue for the underlying scenario. + valueID atree.ValueID } func NewArrayValue( @@ -216,6 +222,7 @@ func newArrayValueFromAtreeArray( return &ArrayValue{ Type: staticType, array: atreeArray, + valueID: atreeArray.ValueID(), elementSize: elementSize, } } @@ -1618,6 +1625,17 @@ func (v *ArrayValue) StorageAddress() atree.Address { } func (v *ArrayValue) ValueID() atree.ValueID { + return v.valueID +} + +// LiveValueID returns the underlying atree array's current value ID. +// In contrast to ValueID, which returns a stable value ID cached at +// construction, LiveValueID reflects mutations to the atree array's root, +// including slab ID reassignments caused by splits triggered through other +// ArrayValue instances wrapping the same underlying atree array. +// Intended for testing only; production code must use ValueID for resource +// tracking and invalidation. +func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } From bfd35e208e89def34035969b4b8b266ad4abc804 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 21 May 2026 13:53:52 -0700 Subject: [PATCH 034/139] Produce only what can be guranteed for entitlement set intersection --- sema/access.go | 63 ++++++++++---- sema/access_test.go | 83 ++++++++++++++---- sema/entitlements_test.go | 173 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 31 deletions(-) diff --git a/sema/access.go b/sema/access.go index ffb78a820f..8db8f5ddb1 100644 --- a/sema/access.go +++ b/sema/access.go @@ -88,9 +88,28 @@ func NewAccessFromEntitlementOrderedSet( } // IntersectAccess returns the intersection of two accesses. -// If either is unauthorized, the result is unauthorized. -// If both are EntitlementSetAccess, the result contains only entitlements present in both. -// For all other combinations, the result is unauthorized. +// The result only contains entitlements that are statically guaranteed by both sides. +// +// Rules: +// - If either side is not an EntitlementSetAccess (e.g. unauthorized, primitive, +// or entitlement map access), the result is unauthorized. +// - Conjunction ∩ Conjunction: standard set intersection (Conjunction). +// Both sides guarantee all of their entitlements, so anything in the +// intersection is guaranteed. +// - Conjunction ∩ Disjunction (and vice versa): +// A conjunction guarantees all of its entitlements, while a disjunction +// only guarantees that at least one (unspecified) entitlement from its set +// is present. The disjunction can therefore be preserved as the result only +// when the conjunction is a superset of the disjunction — in that case the +// conjunction guarantees every option of the disjunction. Otherwise the +// result is unauthorized, because the entitlement actually held by the +// disjunction side might not be guaranteed by the conjunction side. +// (E.g. auth(A) ∩ auth(A | B | C) = unauthorized, because the disjunction +// side might hold B or C, neither of which is guaranteed by the conjunction +// side; but auth(A, B, C) ∩ auth(A | B | C) = auth(A | B | C), because the +// conjunction side guarantees all options of the disjunction.) +// - Disjunction ∩ Disjunction: unauthorized. Neither side guarantees any +// specific entitlement, so nothing can be statically guaranteed in common. func IntersectAccess(a, b Access) Access { aSet, ok := a.(EntitlementSetAccess) if !ok { @@ -102,20 +121,34 @@ func IntersectAccess(a, b Access) Access { return UnauthorizedAccess } - intersection := orderedmap.KeySetIntersection( - aSet.Entitlements, - bSet.Entitlements, - ) + switch { + case aSet.SetKind == Conjunction && bSet.SetKind == Conjunction: + intersection := orderedmap.KeySetIntersection( + aSet.Entitlements, + bSet.Entitlements, + ) + return NewAccessFromEntitlementOrderedSet(intersection, Conjunction) - // If either is a disjunction, the result must be a disjunction, - // because a disjunction only guarantees one of the entitlements is present. - // Only if both are conjunctions can the result be a conjunction. - setKind := Conjunction - if aSet.SetKind == Disjunction || bSet.SetKind == Disjunction { - setKind = Disjunction - } + case aSet.SetKind == Conjunction && bSet.SetKind == Disjunction: + // Preserve the disjunction only if the conjunction guarantees all of + // its options. Otherwise nothing is statically guaranteed in common. + if bSet.Entitlements.ForAllKeys(aSet.Entitlements.Contains) { + return bSet + } + return UnauthorizedAccess - return NewAccessFromEntitlementOrderedSet(intersection, setKind) + case aSet.SetKind == Disjunction && bSet.SetKind == Conjunction: + // Symmetric to the previous case. + if aSet.Entitlements.ForAllKeys(bSet.Entitlements.Contains) { + return aSet + } + return UnauthorizedAccess + + default: + // Disjunction ∩ Disjunction: neither side guarantees any specific + // entitlement, so the result cannot guarantee any entitlement either. + return UnauthorizedAccess + } } func (EntitlementSetAccess) isAccess() {} diff --git a/sema/access_test.go b/sema/access_test.go index 2585823e46..76b4151a92 100644 --- a/sema/access_test.go +++ b/sema/access_test.go @@ -677,21 +677,24 @@ func TestIntersectAccess(t *testing.T) { t.Run("disjunction, identical", func(t *testing.T) { t.Parallel() + // Two disjunctions: neither guarantees any specific entitlement, + // so nothing can be statically guaranteed in common. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), disjunctionAccess(MutateType, InsertType), ) - assert.Equal(t, disjunctionAccess(MutateType, InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) }) t.Run("disjunction, partial overlap", func(t *testing.T) { t.Parallel() + // Both sides are disjunctions, so nothing is guaranteed in common. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), disjunctionAccess(InsertType, RemoveType), ) - assert.Equal(t, disjunctionAccess(InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) }) t.Run("disjunction, disjoint", func(t *testing.T) { @@ -704,40 +707,76 @@ func TestIntersectAccess(t *testing.T) { assert.Equal(t, UnauthorizedAccess, result) }) - t.Run("mixed, conjunction left, disjunction right", func(t *testing.T) { + t.Run("mixed, conjunction left, disjunction right, conjunction not superset", func(t *testing.T) { t.Parallel() - // Conjunction has all of {Mutate, Insert}. - // Disjunction has one of {Insert, Remove}. - // Intersection: {Insert}. Result must be disjunction - // because the disjunction side only guarantees one. + // Conjunction guarantees Mutate and Insert. + // Disjunction guarantees one of Insert or Remove (but might be Remove, + // which the conjunction does not guarantee). + // Therefore the disjunction cannot be preserved. result := IntersectAccess( conjunctionAccess(MutateType, InsertType), disjunctionAccess(InsertType, RemoveType), ) - assert.Equal(t, disjunctionAccess(InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) }) - t.Run("mixed, disjunction left, conjunction right", func(t *testing.T) { + t.Run("mixed, disjunction left, conjunction right, conjunction not superset", func(t *testing.T) { t.Parallel() - // Disjunction has one of {Mutate, Insert}. - // Conjunction has all of {Insert, Remove}. - // Intersection: {Insert}. Result must be disjunction. + // Symmetric: conjunction does not contain every option of the + // disjunction, so nothing is statically guaranteed in common. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), conjunctionAccess(InsertType, RemoveType), ) - assert.Equal(t, disjunctionAccess(InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) + }) + + t.Run("mixed, conjunction left, disjunction right, conjunction superset", func(t *testing.T) { + t.Parallel() + + // Conjunction guarantees all of {Mutate, Insert, Remove}, which is a + // superset of the disjunction's options {Insert, Remove}. + // The disjunction is preserved as-is. + result := IntersectAccess( + conjunctionAccess(MutateType, InsertType, RemoveType), + disjunctionAccess(InsertType, RemoveType), + ) + assert.Equal(t, disjunctionAccess(InsertType, RemoveType), result) + }) + + t.Run("mixed, disjunction left, conjunction right, conjunction superset", func(t *testing.T) { + t.Parallel() + + // Symmetric to the previous case. + result := IntersectAccess( + disjunctionAccess(InsertType, RemoveType), + conjunctionAccess(MutateType, InsertType, RemoveType), + ) + assert.Equal(t, disjunctionAccess(InsertType, RemoveType), result) + }) + + t.Run("mixed, conjunction equals disjunction set", func(t *testing.T) { + t.Parallel() + + // Conjunction guarantees all of {Mutate, Insert}, which equals + // the disjunction's option set: the conjunction is a superset. + // The disjunction is preserved as-is. + result := IntersectAccess( + conjunctionAccess(MutateType, InsertType), + disjunctionAccess(MutateType, InsertType), + ) + assert.Equal(t, disjunctionAccess(MutateType, InsertType), result) }) t.Run("mixed, full overlap, disjunction constrains result", func(t *testing.T) { t.Parallel() // Disjunction has one of {Mutate, Insert}. - // Conjunction has all of {Mutate, Insert}. - // Intersection: {Mutate, Insert}. Still disjunction — - // can't upgrade to conjunction just because the sets match. + // Conjunction has all of {Mutate, Insert}: superset of the disjunction, + // so the disjunction passes through unchanged. The result cannot be + // upgraded to a conjunction. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), conjunctionAccess(MutateType, InsertType), @@ -745,6 +784,18 @@ func TestIntersectAccess(t *testing.T) { assert.Equal(t, disjunctionAccess(MutateType, InsertType), result) }) + t.Run("mixed, conjunction subset of disjunction", func(t *testing.T) { + t.Parallel() + + // Conjunction guarantees only {Insert}, but the disjunction may hold + // Mutate or Remove instead, which the conjunction does not guarantee. + result := IntersectAccess( + conjunctionAccess(InsertType), + disjunctionAccess(MutateType, InsertType, RemoveType), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + t.Run("entitlement map access", func(t *testing.T) { t.Parallel() diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 7f0a60c709..1d33ac3633 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -8687,6 +8687,179 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { typeMismatchError.ActualType.ID(), ) }) + + // Disjunction intersection: the inner disjunction is preserved only + // when the outer conjunction guarantees all of the disjunction's options. + // Otherwise the intersection is unauthorized, because nothing about the + // disjunction's specific entitlements is statically guaranteed. + + t.Run("array, conjunction outer not superset, disjunction inner, escalation prevented", func(t *testing.T) { + t.Parallel() + + // auth(F) ∩ auth(E | F): the conjunction does not contain E, so the + // disjunction (which might actually hold E) cannot be preserved. + // Result is unauthorized. + // + // This is the entitlement escalation that motivated the stricter + // disjunction intersection rules: an Insert-only reference widened to + // auth(Insert | Remove) must not be cast back to auth(Remove) via a + // nested container access with auth(Remove) outer. + _, err := ParseAndCheck(t, ` + entitlement E + entitlement F + + access(all) struct Victim { + access(self) let arr: [Int] + init() { + self.arr = [123] + } + access(all) fun getEOnlyRef(): auth(E) &[Int] { + return &self.arr as auth(E) &[Int] + } + } + + fun test() { + let v = Victim() + let eOnlyArrRef = v.getEOnlyRef() + let disjunction = eOnlyArrRef as auth(E | F) &[Int] + let wrapperArr: [auth(E | F) &[Int]] = [disjunction] + let wrapperArrRef = &wrapperArr as auth(F) &[auth(E | F) &[Int]] + + // Without the stricter disjunction intersection rule, this would + // grant F on an E-only reference. + let fRef: auth(F) &[Int] = wrapperArrRef[0] + } + `) + + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + + assert.Equal(t, + common.TypeID("auth(S.test.F)&[Int]"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&[Int]"), + typeMismatchError.ActualType.ID(), + ) + }) + + t.Run("array, disjunction outer, disjunction inner, escalation prevented", func(t *testing.T) { + t.Parallel() + + // auth(E | F) ∩ auth(E | F): both sides are disjunctions, so neither + // guarantees any specific entitlement. Result is unauthorized, even + // though the option sets are identical. + _, err := ParseAndCheck(t, ` + entitlement E + entitlement F + + fun test() { + let array: [auth(E | F) &Int] = [&1 as auth(E | F) &Int] + let arrayRef = &array as auth(E | F) &[auth(E | F) &Int] + + let ref: auth(E | F) &Int = arrayRef[0] + } + `) + + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + + assert.Equal(t, + common.TypeID("auth(S.test.E|S.test.F)&Int"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&Int"), + typeMismatchError.ActualType.ID(), + ) + }) + + t.Run("array, conjunction outer superset, disjunction inner preserved", func(t *testing.T) { + t.Parallel() + + // auth(E, F) ∩ auth(E | F): the conjunction guarantees all of the + // disjunction's options, so the disjunction passes through unchanged. + _, err := ParseAndCheck(t, ` + entitlement E + entitlement F + + fun test() { + let array: [auth(E | F) &Int] = [&1 as auth(E | F) &Int] + let arrayRef = &array as auth(E, F) &[auth(E | F) &Int] + + let ref: auth(E | F) &Int = arrayRef[0] + } + `) + + require.NoError(t, err) + }) + + t.Run("array, conjunction outer superset, disjunction inner preserved, no upgrade to conjunction", func(t *testing.T) { + t.Parallel() + + // auth(E, F) ∩ auth(E | F) preserves the disjunction. The result + // cannot be upgraded to auth(F) (the disjunction might only hold E). + _, err := ParseAndCheck(t, ` + entitlement E + entitlement F + + fun test() { + let array: [auth(E | F) &Int] = [&1 as auth(E | F) &Int] + let arrayRef = &array as auth(E, F) &[auth(E | F) &Int] + + let ref: auth(F) &Int = arrayRef[0] + } + `) + + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + + assert.Equal(t, + common.TypeID("auth(S.test.F)&Int"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("auth(S.test.E|S.test.F)&Int"), + typeMismatchError.ActualType.ID(), + ) + }) + + t.Run("array, disjunction outer, conjunction inner subset, escalation prevented", func(t *testing.T) { + t.Parallel() + + // auth(E | F) ∩ auth(E): the conjunction does not contain F, so the + // disjunction outer cannot be preserved as the result (it might + // actually hold F, which the conjunction does not have). + // Result is unauthorized. + _, err := ParseAndCheck(t, ` + entitlement E + entitlement F + + fun test() { + let array: [auth(E) &Int] = [&1 as auth(E) &Int] + let arrayRef = &array as auth(E | F) &[auth(E) &Int] + + let ref: auth(E) &Int = arrayRef[0] + } + `) + + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + + assert.Equal(t, + common.TypeID("auth(S.test.E)&Int"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&Int"), + typeMismatchError.ActualType.ID(), + ) + }) } func TestCheckMappingAccessFieldType(t *testing.T) { From 89b12c365caef38e6ae316f885b2daa7ef714d2c Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 21 May 2026 13:57:33 -0700 Subject: [PATCH 035/139] Add complete reproducer --- sema/entitlements_test.go | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 1d33ac3633..7e1f3aa55f 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -9075,3 +9075,100 @@ func TestCheckEntitlementSetAccessIsNotMapping(t *testing.T) { assert.IsType(t, &sema.TypeMismatchError{}, errs[0]) } + +func TestCheckInvalidDisjunctiveEntitlementsEscalation(t *testing.T) { + t.Parallel() + + // An auth(Insert) reference is upcast to the disjunction auth(Insert | Remove), + // then wrapped in an auth(Remove) &[auth(Insert | Remove) &[Int]] container. + // Indexing must intersect the outer auth(Remove) with the disjunctive inner auth. + // The safe intersection of a conjunction with a disjunction is the empty set + // (the disjunction guarantees none of its members individually), so the result + // must be `&[Int]`, not `auth(Remove) &[Int]`. Otherwise an Insert-only reference + // could be escalated to Remove via container indexing. + t.Run("disjunction outer of container escalates inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct Victim { + access(self) let arr: [Int] + init() { + self.arr = [123] + } + access(all) fun getInsertOnlyRef(): auth(Insert) &[Int]{ + return &self.arr as auth(Insert) &[Int] + } + } + + access(all) fun main() { + let v = Victim() + let insertOnlyArrRef = v.getInsertOnlyRef() + + let disjunction = insertOnlyArrRef as auth (Insert|Remove) &[Int] + let wrapperArr: [auth (Insert|Remove) &[Int]] = [disjunction] + let wrapperArrRef = &wrapperArr as auth(Remove) &[auth (Insert|Remove) &[Int]] + let removeRef: auth(Remove) &[Int] = wrapperArrRef[0] + + removeRef.remove(at: 0) + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + + assert.Equal(t, + common.TypeID("auth(Remove)&[Int]"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&[Int]"), + typeMismatchError.ActualType.ID(), + ) + }) + + // An auth(Insert) reference to a constant-sized array of auth(Remove) refs + // is upcast to auth(Insert | Remove). Indexing the disjunctively-authorized + // container must intersect the disjunction with the inner auth(Remove); + // the safe intersection is empty, so the indexed element is unauthorized + // and access to a Remove-entitled member must be rejected. + t.Run("disjunction outer escalates fixed-size array element auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Insert) fun requiresInsert(): String { + return "requiresInsert called" + } + + access(Remove) fun requiresRemove(): String { + return "requiresRemove called" + } + } + + access(all) struct OuterVictim { + access(self) let iv : InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim; 1] + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim; 1] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + let disjointRef: auth(Insert|Remove) &[auth(Remove) &InnerVictim; 1] = insertOnlyArrRef + disjointRef[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) +} From 55be9b1918138e933858ebe705747433af0ded8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 14:34:16 -0700 Subject: [PATCH 036/139] apply cascading intersection to container method return types --- bbq/vm/test/vm_test.go | 11 + bbq/vm/value_array.go | 28 +- bbq/vm/value_dictionary.go | 9 +- interpreter/value_array.go | 37 +- interpreter/value_dictionary.go | 24 +- sema/entitlements_test.go | 374 +++++++++++++ sema/type.go | 919 +++++++++++++++++++++----------- 7 files changed, 1060 insertions(+), 342 deletions(-) diff --git a/bbq/vm/test/vm_test.go b/bbq/vm/test/vm_test.go index 7bf10391e7..089213bf45 100644 --- a/bbq/vm/test/vm_test.go +++ b/bbq/vm/test/vm_test.go @@ -8380,6 +8380,11 @@ func TestArrayFunctions(t *testing.T) { interpreter.TypeValue{ Type: interpreter.FunctionStaticType{ FunctionType: sema.ArrayReverseFunctionType( + nil, + sema.NewVariableSizedType( + nil, + sema.UInt8Type, + ), sema.NewVariableSizedType( nil, sema.UInt8Type, @@ -8412,6 +8417,12 @@ func TestArrayFunctions(t *testing.T) { interpreter.TypeValue{ Type: interpreter.FunctionStaticType{ FunctionType: sema.ArrayReverseFunctionType( + nil, + sema.NewConstantSizedType( + nil, + sema.UInt8Type, + 2, + ), sema.NewConstantSizedType( nil, sema.UInt8Type, diff --git a/bbq/vm/value_array.go b/bbq/vm/value_array.go index f9a3cbee49..aa691fe4ab 100644 --- a/bbq/vm/value_array.go +++ b/bbq/vm/value_array.go @@ -65,8 +65,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeReverseFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) arrayType := arrayTypeFromValue(receiver, context) - return sema.ArrayReverseFunctionType(arrayType) + return sema.ArrayReverseFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayReverseFunction, ), @@ -150,8 +151,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeConcatFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) arrayType := arrayTypeFromValue(receiver, context) - return sema.ArrayConcatFunctionType(arrayType) + return sema.ArrayConcatFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayConcatFunction, ), @@ -174,8 +176,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveFunctionType(elementType) + return sema.ArrayRemoveFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFunction, ), @@ -186,8 +189,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveFirstFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveFirstFunctionType(elementType) + return sema.ArrayRemoveFirstFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFirstFunction, ), @@ -198,8 +202,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveLastFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveLastFunctionType(elementType) + return sema.ArrayRemoveLastFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveLastFunction, ), @@ -210,8 +215,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeSliceFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArraySliceFunctionType(elementType) + return sema.ArraySliceFunctionType(context, accessedType, elementType) }, interpreter.NativeArraySliceFunction, ), @@ -222,8 +228,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeToConstantSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayToConstantSizedFunctionType(elementType) + return sema.ArrayToConstantSizedFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayToConstantSizedFunction, ), @@ -238,8 +245,13 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeToVariableSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayToVariableSizedFunctionType(elementType) + return sema.ArrayToVariableSizedFunctionType( + context, + accessedType, + elementType, + ) }, interpreter.NativeArrayToVariableSizedFunction, ), diff --git a/bbq/vm/value_dictionary.go b/bbq/vm/value_dictionary.go index 139fc2c477..c0d5ba509e 100644 --- a/bbq/vm/value_dictionary.go +++ b/bbq/vm/value_dictionary.go @@ -33,8 +33,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) dictionaryType := dictionaryType(receiver, context) - return sema.DictionaryRemoveFunctionType(dictionaryType) + return sema.DictionaryRemoveFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryRemoveFunction, ), @@ -45,8 +46,9 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeInsertFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) dictionaryType := dictionaryType(receiver, context) - return sema.DictionaryInsertFunctionType(dictionaryType) + return sema.DictionaryInsertFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryInsertFunction, ), @@ -69,9 +71,10 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeForEachKeyFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) dictionaryValue := receiver.(*interpreter.DictionaryValue) dictionaryType := dictionaryValue.SemaType(context) - return sema.DictionaryForEachKeyFunctionType(dictionaryType) + return sema.DictionaryForEachKeyFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryForEachKeyFunction, ), diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 0d49150d3f..55b742a200 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -975,6 +975,13 @@ func (v *ArrayValue) GetMethod( arrayType := v.SemaType(context) + var accessedType sema.Type + if accessedReference != nil { + accessedType = MustSemaTypeOfValue(accessedReference, context) + } else { + accessedType = arrayType + } + switch name { case sema.ArrayTypeAppendFunctionName: return NewBoundHostFunctionValue( @@ -1004,6 +1011,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayConcatFunctionType( + context, + accessedType, arrayType, ), NativeArrayConcatFunction, @@ -1026,6 +1035,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveFunction, @@ -1037,6 +1048,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveFirstFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveFirstFunction, @@ -1048,6 +1061,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveLastFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveLastFunction, @@ -1081,6 +1096,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArraySliceFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArraySliceFunction, @@ -1092,19 +1109,14 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayReverseFunctionType( + context, + accessedType, arrayType, ), NativeArrayReverseFunction, ) case sema.ArrayTypeFilterFunctionName: - var accessedType sema.Type - if accessedReference != nil { - accessedType = MustSemaTypeOfValue(accessedReference, context) - } else { - accessedType = arrayType - } - return NewBoundHostFunctionValue( context, v, @@ -1121,13 +1133,6 @@ func (v *ArrayValue) GetMethod( WithDereferenceReceiver(false) case sema.ArrayTypeMapFunctionName: - var accessedType sema.Type - if accessedReference != nil { - accessedType = MustSemaTypeOfValue(accessedReference, context) - } else { - accessedType = arrayType - } - return NewBoundHostFunctionValue( context, v, @@ -1149,6 +1154,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayToVariableSizedFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayToVariableSizedFunction, @@ -1160,6 +1167,8 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayToConstantSizedFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayToConstantSizedFunction, diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 2306c20720..49f9476ce4 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -929,6 +929,16 @@ func (v *DictionaryValue) GetMethod( name string, accessedReference ReferenceValue, ) FunctionValue { + + dictionaryType := v.SemaType(context) + + var accessedType sema.Type + if accessedReference != nil { + accessedType = MustSemaTypeOfValue(accessedReference, context) + } else { + accessedType = dictionaryType + } + switch name { case sema.DictionaryTypeRemoveFunctionName: return NewBoundHostFunctionValue( @@ -936,7 +946,9 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryRemoveFunctionType( - v.SemaType(context), + context, + accessedType, + dictionaryType, ), NativeDictionaryRemoveFunction, ) @@ -947,7 +959,9 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryInsertFunctionType( - v.SemaType(context), + context, + accessedType, + dictionaryType, ), NativeDictionaryInsertFunction, ) @@ -958,7 +972,7 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryContainsKeyFunctionType( - v.SemaType(context), + dictionaryType, ), NativeDictionaryContainsKeyFunction, ) @@ -969,7 +983,9 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryForEachKeyFunctionType( - v.SemaType(context), + context, + accessedType, + dictionaryType, ), NativeDictionaryForEachKeyFunction, ) diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 7f0a60c709..6c324b7f1e 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -8689,6 +8689,380 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { }) } +func TestInvalidCheckEntitlementEscalation(t *testing.T) { + + t.Parallel() + + // Each subtest exercises an array method that returns elements (or a copy + // of the array) and verifies that when the method is invoked through an + // auth(Insert) reference to a `[auth(Remove) &T; N]` or `[auth(Remove) &T]`, + // the inner element references are intersected with the outer auth. + // outer auth(Insert) ∩ inner auth(Remove) is empty, so the resulting + // element type must be unauthorized: any call to a Remove-entitled member + // on those elements must be rejected. Without this intersection, the + // receiver's Insert-only outer auth would escalate to Remove on every + // element extracted via the copy/return method. + + // `toVariableSized` copies a fixed-size array into a variable-sized array. + t.Run("toVariableSized preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Insert) fun requiresInsert() {} + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim; 1] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim; 1] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + insertOnlyArrRef.toVariableSized()[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `slice` returns a sub-range of a variable-sized array. + t.Run("slice preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + insertOnlyArrRef.slice(from: 0, upTo: 1)[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `concat` returns the concatenation of two arrays. + t.Run("concat preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + let other: [auth(Remove) &InnerVictim] = [] + insertOnlyArrRef.concat(other)[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `reverse` returns a reversed copy of the array. + t.Run("reverse preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + insertOnlyArrRef.reverse()[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `toConstantSized` copies a variable-sized array into a constant-sized + // array (the user provides the target type parameter). + t.Run("toConstantSized preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyArrRef = ov.getInsertOnlyRef() + + insertOnlyArrRef.toConstantSized<[&InnerVictim; 1]>()![0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `remove` extracts a single element from a variable-sized array. + // Note: the receiver must grant the Mutate or Remove entitlement for + // `remove` to be callable; here we use auth(Mutate, Insert) so we can + // call `remove` while still exercising a partial-overlap intersection + // (auth(Mutate, Insert) ∩ auth(Remove) is empty, which strips the + // inner reference auth). + t.Run("remove preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getMutateOnlyRef(): auth(Mutate) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let mutateOnlyArrRef = ov.getMutateOnlyRef() + + mutateOnlyArrRef.remove(at: 0).requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `removeFirst` extracts the first element of a variable-sized array. + t.Run("removeFirst preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getMutateOnlyRef(): auth(Mutate) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let mutateOnlyArrRef = ov.getMutateOnlyRef() + + mutateOnlyArrRef.removeFirst().requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // `removeLast` extracts the last element of a variable-sized array. + t.Run("removeLast preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getMutateOnlyRef(): auth(Mutate) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let mutateOnlyArrRef = ov.getMutateOnlyRef() + + mutateOnlyArrRef.removeLast().requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // Dictionary `remove` extracts a value from a dictionary by key. + t.Run("dictionary remove preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let dict: {String: auth(Remove) &InnerVictim} + + init() { + self.iv = InnerVictim() + self.dict = {"a": &self.iv as auth(Remove) &InnerVictim} + } + + access(all) fun getMutateOnlyRef(): auth(Mutate) &{String: auth(Remove) &InnerVictim} { + return &self.dict + } + } + + access(all) fun main() { + let ov = OuterVictim() + let mutateOnlyRef = ov.getMutateOnlyRef() + + mutateOnlyRef.remove(key: "a")!.requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // Dictionary `insert` returns the previous value at the given key. + t.Run("dictionary insert preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let dict: {String: auth(Remove) &InnerVictim} + + init() { + self.iv = InnerVictim() + self.dict = {"a": &self.iv as auth(Remove) &InnerVictim} + } + + access(all) fun getMutateOnlyRef(): auth(Mutate) &{String: auth(Remove) &InnerVictim} { + return &self.dict + } + } + + access(all) fun main(replacement: auth(Remove) &InnerVictim) { + let ov = OuterVictim() + let mutateOnlyRef = ov.getMutateOnlyRef() + + mutateOnlyRef.insert(key: "a", replacement)!.requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + +} + func TestCheckMappingAccessFieldType(t *testing.T) { t.Parallel() diff --git a/sema/type.go b/sema/type.go index 6772e2055a..a26800416d 100644 --- a/sema/type.go +++ b/sema/type.go @@ -2583,34 +2583,8 @@ func getArrayMembers(arrayType ArrayType) map[string]MemberResolver { }, }, ArrayTypeReverseFunctionName: { - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { - elementType := arrayType.ElementType(false) - - // It is impossible for a resource to be present in two arrays. - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } - - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArrayReverseFunctionType(arrayType), - arrayTypeReverseFunctionDocString, - ) - }, + Kind: common.DeclarationKindFunction, + Resolve: ArrayReverseFunctionMemberFuncResolver(arrayType, arrayType), }, ArrayTypeFilterFunctionName: { Kind: common.DeclarationKindFunction, @@ -2679,225 +2653,280 @@ func getArrayMembers(arrayType ArrayType) map[string]MemberResolver { } members[ArrayTypeConcatFunctionName] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { + Kind: common.DeclarationKindFunction, + Resolve: ArrayConcatFunctionMemberFuncResolver(arrayType, arrayType), + } - // TODO: maybe allow for resource element type + members[ArrayTypeSliceFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArraySliceFunctionMemberFuncResolver(arrayType, arrayType), + } - elementType := arrayType.ElementType(false) + members[ArrayTypeInsertFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayInsertFunctionMemberFuncResolver(arrayType), + } - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } + members[ArrayTypeRemoveFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveFunctionMemberFuncResolver(arrayType, arrayType), + } - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArrayConcatFunctionType(arrayType), - arrayTypeConcatFunctionDocString, - ) - }, + members[ArrayTypeRemoveFirstFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveFirstFunctionMemberFuncResolver(arrayType, arrayType), } - members["slice"] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { + members[ArrayTypeRemoveLastFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveLastFunctionMemberFuncResolver(arrayType, arrayType), + } - elementType := arrayType.ElementType(false) + members[ArrayTypeToConstantSizedFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayToConstantSizedFunctionMemberFuncResolver(arrayType, arrayType), + } + } - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } + if _, ok := arrayType.(*ConstantSizedType); ok { - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArraySliceFunctionType(elementType), - arrayTypeSliceFunctionDocString, - ) - }, + members[ArrayTypeToVariableSizedFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayToVariableSizedFunctionMemberFuncResolver(arrayType, arrayType), } + } - members["insert"] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { + return withBuiltinMembers(arrayType, members) +} - elementType := arrayType.ElementType(false) +func ArrayToVariableSizedFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { + elementType := arrayType.ElementType(false) - return NewFunctionMember( - memoryGauge, - arrayType, - insertMutateEntitledAccess, - identifier, - ArrayInsertFunctionType(elementType), - arrayTypeInsertFunctionDocString, - ) - }, + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } - members["remove"] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArrayToVariableSizedFunctionType(memoryGauge, accessedType, elementType), + arrayTypeToVariableSizedFunctionDocString, + ) + } +} - elementType := arrayType.ElementType(false) +func ArrayMapFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { + elementType := arrayType.ElementType(false) - return NewFunctionMember( - memoryGauge, - arrayType, - removeMutateEntitledAccess, - identifier, - ArrayRemoveFunctionType(elementType), - arrayTypeRemoveFunctionDocString, - ) - }, + // TODO: maybe allow for resource element type as a reference. + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } - members["removeFirst"] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArrayMapFunctionType( + memoryGauge, + accessedType, + arrayType, + ), + arrayTypeMapFunctionDocString, + ) + } +} - elementType := arrayType.ElementType(false) +func ArrayFilterFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { - return NewFunctionMember( - memoryGauge, - arrayType, - removeMutateEntitledAccess, - identifier, - ArrayRemoveFirstFunctionType(elementType), - arrayTypeRemoveFirstFunctionDocString, - ) - }, + elementType := arrayType.ElementType(false) + + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } - members["removeLast"] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArrayFilterFunctionType( + memoryGauge, + accessedType, + elementType, + ), + arrayTypeFilterFunctionDocString, + ) + } +} - elementType := arrayType.ElementType(false) +func ArrayConcatFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { - return NewFunctionMember( - memoryGauge, - arrayType, - removeMutateEntitledAccess, - identifier, - ArrayRemoveLastFunctionType(elementType), - arrayTypeRemoveLastFunctionDocString, - ) - }, + elementType := arrayType.ElementType(false) + + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } - members[ArrayTypeToConstantSizedFunctionName] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { - elementType := arrayType.ElementType(false) + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArrayConcatFunctionType(memoryGauge, accessedType, arrayType), + arrayTypeConcatFunctionDocString, + ) + } +} - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } +func ArraySliceFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArrayToConstantSizedFunctionType(elementType), - arrayTypeToConstantSizedFunctionDocString, - ) - }, + elementType := arrayType.ElementType(false) + + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } + + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArraySliceFunctionType(memoryGauge, accessedType, elementType), + arrayTypeSliceFunctionDocString, + ) } +} - if _, ok := arrayType.(*ConstantSizedType); ok { +func ArrayInsertFunctionMemberFuncResolver(arrayType ArrayType) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { - members[ArrayTypeToVariableSizedFunctionName] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { - elementType := arrayType.ElementType(false) + elementType := arrayType.ElementType(false) - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } + return NewFunctionMember( + memoryGauge, + arrayType, + insertMutateEntitledAccess, + identifier, + ArrayInsertFunctionType(elementType), + arrayTypeInsertFunctionDocString, + ) + } +} - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArrayToVariableSizedFunctionType(elementType), - arrayTypeToVariableSizedFunctionDocString, - ) - }, +func ArrayReverseFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { + + elementType := arrayType.ElementType(false) + + // It is impossible for a resource to be present in two arrays. + if elementType.IsResourceType() { + report( + &InvalidResourceArrayMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindFunction, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) } - } - return withBuiltinMembers(arrayType, members) + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArrayReverseFunctionType(memoryGauge, accessedType, arrayType), + arrayTypeReverseFunctionDocString, + ) + } } -func ArrayMapFunctionMemberFuncResolver( +func ArrayToConstantSizedFunctionMemberFuncResolver( accessedType Type, arrayType ArrayType, ) ResolveMemberFunc { @@ -2907,9 +2936,9 @@ func ArrayMapFunctionMemberFuncResolver( targetRange ast.HasPosition, report func(error), ) *Member { + elementType := arrayType.ElementType(false) - // TODO: maybe allow for resource element type as a reference. if elementType.IsResourceType() { report( &InvalidResourceArrayMemberError{ @@ -2924,54 +2953,84 @@ func ArrayMapFunctionMemberFuncResolver( memoryGauge, arrayType, identifier, - ArrayMapFunctionType( - memoryGauge, - accessedType, - arrayType, - ), - arrayTypeMapFunctionDocString, + ArrayToConstantSizedFunctionType(memoryGauge, accessedType, elementType), + arrayTypeToConstantSizedFunctionDocString, + ) + } +} + +func ArrayRemoveFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { + elementType := arrayType.ElementType(false) + return NewFunctionMember( + memoryGauge, + arrayType, + removeMutateEntitledAccess, + identifier, + ArrayRemoveFunctionType(memoryGauge, accessedType, elementType), + arrayTypeRemoveFunctionDocString, + ) + } +} + +func ArrayRemoveFirstFunctionMemberFuncResolver( + accessedType Type, + arrayType ArrayType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { + elementType := arrayType.ElementType(false) + return NewFunctionMember( + memoryGauge, + arrayType, + removeMutateEntitledAccess, + identifier, + ArrayRemoveFirstFunctionType(memoryGauge, accessedType, elementType), + arrayTypeRemoveFirstFunctionDocString, ) } } -func ArrayFilterFunctionMemberFuncResolver( +func ArrayRemoveLastFunctionMemberFuncResolver( accessedType Type, arrayType ArrayType, ) ResolveMemberFunc { return func( memoryGauge common.MemoryGauge, identifier string, - targetRange ast.HasPosition, - report func(error), + _ ast.HasPosition, + _ func(error), ) *Member { - elementType := arrayType.ElementType(false) - - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } - - return NewPublicFunctionMember( + return NewFunctionMember( memoryGauge, arrayType, + removeMutateEntitledAccess, identifier, - ArrayFilterFunctionType( - memoryGauge, - accessedType, - elementType, - ), - arrayTypeFilterFunctionDocString, + ArrayRemoveLastFunctionType(memoryGauge, accessedType, elementType), + arrayTypeRemoveLastFunctionDocString, ) } } -func ArrayRemoveLastFunctionType(elementType Type) *FunctionType { +func ArrayRemoveLastFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, nil, @@ -2979,7 +3038,12 @@ func ArrayRemoveLastFunctionType(elementType Type) *FunctionType { ) } -func ArrayRemoveFirstFunctionType(elementType Type) *FunctionType { +func ArrayRemoveFirstFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, nil, @@ -2987,7 +3051,12 @@ func ArrayRemoveFirstFunctionType(elementType Type) *FunctionType { ) } -func ArrayRemoveFunctionType(elementType Type) *FunctionType { +func ArrayRemoveFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -3000,6 +3069,42 @@ func ArrayRemoveFunctionType(elementType Type) *FunctionType { ) } +// intersectArrayElementReferences returns elementType with any inner reference +// authorizations intersected with the outer authorization of accessedType (when +// accessedType is a reference). For non-reference accessedType, or when the +// element type contains no references, elementType is returned unchanged. +// +// This is the same cascading rule applied during indexing: when an array is +// accessed via a reference, the outer reference's authorization caps any +// inner-element references extracted (or otherwise read out) from the array. +// Without it, copy/extract methods would escalate inner-element entitlements +// beyond what the outer reference grants. +// +// Note that unlike GetDescendantReferenceType, this function does NOT wrap +// non-reference element types in a reference. It only intersects authorizations +// of references that already exist within the element type. This is appropriate +// for methods that return element values (or copies of arrays of element +// values), which must preserve the value/reference distinction of the original +// element type. +func intersectArrayElementReferences( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) Type { + outerRef, ok := MaybeReferenceType(accessedType) + if !ok { + return elementType + } + if !elementType.IsOrContainsReferenceType() { + return elementType + } + return intersectReferenceAuthorizationsInType( + memoryGauge, + elementType, + outerRef.Authorization, + ) +} + func ArrayInsertFunctionType(elementType Type) *FunctionType { return NewSimpleFunctionType( FunctionPurityImpure, @@ -3018,18 +3123,46 @@ func ArrayInsertFunctionType(elementType Type) *FunctionType { ) } -func ArrayConcatFunctionType(arrayType Type) *FunctionType { - typeAnnotation := NewTypeAnnotation(arrayType) +func ArrayConcatFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + arrayType ArrayType, +) *FunctionType { + + // `other` (the input) stays at the array's declared element type, so the + // caller can pass arrays of references whose authorizations match the + // receiver's declared element type. The return type's element references + // are intersected with the outer authorization, because the returned array + // contains elements that originated from the receiver — extracting them + // through the outer reference must not escalate inner-element entitlements + // beyond what the outer reference grants. + paramTypeAnnotation := NewTypeAnnotation(arrayType) + + returnElementType := intersectArrayElementReferences( + memoryGauge, + accessedType, + arrayType.ElementType(false), + ) + var returnArrayType Type + switch arrayType := arrayType.(type) { + case *VariableSizedType: + returnArrayType = NewVariableSizedType(memoryGauge, returnElementType) + case *ConstantSizedType: + returnArrayType = NewConstantSizedType(memoryGauge, returnElementType, arrayType.Size) + default: + panic(errors.NewUnreachableError()) + } + return NewSimpleFunctionType( FunctionPurityView, []Parameter{ { Label: ArgumentLabelNotRequired, Identifier: "other", - TypeAnnotation: typeAnnotation, + TypeAnnotation: paramTypeAnnotation, }, }, - typeAnnotation, + NewTypeAnnotation(returnArrayType), ) } @@ -3090,7 +3223,12 @@ func ArrayAppendFunctionType(elementType Type) *FunctionType { ) } -func ArraySliceFunctionType(elementType Type) *FunctionType { +func ArraySliceFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityView, []Parameter{ @@ -3109,7 +3247,26 @@ func ArraySliceFunctionType(elementType Type) *FunctionType { ) } -func ArrayToVariableSizedFunctionType(elementType Type) *FunctionType { +func ArrayToVariableSizedFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + + // When the array is accessed via a reference, the returned copy's element + // references must be intersected with the outer reference's authorization, + // the same way indexing does. Otherwise a copy could escalate inner-element + // entitlements beyond what the outer reference grants. + if ShouldReturnReference(accessedType, elementType, false) { + outerRef, _ := MaybeReferenceType(accessedType) + elementType = GetDescendantReferenceType( + memoryGauge, + elementType, + UnauthorizedAccess, + outerRef.Authorization, + ) + } + return NewSimpleFunctionType( FunctionPurityView, []Parameter{}, @@ -3119,7 +3276,13 @@ func ArrayToVariableSizedFunctionType(elementType Type) *FunctionType { ) } -func ArrayToConstantSizedFunctionType(elementType Type) *FunctionType { +func ArrayToConstantSizedFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + // Ideally this should have a typebound of [T; _] but since we don't know // the size of the ConstantSizedArray, we omit specifying the bound. typeParameter := &TypeParameter{ @@ -3156,7 +3319,7 @@ func ArrayToConstantSizedFunctionType(elementType Type) *FunctionType { } constArrayType, ok := typeArg.(*ConstantSizedType) - if !ok || constArrayType.Type != elementType { + if !ok || !constArrayType.Type.Equal(elementType) { errorRange := invocationRange if len(astTypeArguments) > 0 { errorRange = astTypeArguments[0] @@ -3176,10 +3339,30 @@ func ArrayToConstantSizedFunctionType(elementType Type) *FunctionType { } } -func ArrayReverseFunctionType(arrayType ArrayType) *FunctionType { +func ArrayReverseFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + arrayType ArrayType, +) *FunctionType { + + returnElementType := intersectArrayElementReferences( + memoryGauge, + accessedType, + arrayType.ElementType(false), + ) + var returnArrayType Type + switch arrayType := arrayType.(type) { + case *VariableSizedType: + returnArrayType = NewVariableSizedType(memoryGauge, returnElementType) + case *ConstantSizedType: + returnArrayType = NewConstantSizedType(memoryGauge, returnElementType, arrayType.Size) + default: + panic(errors.NewUnreachableError()) + } + return &FunctionType{ Parameters: []Parameter{}, - ReturnTypeAnnotation: NewTypeAnnotation(arrayType), + ReturnTypeAnnotation: NewTypeAnnotation(returnArrayType), Purity: FunctionPurityView, } } @@ -6896,67 +7079,16 @@ func (t *DictionaryType) GetMembers() map[string]MemberResolver { }, }, DictionaryTypeInsertFunctionName: { - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { - return NewFunctionMember( - memoryGauge, - t, - insertMutateEntitledAccess, - identifier, - DictionaryInsertFunctionType(t), - dictionaryTypeInsertFunctionDocString, - ) - }, + Kind: common.DeclarationKindFunction, + Resolve: DictionaryInsertFunctionMemberFuncResolver(t, t), }, DictionaryTypeRemoveFunctionName: { - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - _ ast.HasPosition, - _ func(error), - ) *Member { - return NewFunctionMember( - memoryGauge, - t, - removeMutateEntitledAccess, - identifier, - DictionaryRemoveFunctionType(t), - dictionaryTypeRemoveFunctionDocString, - ) - }, + Kind: common.DeclarationKindFunction, + Resolve: DictionaryRemoveFunctionMemberFuncResolver(t, t), }, DictionaryTypeForEachKeyFunctionName: { - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { - if t.KeyType.IsResourceType() { - report( - &InvalidResourceDictionaryMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindField, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } - - return NewPublicFunctionMember( - memoryGauge, - t, - identifier, - DictionaryForEachKeyFunctionType(t), - dictionaryTypeForEachKeyFunctionDocString, - ) - }, + Kind: common.DeclarationKindFunction, + Resolve: DictionaryForEachKeyFunctionMemberFuncResolver(t, t), }, }, ) @@ -6978,7 +7110,15 @@ func DictionaryContainsKeyFunctionType(t *DictionaryType) *FunctionType { ) } -func DictionaryInsertFunctionType(t *DictionaryType) *FunctionType { +func DictionaryInsertFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + t *DictionaryType, +) *FunctionType { + // The returned previous-value comes from the dictionary, so its inner + // references are intersected with the outer authorization. The value + // parameter stays at the declared type — the caller provides it. + returnValueType := intersectArrayElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -6994,13 +7134,18 @@ func DictionaryInsertFunctionType(t *DictionaryType) *FunctionType { }, NewTypeAnnotation( &OptionalType{ - Type: t.ValueType, + Type: returnValueType, }, ), ) } -func DictionaryRemoveFunctionType(t *DictionaryType) *FunctionType { +func DictionaryRemoveFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + t *DictionaryType, +) *FunctionType { + returnValueType := intersectArrayElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -7011,22 +7156,31 @@ func DictionaryRemoveFunctionType(t *DictionaryType) *FunctionType { }, NewTypeAnnotation( &OptionalType{ - Type: t.ValueType, + Type: returnValueType, }, ), ) } -func DictionaryForEachKeyFunctionType(t *DictionaryType) *FunctionType { +func DictionaryForEachKeyFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + t *DictionaryType, +) *FunctionType { const functionPurity = FunctionPurityImpure + // Keys are passed into the user's callback, so their inner references are + // intersected with the outer authorization (the callback can't be given + // references that exceed what the outer reference grants). + keyType := intersectArrayElementReferences(memoryGauge, accessedType, t.KeyType) + // fun(K): Bool funcType := NewSimpleFunctionType( functionPurity, []Parameter{ { Identifier: "key", - TypeAnnotation: NewTypeAnnotation(t.KeyType), + TypeAnnotation: NewTypeAnnotation(keyType), }, }, BoolTypeAnnotation, @@ -7046,6 +7200,78 @@ func DictionaryForEachKeyFunctionType(t *DictionaryType) *FunctionType { ) } +func DictionaryInsertFunctionMemberFuncResolver( + accessedType Type, + dictionaryType *DictionaryType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { + return NewFunctionMember( + memoryGauge, + dictionaryType, + insertMutateEntitledAccess, + identifier, + DictionaryInsertFunctionType(memoryGauge, accessedType, dictionaryType), + dictionaryTypeInsertFunctionDocString, + ) + } +} + +func DictionaryRemoveFunctionMemberFuncResolver( + accessedType Type, + dictionaryType *DictionaryType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { + return NewFunctionMember( + memoryGauge, + dictionaryType, + removeMutateEntitledAccess, + identifier, + DictionaryRemoveFunctionType(memoryGauge, accessedType, dictionaryType), + dictionaryTypeRemoveFunctionDocString, + ) + } +} + +func DictionaryForEachKeyFunctionMemberFuncResolver( + accessedType Type, + dictionaryType *DictionaryType, +) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + targetRange ast.HasPosition, + report func(error), + ) *Member { + if dictionaryType.KeyType.IsResourceType() { + report( + &InvalidResourceDictionaryMemberError{ + Name: identifier, + DeclarationKind: common.DeclarationKindField, + Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), + }, + ) + } + + return NewPublicFunctionMember( + memoryGauge, + dictionaryType, + identifier, + DictionaryForEachKeyFunctionType(memoryGauge, accessedType, dictionaryType), + dictionaryTypeForEachKeyFunctionDocString, + ) + } +} + func (*DictionaryType) isValueIndexableType() bool { return true } @@ -7854,6 +8080,8 @@ func (t *ReferenceType) overloadMembers(members map[string]MemberResolver) { switch ty := t.Type.(type) { case ArrayType: t.overloadArrayReferenceMembers(ty, members) + case *DictionaryType: + t.overloadDictionaryReferenceMembers(ty, members) } } @@ -7868,6 +8096,71 @@ func (t *ReferenceType) overloadArrayReferenceMembers(arrayType ArrayType, membe Kind: common.DeclarationKindFunction, Resolve: ArrayMapFunctionMemberFuncResolver(t, arrayType), } + + members[ArrayTypeReverseFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayReverseFunctionMemberFuncResolver(t, arrayType), + } + + if _, ok := arrayType.(*VariableSizedType); ok { + members[ArrayTypeConcatFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayConcatFunctionMemberFuncResolver(t, arrayType), + } + + members[ArrayTypeSliceFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArraySliceFunctionMemberFuncResolver(t, arrayType), + } + + members[ArrayTypeRemoveFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveFunctionMemberFuncResolver(t, arrayType), + } + + members[ArrayTypeRemoveFirstFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveFirstFunctionMemberFuncResolver(t, arrayType), + } + + members[ArrayTypeRemoveLastFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayRemoveLastFunctionMemberFuncResolver(t, arrayType), + } + + members[ArrayTypeToConstantSizedFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayToConstantSizedFunctionMemberFuncResolver(t, arrayType), + } + } + + if _, ok := arrayType.(*ConstantSizedType); ok { + members[ArrayTypeToVariableSizedFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: ArrayToVariableSizedFunctionMemberFuncResolver(t, arrayType), + } + } +} + +func (t *ReferenceType) overloadDictionaryReferenceMembers( + dictionaryType *DictionaryType, + members map[string]MemberResolver, +) { + + members[DictionaryTypeInsertFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: DictionaryInsertFunctionMemberFuncResolver(t, dictionaryType), + } + + members[DictionaryTypeRemoveFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: DictionaryRemoveFunctionMemberFuncResolver(t, dictionaryType), + } + + members[DictionaryTypeForEachKeyFunctionName] = MemberResolver{ + Kind: common.DeclarationKindFunction, + Resolve: DictionaryForEachKeyFunctionMemberFuncResolver(t, dictionaryType), + } } const AddressTypeName = "Address" From 0ba7e55fa1ae2b14ad4b39995915200c600f1e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 21 May 2026 14:42:36 -0700 Subject: [PATCH 037/139] update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index e7327ca767..68c4881476 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3" +const Version = "v1.10.4-rc.1" From c0a251b274e116f491e008b66a3eef8a590a9506 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 21 May 2026 16:03:07 -0700 Subject: [PATCH 038/139] Update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index e7327ca767..68c4881476 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3" +const Version = "v1.10.4-rc.1" From e591617f10a81983652736976b367dc507b0d10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 10:34:38 -0700 Subject: [PATCH 039/139] rename function, as it used for arrays and dictionaries --- sema/type.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/sema/type.go b/sema/type.go index a26800416d..de5c0cac20 100644 --- a/sema/type.go +++ b/sema/type.go @@ -3030,7 +3030,7 @@ func ArrayRemoveLastFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, nil, @@ -3043,7 +3043,7 @@ func ArrayRemoveFirstFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, nil, @@ -3056,7 +3056,7 @@ func ArrayRemoveFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -3069,7 +3069,7 @@ func ArrayRemoveFunctionType( ) } -// intersectArrayElementReferences returns elementType with any inner reference +// intersectContainerElementReferences returns elementType with any inner reference // authorizations intersected with the outer authorization of accessedType (when // accessedType is a reference). For non-reference accessedType, or when the // element type contains no references, elementType is returned unchanged. @@ -3086,7 +3086,7 @@ func ArrayRemoveFunctionType( // for methods that return element values (or copies of arrays of element // values), which must preserve the value/reference distinction of the original // element type. -func intersectArrayElementReferences( +func intersectContainerElementReferences( memoryGauge common.MemoryGauge, accessedType Type, elementType Type, @@ -3138,7 +3138,7 @@ func ArrayConcatFunctionType( // beyond what the outer reference grants. paramTypeAnnotation := NewTypeAnnotation(arrayType) - returnElementType := intersectArrayElementReferences( + returnElementType := intersectContainerElementReferences( memoryGauge, accessedType, arrayType.ElementType(false), @@ -3228,7 +3228,7 @@ func ArraySliceFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityView, []Parameter{ @@ -3281,7 +3281,7 @@ func ArrayToConstantSizedFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectArrayElementReferences(memoryGauge, accessedType, elementType) + elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) // Ideally this should have a typebound of [T; _] but since we don't know // the size of the ConstantSizedArray, we omit specifying the bound. @@ -3345,7 +3345,7 @@ func ArrayReverseFunctionType( arrayType ArrayType, ) *FunctionType { - returnElementType := intersectArrayElementReferences( + returnElementType := intersectContainerElementReferences( memoryGauge, accessedType, arrayType.ElementType(false), @@ -7118,7 +7118,7 @@ func DictionaryInsertFunctionType( // The returned previous-value comes from the dictionary, so its inner // references are intersected with the outer authorization. The value // parameter stays at the declared type — the caller provides it. - returnValueType := intersectArrayElementReferences(memoryGauge, accessedType, t.ValueType) + returnValueType := intersectContainerElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -7145,7 +7145,7 @@ func DictionaryRemoveFunctionType( accessedType Type, t *DictionaryType, ) *FunctionType { - returnValueType := intersectArrayElementReferences(memoryGauge, accessedType, t.ValueType) + returnValueType := intersectContainerElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, []Parameter{ @@ -7172,7 +7172,7 @@ func DictionaryForEachKeyFunctionType( // Keys are passed into the user's callback, so their inner references are // intersected with the outer authorization (the callback can't be given // references that exceed what the outer reference grants). - keyType := intersectArrayElementReferences(memoryGauge, accessedType, t.KeyType) + keyType := intersectContainerElementReferences(memoryGauge, accessedType, t.KeyType) // fun(K): Bool funcType := NewSimpleFunctionType( From 353a23d846ca9603e4c364f31b836f1271ce4f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 14:12:15 -0700 Subject: [PATCH 040/139] refactor ShouldReturnReference/MaybeReferenceType/GetDescendantReferenceType into GetDescendantTypeForAccess --- sema/check_expression.go | 14 +- sema/check_for.go | 41 +--- sema/check_member_expression.go | 29 +++ sema/entitlements_test.go | 418 ++++++++++++++++++++++++++++++++ sema/type.go | 192 +++++++++++---- 5 files changed, 613 insertions(+), 81 deletions(-) diff --git a/sema/check_expression.go b/sema/check_expression.go index 5c30c27c1e..8105c957d4 100644 --- a/sema/check_expression.go +++ b/sema/check_expression.go @@ -400,21 +400,17 @@ func (checker *Checker) visitIndexExpression( // 2) is container-typed, // then the element type should also be a reference. // Otherwise, if the member is already a reference, then again, a reference must be returned. - returnReference := false - if ShouldReturnReference(valueIndexedType, elementType, isAssignment) { + returnReference := ShouldReturnReference(valueIndexedType, elementType, isAssignment) + if returnReference { // For index expressions, non-reference elements are un-authorized. // For reference elements, the authorization is the intersection of // the outer (container) reference's authorization and the inner (element) reference's authorization. - outerRef, _ := MaybeReferenceType(valueIndexedType) - elementType = GetDescendantReferenceType( + elementType = GetDescendantTypeForAccess( checker.memoryGauge, + valueIndexedType, elementType, - UnauthorizedAccess, - outerRef.Authorization, + isAssignment, ) - - // Store the result in elaboration, so the interpreter can re-use this. - returnReference = true } checker.Elaboration.SetIndexExpressionTypes( diff --git a/sema/check_for.go b/sema/check_for.go index b5d8109d0b..fd7b8b3277 100644 --- a/sema/check_for.go +++ b/sema/check_for.go @@ -146,39 +146,24 @@ func (checker *Checker) loopVariableType(valueType Type, hasPosition ast.HasPosi // b) A primitive type, then the loop-var is the concrete type itself. if referenceType, ok := valueType.(*ReferenceType); ok { - referencedType := referenceType.Type - referencedIterableElementType := checker.iterableElementType(referencedType, hasPosition) + referencedIterableElementType := checker.iterableElementType(referenceType.Type, hasPosition) if referencedIterableElementType.IsInvalidType() { return referencedIterableElementType } - // Case (a): Element type is a container type. - // Then the loop-var must also be a reference type. - if referencedIterableElementType.ContainFieldsOrElements() { - return GetDescendantReferenceType( - checker.memoryGauge, - referencedIterableElementType, - UnauthorizedAccess, - referenceType.Authorization, - ) - } - - // Case (a'): Element type is a reference type. - // Intersect the outer (container) reference's authorization - // with the inner (element) reference's authorization. - if _, isRef := referencedIterableElementType.(*ReferenceType); isRef { - return GetDescendantReferenceType( - checker.memoryGauge, - referencedIterableElementType, - UnauthorizedAccess, - referenceType.Authorization, - ) - } - - // Case (b): Element type is a primitive type. - // Then the loop-var must be the concrete type. - return referencedIterableElementType + // Element type is exposed via the same cascading rule as indexing / + // field access: + // - Container-typed elements (case a): exposed as references. + // - Reference elements (case a'): outer ref's authorization + // intersected with inner. + // - Primitive elements (case b): exposed as the concrete type. + return GetDescendantTypeForAccess( + checker.memoryGauge, + valueType, + referencedIterableElementType, + false, + ) } // If it's not a reference, then simply get the element type. diff --git a/sema/check_member_expression.go b/sema/check_member_expression.go index bb0c4e68d2..ef5d7e5e1b 100644 --- a/sema/check_member_expression.go +++ b/sema/check_member_expression.go @@ -210,6 +210,35 @@ func MaybeReferenceType(typ Type) (*ReferenceType, bool) { return refType, isReference } +// GetDescendantTypeForAccess returns the type that a descendant (member or element) +// should have when read through `accessedType`. +// When `accessedType` is a reference, and the descendant warrants becoming a reference per ShouldReturnReference, +// the descendant is wrapped via GetDescendantReferenceType with an unauthorized wrapping authorization, +// intersecting any inner reference authorizations with the outer reference's authorization. +// Otherwise (for owned access, for primitive descendants, or in assignment contexts): +// `descendantType` is returned unchanged. +// This encapsulates the cascading rule that applies uniformly when reading element/member data +// out of a referenced container or composite. +// Call sites that need a custom wrapping authorization (e.g. mapped field access) +// keep using GetDescendantReferenceType directly. +func GetDescendantTypeForAccess( + memoryGauge common.MemoryGauge, + accessedType Type, + descendantType Type, + isAssignment bool, +) Type { + if !ShouldReturnReference(accessedType, descendantType, isAssignment) { + return descendantType + } + outerRef, _ := MaybeReferenceType(accessedType) + return GetDescendantReferenceType( + memoryGauge, + descendantType, + UnauthorizedAccess, + outerRef.Authorization, + ) +} + func (checker *Checker) visitMember(expression *ast.MemberExpression, isAssignment bool) ( accessedType Type, resultingType Type, diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 3f0e26367c..dc5c459dc2 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -9236,6 +9236,424 @@ func TestInvalidCheckEntitlementEscalation(t *testing.T) { } +func TestCheckContainerMethodElementCascading(t *testing.T) { + + t.Parallel() + + // Each test exercises the four reference/non-reference combinations: + // + // case 1: outer non-ref, inner non-ref (S) + // case 2: outer ref, inner non-ref (S) — diverges + // case 3: outer non-ref, inner ref (&S) + // case 4: outer ref, inner ref (&S) — auth intersected + // + // Public copy methods route their return element type through + // GetDescendantTypeForAccess: case 2 wraps the non-reference element type + // S in a reference (&S), because the receiver doesn't own the elements + // when accessed through a reference. + // + // Entitled mutating/extracting methods (require Insert/Remove/Mutate + // access) route their return value/element type through + // intersectContainerElementReferences: case 2 preserves S, so resource + // values can be transferred out via `<-`. + // + // S is an empty struct so that ShouldReturnReference's + // ContainFieldsOrElements check returns true for it; otherwise case 2 + // would never wrap. + + t.Run("Public copy methods", func(t *testing.T) { + + t.Parallel() + + // case 2 wraps S to &S + + t.Run("array filter", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let r1: [S] = a1.filter(view fun (_: S): Bool { return true }) + + // case 2: outer ref wraps S to &S + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.filter(view fun (_: &S): Bool { return true }) + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.filter(view fun (_: &S): Bool { return true }) + + // case 4: outer ref, inner ref already, auth intersected + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.filter(view fun (_: &S): Bool { return true }) + } + `) + require.NoError(t, err) + }) + + t.Run("array map", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let r1: [Int] = a1.map(view fun (_: S): Int { return 0 }) + + // case 2 + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [Int] = ref2.map(view fun (_: &S): Int { return 0 }) + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [Int] = a3.map(view fun (_: &S): Int { return 0 }) + + // case 4 + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [Int] = ref4.map(view fun (_: &S): Int { return 0 }) + } + `) + require.NoError(t, err) + }) + + t.Run("array slice", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let r1: [S] = a1.slice(from: 0, upTo: 1) + + // case 2: S wrapped to &S + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.slice(from: 0, upTo: 1) + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.slice(from: 0, upTo: 1) + + // case 4 + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.slice(from: 0, upTo: 1) + } + `) + require.NoError(t, err) + }) + + t.Run("array concat", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let other1: [S] = [] + let r1: [S] = a1.concat(other1) + + // case 2: return wraps to [&S]; the param stays at the declared type + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let other2: [S] = [] + let r2: [&S] = ref2.concat(other2) + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let other3: [&S] = [] + let r3: [&S] = a3.concat(other3) + + // case 4 + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let other4: [&S] = [] + let r4: [&S] = ref4.concat(other4) + } + `) + require.NoError(t, err) + }) + + t.Run("array reverse", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let r1: [S] = a1.reverse() + + // case 2 + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.reverse() + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.reverse() + + // case 4 + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.reverse() + } + `) + require.NoError(t, err) + }) + + t.Run("array toVariableSized", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S; 1] = [S()] + let r1: [S] = a1.toVariableSized() + + // case 2 + let a2: [S; 1] = [S()] + let ref2 = &a2 as &[S; 1] + let r2: [&S] = ref2.toVariableSized() + + // case 3 + let s3 = S() + let a3: [&S; 1] = [&s3 as &S] + let r3: [&S] = a3.toVariableSized() + + // case 4 + let s4 = S() + let a4: [&S; 1] = [&s4 as &S] + let ref4 = &a4 as &[&S; 1] + let r4: [&S] = ref4.toVariableSized() + } + `) + require.NoError(t, err) + }) + + t.Run("array toConstantSized", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + let a1: [S] = [S()] + let r1: [S; 1]? = a1.toConstantSized<[S; 1]>() + + // case 2: element wrapped to &S; the type argument must match + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S; 1]? = ref2.toConstantSized<[&S; 1]>() + + // case 3 + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S; 1]? = a3.toConstantSized<[&S; 1]>() + + // case 4 + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S; 1]? = ref4.toConstantSized<[&S; 1]>() + } + `) + require.NoError(t, err) + }) + + // dictionary forEachKey: keys must be Hashable, and none of the built-in + // Hashable types have ContainFieldsOrElements=true, so case 2's wrap + // branch isn't reachable in practice. Test what is reachable: the + // callback's key parameter is the declared key type (no cascading applies + // in the wrap sense, but inner-reference intersection inside keys still + // would if such keys existed). + t.Run("dictionary forEachKey", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + fun cases() { + // case 1 + let d1: {String: Int} = {"a": 1} + d1.forEachKey(view fun (_: String): Bool { return true }) + + // case 2: primitive key; no wrap because String has no fields/elements + let d2: {String: Int} = {"a": 1} + let ref2 = &d2 as &{String: Int} + ref2.forEachKey(view fun (_: String): Bool { return true }) + } + `) + require.NoError(t, err) + }) + + }) + + t.Run("Entitled mutating/extracting methods", func(t *testing.T) { + + t.Parallel() + + // case 2 keeps S (no wrap) + + t.Run("array remove", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + var a1: [S] = [S()] + let r1: S = a1.remove(at: 0) + + // case 2: S preserved, not wrapped + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.remove(at: 0) + + // case 3 + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.remove(at: 0) + + // case 4 + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.remove(at: 0) + } + `) + require.NoError(t, err) + }) + + t.Run("array removeFirst", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + var a1: [S] = [S()] + let r1: S = a1.removeFirst() + + // case 2 + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.removeFirst() + + // case 3 + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.removeFirst() + + // case 4 + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.removeFirst() + } + `) + require.NoError(t, err) + }) + + t.Run("array removeLast", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + var a1: [S] = [S()] + let r1: S = a1.removeLast() + + // case 2 + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.removeLast() + + // case 3 + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.removeLast() + + // case 4 + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.removeLast() + } + `) + require.NoError(t, err) + }) + + t.Run("dictionary remove", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + var d1: {String: S} = {"a": S()} + let r1: S? = d1.remove(key: "a") + + // case 2: V (S) stays S + var d2: {String: S} = {"a": S()} + let ref2 = &d2 as auth(Mutate) &{String: S} + let r2: S? = ref2.remove(key: "a") + + // case 3 + let s3 = S() + var d3: {String: &S} = {"a": &s3 as &S} + let r3: (&S)? = d3.remove(key: "a") + + // case 4 + let s4 = S() + var d4: {String: &S} = {"a": &s4 as &S} + let ref4 = &d4 as auth(Mutate) &{String: &S} + let r4: (&S)? = ref4.remove(key: "a") + } + `) + require.NoError(t, err) + }) + + t.Run("dictionary insert", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + access(all) struct S {} + fun cases() { + // case 1 + var d1: {String: S} = {} + let r1: S? = d1.insert(key: "a", S()) + + // case 2: previous value type S stays S + var d2: {String: S} = {} + let ref2 = &d2 as auth(Mutate) &{String: S} + let r2: S? = ref2.insert(key: "a", S()) + + // case 3 + let s3 = S() + var d3: {String: &S} = {} + let r3: (&S)? = d3.insert(key: "a", &s3 as &S) + + // case 4 + let s4 = S() + var d4: {String: &S} = {} + let ref4 = &d4 as auth(Mutate) &{String: &S} + let r4: (&S)? = ref4.insert(key: "a", &s4 as &S) + } + `) + require.NoError(t, err) + }) + + }) + +} + func TestCheckMappingAccessFieldType(t *testing.T) { t.Parallel() diff --git a/sema/type.go b/sema/type.go index de5c0cac20..4018e9b118 100644 --- a/sema/type.go +++ b/sema/type.go @@ -3030,6 +3030,16 @@ func ArrayRemoveLastFunctionType( accessedType Type, elementType Type, ) *FunctionType { + // Use intersectContainerElementReferences instead of + // GetDescendantTypeForAccess (used by the public copy methods). + // + // `removeLast` transfers ownership of the extracted element out of the array, + // so the return type must stay at the element's value type: + // wrapping a non-reference S to &S would break resource transfer + // (`let r <- ref.removeLast()` only type-checks when the return is `R`, not `&R`). + // + // intersectContainerElementReferences still intersects references nested inside the element type + // with the outer authorization, so inner-reference escalation remains closed. elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, @@ -3043,6 +3053,16 @@ func ArrayRemoveFirstFunctionType( accessedType Type, elementType Type, ) *FunctionType { + // Use intersectContainerElementReferences instead of + // GetDescendantTypeForAccess (used by the public copy methods). + // + // `removeFirst` transfers ownership of the extracted element out of the array, + // so the return type must stay at the element's value type: + // wrapping a non-reference S to &S would break resource transfer + // (`let r <- ref.removeFirst()` only type-checks when the return is `R`, not `&R`). + // + // intersectContainerElementReferences still intersects references nested inside the element type + // with the outer authorization, so inner-reference escalation remains closed. elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, @@ -3056,6 +3076,16 @@ func ArrayRemoveFunctionType( accessedType Type, elementType Type, ) *FunctionType { + // Use intersectContainerElementReferences instead of + // GetDescendantTypeForAccess (used by the public copy methods). + // + // `remove` transfers ownership of the extracted element out of the array, + // so the return type must stay at the element's value type: + // wrapping a non-reference S to &S would break resource transfer + // (`let r <- ref.remove(at: 0)` only type-checks when the return is `R`, not `&R`). + // + // intersectContainerElementReferences still intersects references nested inside the element type + // with the outer authorization, so inner-reference escalation remains closed. elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) return NewSimpleFunctionType( FunctionPurityImpure, @@ -3138,11 +3168,21 @@ func ArrayConcatFunctionType( // beyond what the outer reference grants. paramTypeAnnotation := NewTypeAnnotation(arrayType) - returnElementType := intersectContainerElementReferences( + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + returnElementType := GetDescendantTypeForAccess( memoryGauge, accessedType, arrayType.ElementType(false), + false, ) + var returnArrayType Type switch arrayType := arrayType.(type) { case *VariableSizedType: @@ -3228,7 +3268,21 @@ func ArraySliceFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) + + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + elementType = GetDescendantTypeForAccess( + memoryGauge, + accessedType, + elementType, + false, + ) return NewSimpleFunctionType( FunctionPurityView, []Parameter{ @@ -3253,19 +3307,20 @@ func ArrayToVariableSizedFunctionType( elementType Type, ) *FunctionType { - // When the array is accessed via a reference, the returned copy's element - // references must be intersected with the outer reference's authorization, - // the same way indexing does. Otherwise a copy could escalate inner-element - // entitlements beyond what the outer reference grants. - if ShouldReturnReference(accessedType, elementType, false) { - outerRef, _ := MaybeReferenceType(accessedType) - elementType = GetDescendantReferenceType( - memoryGauge, - elementType, - UnauthorizedAccess, - outerRef.Authorization, - ) - } + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + elementType = GetDescendantTypeForAccess( + memoryGauge, + accessedType, + elementType, + false, + ) return NewSimpleFunctionType( FunctionPurityView, @@ -3281,7 +3336,21 @@ func ArrayToConstantSizedFunctionType( accessedType Type, elementType Type, ) *FunctionType { - elementType = intersectContainerElementReferences(memoryGauge, accessedType, elementType) + + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + elementType = GetDescendantTypeForAccess( + memoryGauge, + accessedType, + elementType, + false, + ) // Ideally this should have a typebound of [T; _] but since we don't know // the size of the ConstantSizedArray, we omit specifying the bound. @@ -3345,11 +3414,21 @@ func ArrayReverseFunctionType( arrayType ArrayType, ) *FunctionType { - returnElementType := intersectContainerElementReferences( + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + returnElementType := GetDescendantTypeForAccess( memoryGauge, accessedType, arrayType.ElementType(false), + false, ) + var returnArrayType Type switch arrayType := arrayType.(type) { case *VariableSizedType: @@ -3376,15 +3455,20 @@ func ArrayFilterFunctionType( elementType Type, ) *FunctionType { - if ShouldReturnReference(accessedType, elementType, false) { - outerRef, _ := MaybeReferenceType(accessedType) - elementType = GetDescendantReferenceType( - memoryGauge, - elementType, - UnauthorizedAccess, - outerRef.Authorization, - ) - } + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + elementType = GetDescendantTypeForAccess( + memoryGauge, + accessedType, + elementType, + false, + ) funcType := &FunctionType{ Parameters: []Parameter{ @@ -3442,17 +3526,20 @@ func ArrayMapFunctionType( panic(errors.NewUnreachableError()) } - elementType := arrayType.ElementType(false) - - if ShouldReturnReference(accessedType, elementType, false) { - outerRef, _ := MaybeReferenceType(accessedType) - elementType = GetDescendantReferenceType( - memoryGauge, - elementType, - UnauthorizedAccess, - outerRef.Authorization, - ) - } + // Use GetDescendantTypeForAccess to cascade the outer reference's + // authorization into the returned element type. When the array is + // accessed via a reference, this exposes the element the same way + // indexing and field access do: non-reference fielded types are wrapped + // to references, and inner-reference authorizations are intersected + // with the outer authorization. Without this cascade, a method-driven + // copy could escalate inner-element entitlements beyond what the outer + // reference grants. + elementType := GetDescendantTypeForAccess( + memoryGauge, + accessedType, + arrayType.ElementType(false), + false, + ) transformFuncType := &FunctionType{ Parameters: []Parameter{ @@ -7115,9 +7202,16 @@ func DictionaryInsertFunctionType( accessedType Type, t *DictionaryType, ) *FunctionType { - // The returned previous-value comes from the dictionary, so its inner - // references are intersected with the outer authorization. The value - // parameter stays at the declared type — the caller provides it. + // Use intersectContainerElementReferences instead of + // GetDescendantTypeForAccess (used by the public copy methods). + // + // `insert` transfers ownership of the previous value at the key back to the caller, + // so the return type must stay at the value type: + // wrapping a non-reference V to &V would break resource transfer + // (`let r <- ref.insert(...)` only type-checks when the return is `V?`, not `(&V)?`). + // + // intersectContainerElementReferences still intersects references nested inside the value type + // with the outer authorization, so inner-reference escalation remains closed. returnValueType := intersectContainerElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, @@ -7145,6 +7239,16 @@ func DictionaryRemoveFunctionType( accessedType Type, t *DictionaryType, ) *FunctionType { + // Use intersectContainerElementReferences instead of + // GetDescendantTypeForAccess (used by the public copy methods). + // + // `remove` transfers ownership of the value at the key back to the caller, + // so the return type must stay at the value type: + // wrapping a non-reference V to &V would break resource transfer + // (`let r <- ref.remove(key: "a")` only type-checks when the return is `V?`, not `(&V)?`). + // + // intersectContainerElementReferences still intersects references nested inside the value type + // with the outer authorization, so inner-reference escalation remains closed. returnValueType := intersectContainerElementReferences(memoryGauge, accessedType, t.ValueType) return NewSimpleFunctionType( FunctionPurityImpure, @@ -7169,10 +7273,10 @@ func DictionaryForEachKeyFunctionType( ) *FunctionType { const functionPurity = FunctionPurityImpure - // Keys are passed into the user's callback, so their inner references are - // intersected with the outer authorization (the callback can't be given - // references that exceed what the outer reference grants). - keyType := intersectContainerElementReferences(memoryGauge, accessedType, t.KeyType) + // Keys are passed into the user's callback. When the dictionary is accessed via a reference, + // the callback's key parameter is exposed via the same cascading rule as indexing and field access + // (see GetDescendantTypeForAccess). + keyType := GetDescendantTypeForAccess(memoryGauge, accessedType, t.KeyType, false) // fun(K): Bool funcType := NewSimpleFunctionType( From 9e1fdef4a62aa13138a0db173a355d4314d142b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 14:32:35 -0700 Subject: [PATCH 041/139] concat is only defined for variable-sized arrays --- sema/type.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/sema/type.go b/sema/type.go index 4018e9b118..17bce7305f 100644 --- a/sema/type.go +++ b/sema/type.go @@ -3153,12 +3153,23 @@ func ArrayInsertFunctionType(elementType Type) *FunctionType { ) } +// ArrayConcatFunctionType returns the type for `concat(_:)`. +// +// `concat` is only available on variable-sized arrays — there is no sound +// return type for concatenating two constant-sized arrays without arithmetic +// on the element count, which the type system does not support. Callers in +// getArrayMembers / overloadArrayReferenceMembers register this method only +// when arrayType is *VariableSizedType; passing anything else panics. func ArrayConcatFunctionType( memoryGauge common.MemoryGauge, accessedType Type, arrayType ArrayType, ) *FunctionType { + if _, ok := arrayType.(*VariableSizedType); !ok { + panic(errors.NewUnreachableError()) + } + // `other` (the input) stays at the array's declared element type, so the // caller can pass arrays of references whose authorizations match the // receiver's declared element type. The return type's element references @@ -3183,15 +3194,7 @@ func ArrayConcatFunctionType( false, ) - var returnArrayType Type - switch arrayType := arrayType.(type) { - case *VariableSizedType: - returnArrayType = NewVariableSizedType(memoryGauge, returnElementType) - case *ConstantSizedType: - returnArrayType = NewConstantSizedType(memoryGauge, returnElementType, arrayType.Size) - default: - panic(errors.NewUnreachableError()) - } + returnArrayType := NewVariableSizedType(memoryGauge, returnElementType) return NewSimpleFunctionType( FunctionPurityView, From df8c4b1216dcae5c9bb1561e6d308428c6258145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 16:42:09 -0700 Subject: [PATCH 042/139] add unreachable panics --- interpreter/value_array.go | 10 ++++++++-- sema/check_member_expression.go | 11 +++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 55b742a200..5595e5be32 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1801,7 +1801,10 @@ func (v *ArrayValue) Filter( asReference := sema.ShouldReturnReference(accessedType, elementType, false) if asReference { - outerRef, _ := sema.MaybeReferenceType(accessedType) + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } elementType = sema.GetDescendantReferenceType( context, elementType, @@ -1912,7 +1915,10 @@ func (v *ArrayValue) Map( asReference := sema.ShouldReturnReference(accessedType, elementType, false) if asReference { - outerRef, _ := sema.MaybeReferenceType(accessedType) + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } elementType = sema.GetDescendantReferenceType( context, elementType, diff --git a/sema/check_member_expression.go b/sema/check_member_expression.go index ef5d7e5e1b..d082bc9ea8 100644 --- a/sema/check_member_expression.go +++ b/sema/check_member_expression.go @@ -21,6 +21,7 @@ package sema import ( "github.com/onflow/cadence/ast" "github.com/onflow/cadence/common" + "github.com/onflow/cadence/errors" ) // NOTE: only called if the member expression is *not* an assignment @@ -230,7 +231,10 @@ func GetDescendantTypeForAccess( if !ShouldReturnReference(accessedType, descendantType, isAssignment) { return descendantType } - outerRef, _ := MaybeReferenceType(accessedType) + outerRef, isRef := MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } return GetDescendantReferenceType( memoryGauge, descendantType, @@ -491,7 +495,10 @@ func (checker *Checker) visitMember(expression *ast.MemberExpression, isAssignme // For reference elements, the outer reference's raw authorization is intersected // with the inner reference's authorization (note: mapped access fields cannot have // reference types, so `authorization` and outer auth differ only for non-mapped fields). - outerRef, _ := MaybeReferenceType(accessedType) + outerRef, isRef := MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } resultingType = GetDescendantReferenceType( checker.memoryGauge, resultingType, From 66de21a953ee0b9a589c89302b6df571b0f04ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 17:21:46 -0700 Subject: [PATCH 043/139] adjust container function implementations to new behaviour --- bbq/vm/value_array.go | 10 +- bbq/vm/value_dictionary.go | 17 +- interpreter/misc_test.go | 352 ++++++++++++++++++++++++++++++++ interpreter/value_array.go | 223 +++++++++++++++++--- interpreter/value_dictionary.go | 55 ++++- 5 files changed, 616 insertions(+), 41 deletions(-) diff --git a/bbq/vm/value_array.go b/bbq/vm/value_array.go index aa691fe4ab..1418869805 100644 --- a/bbq/vm/value_array.go +++ b/bbq/vm/value_array.go @@ -70,7 +70,7 @@ func init() { return sema.ArrayReverseFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayReverseFunction, - ), + ).WithDereferenceReceiver(false), ) } @@ -156,7 +156,7 @@ func init() { return sema.ArrayConcatFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayConcatFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -220,7 +220,7 @@ func init() { return sema.ArraySliceFunctionType(context, accessedType, elementType) }, interpreter.NativeArraySliceFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -233,7 +233,7 @@ func init() { return sema.ArrayToConstantSizedFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayToConstantSizedFunction, - ), + ).WithDereferenceReceiver(false), ) // Methods available only for constant-sized arrays @@ -254,7 +254,7 @@ func init() { ) }, interpreter.NativeArrayToVariableSizedFunction, - ), + ).WithDereferenceReceiver(false), ) } diff --git a/bbq/vm/value_dictionary.go b/bbq/vm/value_dictionary.go index c0d5ba509e..f4b3a607cf 100644 --- a/bbq/vm/value_dictionary.go +++ b/bbq/vm/value_dictionary.go @@ -20,6 +20,7 @@ package vm import ( "github.com/onflow/cadence/bbq/commons" + "github.com/onflow/cadence/errors" "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" ) @@ -72,12 +73,11 @@ func init() { sema.DictionaryTypeForEachKeyFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - dictionaryValue := receiver.(*interpreter.DictionaryValue) - dictionaryType := dictionaryValue.SemaType(context) + dictionaryType := dictionaryTypeFromSemaType(accessedType) return sema.DictionaryForEachKeyFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryForEachKeyFunction, - ), + ).WithDereferenceReceiver(false), ) } @@ -86,3 +86,14 @@ func dictionaryType(receiver Value, context interpreter.ValueStaticTypeContext) dictionaryType := dictionaryValue.SemaType(context) return dictionaryType } + +func dictionaryTypeFromSemaType(accessedType sema.Type) *sema.DictionaryType { + switch accessedType := accessedType.(type) { + case *sema.DictionaryType: + return accessedType + case *sema.ReferenceType: + return dictionaryTypeFromSemaType(accessedType.Type) + default: + panic(errors.NewUnreachableError()) + } +} diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index 39da53b8c6..e106d11f04 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -12505,6 +12505,358 @@ func TestInterpretArrayToConstantSized(t *testing.T) { }) } +func TestInterpretContainerMethodElementCascading(t *testing.T) { + + t.Parallel() + + // Runtime counterpart of sema's TestCheckContainerMethodElementCascading. + // + // Each subtest runs the same Cadence code as its sema counterpart, which + // exercises four reference/non-reference combinations: + // case 1: outer non-ref, inner non-ref (S) + // case 2: outer ref, inner non-ref (S) — diverges + // case 3: outer non-ref, inner ref (&S) + // case 4: outer ref, inner ref (&S) — auth intersected + // + // Each case binds the method's result to a typed local. If the runtime + // produces a value whose dynamic type does not satisfy that declared + // type, the interpreter rejects the assignment (or a later operation on + // the value) with a value-transfer / static-type mismatch. So the test + // passes as long as the function runs to completion. + // + // Public copy methods (route through sema.GetDescendantTypeForAccess) + // must wrap S to &S in case 2 at runtime, just as the sema return type + // promises. Entitled mutating/extracting methods (route through + // sema.intersectContainerElementReferences) must preserve S in case 2. + + runCases := func(t *testing.T, code string) { + t.Helper() + inter := parseCheckAndPrepare(t, code) + _, err := inter.Invoke("cases") + require.NoError(t, err) + } + + t.Run("Public copy methods", func(t *testing.T) { + + t.Parallel() + + // case 2 wraps S to &S + + t.Run("array filter", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let r1: [S] = a1.filter(view fun (_: S): Bool { return true }) + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.filter(view fun (_: &S): Bool { return true }) + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.filter(view fun (_: &S): Bool { return true }) + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.filter(view fun (_: &S): Bool { return true }) + } + `) + }) + + t.Run("array map", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let r1: [Int] = a1.map(view fun (_: S): Int { return 0 }) + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [Int] = ref2.map(view fun (_: &S): Int { return 0 }) + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [Int] = a3.map(view fun (_: &S): Int { return 0 }) + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [Int] = ref4.map(view fun (_: &S): Int { return 0 }) + } + `) + }) + + t.Run("array slice", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let r1: [S] = a1.slice(from: 0, upTo: 1) + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.slice(from: 0, upTo: 1) + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.slice(from: 0, upTo: 1) + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.slice(from: 0, upTo: 1) + } + `) + }) + + t.Run("array concat", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let other1: [S] = [] + let r1: [S] = a1.concat(other1) + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let other2: [S] = [] + let r2: [&S] = ref2.concat(other2) + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let other3: [&S] = [] + let r3: [&S] = a3.concat(other3) + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let other4: [&S] = [] + let r4: [&S] = ref4.concat(other4) + } + `) + }) + + t.Run("array reverse", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let r1: [S] = a1.reverse() + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S] = ref2.reverse() + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S] = a3.reverse() + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S] = ref4.reverse() + } + `) + }) + + t.Run("array toVariableSized", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S; 1] = [S()] + let r1: [S] = a1.toVariableSized() + + let a2: [S; 1] = [S()] + let ref2 = &a2 as &[S; 1] + let r2: [&S] = ref2.toVariableSized() + + let s3 = S() + let a3: [&S; 1] = [&s3 as &S] + let r3: [&S] = a3.toVariableSized() + + let s4 = S() + let a4: [&S; 1] = [&s4 as &S] + let ref4 = &a4 as &[&S; 1] + let r4: [&S] = ref4.toVariableSized() + } + `) + }) + + t.Run("array toConstantSized", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + let a1: [S] = [S()] + let r1: [S; 1]? = a1.toConstantSized<[S; 1]>() + + let a2: [S] = [S()] + let ref2 = &a2 as &[S] + let r2: [&S; 1]? = ref2.toConstantSized<[&S; 1]>() + + let s3 = S() + let a3: [&S] = [&s3 as &S] + let r3: [&S; 1]? = a3.toConstantSized<[&S; 1]>() + + let s4 = S() + let a4: [&S] = [&s4 as &S] + let ref4 = &a4 as &[&S] + let r4: [&S; 1]? = ref4.toConstantSized<[&S; 1]>() + } + `) + }) + + t.Run("dictionary forEachKey", func(t *testing.T) { + t.Parallel() + runCases(t, ` + fun cases() { + let d1: {String: Int} = {"a": 1} + d1.forEachKey(view fun (_: String): Bool { return true }) + + let d2: {String: Int} = {"a": 1} + let ref2 = &d2 as &{String: Int} + ref2.forEachKey(view fun (_: String): Bool { return true }) + } + `) + }) + }) + + t.Run("Entitled mutating/extracting methods", func(t *testing.T) { + + t.Parallel() + + // case 2 keeps S (no wrap) + + t.Run("array remove", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + var a1: [S] = [S()] + let r1: S = a1.remove(at: 0) + + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.remove(at: 0) + + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.remove(at: 0) + + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.remove(at: 0) + } + `) + }) + + t.Run("array removeFirst", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + var a1: [S] = [S()] + let r1: S = a1.removeFirst() + + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.removeFirst() + + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.removeFirst() + + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.removeFirst() + } + `) + }) + + t.Run("array removeLast", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + var a1: [S] = [S()] + let r1: S = a1.removeLast() + + var a2: [S] = [S()] + let ref2 = &a2 as auth(Mutate) &[S] + let r2: S = ref2.removeLast() + + let s3 = S() + var a3: [&S] = [&s3 as &S] + let r3: &S = a3.removeLast() + + let s4 = S() + var a4: [&S] = [&s4 as &S] + let ref4 = &a4 as auth(Mutate) &[&S] + let r4: &S = ref4.removeLast() + } + `) + }) + + t.Run("dictionary remove", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + var d1: {String: S} = {"a": S()} + let r1: S? = d1.remove(key: "a") + + var d2: {String: S} = {"a": S()} + let ref2 = &d2 as auth(Mutate) &{String: S} + let r2: S? = ref2.remove(key: "a") + + let s3 = S() + var d3: {String: &S} = {"a": &s3 as &S} + let r3: (&S)? = d3.remove(key: "a") + + let s4 = S() + var d4: {String: &S} = {"a": &s4 as &S} + let ref4 = &d4 as auth(Mutate) &{String: &S} + let r4: (&S)? = ref4.remove(key: "a") + } + `) + }) + + t.Run("dictionary insert", func(t *testing.T) { + t.Parallel() + runCases(t, ` + access(all) struct S {} + fun cases() { + var d1: {String: S} = {} + let r1: S? = d1.insert(key: "a", S()) + + var d2: {String: S} = {} + let ref2 = &d2 as auth(Mutate) &{String: S} + let r2: S? = ref2.insert(key: "a", S()) + + let s3 = S() + var d3: {String: &S} = {} + let r3: (&S)? = d3.insert(key: "a", &s3 as &S) + + let s4 = S() + var d4: {String: &S} = {} + let ref4 = &d4 as auth(Mutate) &{String: &S} + let r4: (&S)? = ref4.insert(key: "a", &s4 as &S) + } + `) + }) + }) +} + func TestInterpretCastingBoxing(t *testing.T) { t.Parallel() diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 5595e5be32..6112371b6b 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -428,7 +428,11 @@ func (v *ArrayValue) IsDestroyed() bool { return v.isDestroyed } -func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Value { +func (v *ArrayValue) Concat( + context ValueTransferContext, + other *ArrayValue, + accessedType sema.Type, +) Value { first := true @@ -444,7 +448,27 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val panic(errors.NewExternalError(err)) } - elementType := v.Type.ElementType() + // `other`'s elements are checked against the receiver's declared element + // type (param type stayed at the declared type — the caller provided it). + otherElementType := v.Type.ElementType() + + // Cascade outer authorization into the result element type, matching + // sema's ArrayConcatFunctionType — see sema.GetDescendantTypeForAccess. + resultElementSemaType := v.SemaType(context).ElementType(false) + asReference := sema.ShouldReturnReference(accessedType, resultElementSemaType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + resultElementSemaType = sema.GetDescendantReferenceType( + context, + resultElementSemaType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } + resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) newCount := v.array.Count() + other.array.Count() @@ -458,7 +482,7 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val return NewArrayValueWithIterator( context, - v.Type, + NewVariableSizedStaticType(context, resultElementStaticType), common.ZeroAddress, newCount, func() Value { @@ -491,7 +515,7 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val if atreeValue != nil { value = MustConvertStoredValue(context, atreeValue) - checkContainerMutation(context, elementType, value) + checkContainerMutation(context, otherElementType, value) } } @@ -499,6 +523,10 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val return nil } + if asReference { + value = getReferenceValue(context, value, resultElementSemaType) + } + return value.Transfer( context, atree.Address{}, @@ -1016,7 +1044,12 @@ func (v *ArrayValue) GetMethod( arrayType, ), NativeArrayConcatFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the result element + // type, matching sema.ArrayConcatFunctionType's return. + WithDereferenceReceiver(false) case sema.ArrayTypeInsertFunctionName: return NewBoundHostFunctionValue( @@ -1101,7 +1134,12 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArraySliceFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the result element + // type, matching sema.ArraySliceFunctionType's return. + WithDereferenceReceiver(false) case sema.ArrayTypeReverseFunctionName: return NewBoundHostFunctionValue( @@ -1114,7 +1152,12 @@ func (v *ArrayValue) GetMethod( arrayType, ), NativeArrayReverseFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the result element + // type, matching sema.ArrayReverseFunctionType's return. + WithDereferenceReceiver(false) case sema.ArrayTypeFilterFunctionName: return NewBoundHostFunctionValue( @@ -1159,7 +1202,13 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArrayToVariableSizedFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the result element + // type, matching sema.ArrayToVariableSizedFunctionType's + // return. + WithDereferenceReceiver(false) case sema.ArrayTypeToConstantSizedFunctionName: return NewBoundHostFunctionValue( @@ -1172,7 +1221,13 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArrayToConstantSizedFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the result element + // type, matching sema.ArrayToConstantSizedFunctionType's + // return. + WithDereferenceReceiver(false) } return nil @@ -1676,6 +1731,7 @@ func (v *ArrayValue) Slice( context ArrayCreationContext, from IntValue, to IntValue, + accessedType sema.Type, ) Value { fromIndex := from.ToInt() toIndex := to.ToInt() @@ -1725,9 +1781,30 @@ func (v *ArrayValue) Slice( }, ) + // Cascade outer authorization into the result element type, matching + // sema's ArraySliceFunctionType: when sliced through a reference, + // elements are exposed as references (with auths intersected) — see + // sema.GetDescendantTypeForAccess. Without this, the new array's + // declared element type would mismatch its actual contents. + elementType := v.SemaType(context).ElementType(false) + asReference := sema.ShouldReturnReference(accessedType, elementType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + elementType = sema.GetDescendantReferenceType( + context, + elementType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } + resultElementStaticType := ConvertSemaToStaticType(context, elementType) + return NewArrayValueWithIterator( context, - NewVariableSizedStaticType(context, v.Type.ElementType()), + NewVariableSizedStaticType(context, resultElementStaticType), common.ZeroAddress, newCount, func() Value { @@ -1748,6 +1825,10 @@ func (v *ArrayValue) Slice( return nil } + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -1762,13 +1843,45 @@ func (v *ArrayValue) Slice( func (v *ArrayValue) Reverse( context ArrayCreationContext, + accessedType sema.Type, ) Value { count := v.Count() index := count - 1 + // Cascade outer authorization into the result element type, matching + // sema's ArrayReverseFunctionType — see sema.GetDescendantTypeForAccess. + semaArrayType := v.SemaType(context) + elementType := semaArrayType.ElementType(false) + asReference := sema.ShouldReturnReference(accessedType, elementType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + elementType = sema.GetDescendantReferenceType( + context, + elementType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } + + // reverse() preserves the array shape (variable- or constant-sized), + // so reuse v.Type's shape but with the cascaded element type. + resultElementStaticType := ConvertSemaToStaticType(context, elementType) + var resultStaticType ArrayStaticType + switch t := v.Type.(type) { + case *VariableSizedStaticType: + resultStaticType = NewVariableSizedStaticType(context, resultElementStaticType) + case *ConstantSizedStaticType: + resultStaticType = NewConstantSizedStaticType(context, resultElementStaticType, t.Size) + default: + panic(errors.NewUnreachableError()) + } + return NewArrayValueWithIterator( context, - v.Type, + resultStaticType, common.ZeroAddress, uint64(count), func() Value { @@ -1779,6 +1892,10 @@ func (v *ArrayValue) Reverse( value := v.Get(context, index) index-- + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -2027,20 +2144,35 @@ func (v *ArrayValue) ForEach( func (v *ArrayValue) ToVariableSized( context ArrayCreationContext, + accessedType sema.Type, ) Value { count := v.Count() // Convert the constant-sized array type to a variable-sized array type. - constantSizedType, ok := v.Type.(*ConstantSizedStaticType) - if !ok { + if _, ok := v.Type.(*ConstantSizedStaticType); !ok { panic(errors.NewUnreachableError()) } - variableSizedType := NewVariableSizedStaticType( - context, - constantSizedType.Type, - ) + // Cascade outer authorization into the result element type, matching + // sema's ArrayToVariableSizedFunctionType — see + // sema.GetDescendantTypeForAccess. + elementType := v.SemaType(context).ElementType(false) + asReference := sema.ShouldReturnReference(accessedType, elementType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + elementType = sema.GetDescendantReferenceType( + context, + elementType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } + resultElementStaticType := ConvertSemaToStaticType(context, elementType) + variableSizedType := NewVariableSizedStaticType(context, resultElementStaticType) // Convert the array to a variable-sized array. @@ -2078,6 +2210,10 @@ func (v *ArrayValue) ToVariableSized( value := MustConvertStoredValue(context, atreeValue) + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -2093,6 +2229,7 @@ func (v *ArrayValue) ToVariableSized( func (v *ArrayValue) ToConstantSized( context ArrayCreationContext, expectedConstantSizedArraySize int64, + accessedType sema.Type, ) OptionalValue { // Ensure the array has the expected size. @@ -2105,14 +2242,31 @@ func (v *ArrayValue) ToConstantSized( // Convert the variable-sized array type to a constant-sized array type. - variableSizedType, ok := v.Type.(*VariableSizedStaticType) - if !ok { + if _, ok := v.Type.(*VariableSizedStaticType); !ok { panic(errors.NewUnreachableError()) } + // Cascade outer authorization into the result element type, matching + // sema's ArrayToConstantSizedFunctionType — see + // sema.GetDescendantTypeForAccess. + elementType := v.SemaType(context).ElementType(false) + asReference := sema.ShouldReturnReference(accessedType, elementType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + elementType = sema.GetDescendantReferenceType( + context, + elementType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } + resultElementStaticType := ConvertSemaToStaticType(context, elementType) constantSizedType := NewConstantSizedStaticType( context, - variableSizedType.Type, + resultElementStaticType, expectedConstantSizedArraySize, ) @@ -2152,6 +2306,10 @@ func (v *ArrayValue) ToConstantSized( value := MustConvertStoredValue(context, atreeValue) + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -2316,10 +2474,11 @@ var NativeArrayConcatFunction = NativeFunction( receiver Value, args []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) otherArray := AssertValueOfType[*ArrayValue](args[0]) - return thisArray.Concat(context, otherArray) + return thisArray.Concat(context, otherArray, accessedType) }, ) @@ -2378,11 +2537,12 @@ var NativeArraySliceFunction = NativeFunction( receiver Value, args []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) fromValue := AssertValueOfType[IntValue](args[0]) toValue := AssertValueOfType[IntValue](args[1]) - return thisArray.Slice(context, fromValue, toValue) + return thisArray.Slice(context, fromValue, toValue, accessedType) }, ) @@ -2394,8 +2554,9 @@ var NativeArrayReverseFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) - return thisArray.Reverse(context) + thisArray := arrayValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) + return thisArray.Reverse(context, accessedType) }, ) @@ -2459,9 +2620,10 @@ var NativeArrayToVariableSizedFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) - return thisArray.ToVariableSized(context) + return thisArray.ToVariableSized(context, accessedType) }, ) @@ -2473,13 +2635,14 @@ var NativeArrayToConstantSizedFunction = NativeFunction( receiver Value, args []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) constantSizedArrayType, ok := typeArguments.NextStatic().(*ConstantSizedStaticType) if !ok { panic(errors.NewUnreachableError()) } - return thisArray.ToConstantSized(context, constantSizedArrayType.Size) + return thisArray.ToConstantSized(context, constantSizedArrayType.Size, accessedType) }, ) diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 49f9476ce4..92c18839db 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -590,8 +590,28 @@ func (v *DictionaryValue) Destroy(context ResourceDestructionContext) { func (v *DictionaryValue) ForEachKey( context InvocationContext, procedure FunctionValue, + accessedType sema.Type, ) { + // Cascade outer authorization into the callback's key parameter type, + // matching sema's DictionaryForEachKeyFunctionType — see + // sema.GetDescendantTypeForAccess. In practice no built-in Hashable type + // has ContainFieldsOrElements=true, so the wrap doesn't trigger today, + // but the symmetry with filter/map keeps the runtime aligned with sema + // if that ever changes. keyType := v.SemaType(context).KeyType + asReference := sema.ShouldReturnReference(accessedType, keyType, false) + if asReference { + outerRef, isRef := sema.MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + keyType = sema.GetDescendantReferenceType( + context, + keyType, + sema.UnauthorizedAccess, + outerRef.Authorization, + ) + } argumentTypes := []sema.Type{keyType} @@ -613,6 +633,10 @@ func (v *DictionaryValue) ForEachKey( key := MustConvertStoredValue(context, item) + if asReference { + key = getReferenceValue(context, key, keyType) + } + result := invokeFunctionValue( context, procedure, @@ -988,7 +1012,13 @@ func (v *DictionaryValue) GetMethod( dictionaryType, ), NativeDictionaryForEachKeyFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function can read accessedType from it and cascade the + // outer reference's authorization into the callback's key + // parameter type, matching + // sema.DictionaryForEachKeyFunctionType. + WithDereferenceReceiver(false) } return nil @@ -1900,12 +1930,31 @@ var NativeDictionaryForEachKeyFunction = NativeFunction( args []Value, ) Value { funcArgument := AssertValueOfType[FunctionValue](args[0]) - dictionary := AssertValueOfType[*DictionaryValue](receiver) - dictionary.ForEachKey(context, funcArgument) + dictionary := dictionaryValueFromReceiver(context, receiver) + accessedType := MustSemaTypeOfValue(receiver, context) + dictionary.ForEachKey(context, funcArgument, accessedType) return Void }, ) +func dictionaryValueFromReceiver(context ValueStaticTypeContext, receiver Value) *DictionaryValue { + switch receiver := receiver.(type) { + case *DictionaryValue: + return receiver + + case *StorageReferenceValue: + referencedValue := receiver.MustReferencedValue(context) + return AssertValueOfType[*DictionaryValue](referencedValue) + + case *EphemeralReferenceValue: + referencedValue := receiver.Value + return AssertValueOfType[*DictionaryValue](referencedValue) + + default: + panic(errors.NewUnreachableError()) + } +} + // DictionaryKeyIterator type DictionaryKeyIterator struct { From fb1780a60fb7672d39e5f11288e44cd6c8dae2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 17:31:59 -0700 Subject: [PATCH 044/139] fix getting element type from array --- bbq/vm/value_array.go | 10 +++++----- bbq/vm/value_dictionary.go | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/bbq/vm/value_array.go b/bbq/vm/value_array.go index 1418869805..175c3c2fab 100644 --- a/bbq/vm/value_array.go +++ b/bbq/vm/value_array.go @@ -66,7 +66,7 @@ func init() { sema.ArrayTypeReverseFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - arrayType := arrayTypeFromValue(receiver, context) + arrayType := arrayTypeFromSemaType(accessedType) return sema.ArrayReverseFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayReverseFunction, @@ -152,7 +152,7 @@ func init() { sema.ArrayTypeConcatFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - arrayType := arrayTypeFromValue(receiver, context) + arrayType := arrayTypeFromSemaType(accessedType) return sema.ArrayConcatFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayConcatFunction, @@ -216,7 +216,7 @@ func init() { sema.ArrayTypeSliceFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArraySliceFunctionType(context, accessedType, elementType) }, interpreter.NativeArraySliceFunction, @@ -229,7 +229,7 @@ func init() { sema.ArrayTypeToConstantSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArrayToConstantSizedFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayToConstantSizedFunction, @@ -246,7 +246,7 @@ func init() { sema.ArrayTypeToVariableSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArrayToVariableSizedFunctionType( context, accessedType, diff --git a/bbq/vm/value_dictionary.go b/bbq/vm/value_dictionary.go index f4b3a607cf..57c2118b38 100644 --- a/bbq/vm/value_dictionary.go +++ b/bbq/vm/value_dictionary.go @@ -83,8 +83,7 @@ func init() { func dictionaryType(receiver Value, context interpreter.ValueStaticTypeContext) *sema.DictionaryType { dictionaryValue := receiver.(*interpreter.DictionaryValue) - dictionaryType := dictionaryValue.SemaType(context) - return dictionaryType + return dictionaryValue.SemaType(context) } func dictionaryTypeFromSemaType(accessedType sema.Type) *sema.DictionaryType { From ff329fa445c765108e5927e2cc2765d64a88c046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 17:46:05 -0700 Subject: [PATCH 045/139] fix dictionary insert/remove functions --- bbq/vm/value_dictionary.go | 8 +- interpreter/misc_test.go | 59 +++++++++++++ interpreter/value_dictionary.go | 20 ++++- sema/entitlements_test.go | 141 ++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 8 deletions(-) diff --git a/bbq/vm/value_dictionary.go b/bbq/vm/value_dictionary.go index 57c2118b38..e438712d13 100644 --- a/bbq/vm/value_dictionary.go +++ b/bbq/vm/value_dictionary.go @@ -35,11 +35,11 @@ func init() { sema.DictionaryTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - dictionaryType := dictionaryType(receiver, context) + dictionaryType := dictionaryTypeFromSemaType(accessedType) return sema.DictionaryRemoveFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryRemoveFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -48,11 +48,11 @@ func init() { sema.DictionaryTypeInsertFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - dictionaryType := dictionaryType(receiver, context) + dictionaryType := dictionaryTypeFromSemaType(accessedType) return sema.DictionaryInsertFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryInsertFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index e106d11f04..ea6d0dd3d4 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -12854,6 +12854,65 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { } `) }) + + // These tests exercise the non-trivial inner-reference intersection + // performed by intersectContainerElementReferences when the + // value/element type has an authorized inner reference. The case-4 + // tests above use unauthorized `&S` as the inner type, which makes + // the intersection a no-op; here the inner is `auth(E) &S` and the + // outer is `auth(Mutate)`, so the intersection actually strips the + // auth. Verifies that the runtime return type matches the + // sema-cascaded return — without WithDereferenceReceiver(false) on + // the bound function, the BBQ VM would derive the function type + // against the unwrapped receiver (a bare dict/array) and produce + // the wrong (unintersected) return type, which would silently + // allow the user to recover the stripped authorization via a force + // cast on the returned value. + + t.Run("dictionary remove intersects inner auth", func(t *testing.T) { + t.Parallel() + runCases(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {"a": &s as auth(E) &S} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + let r: (&S)? = ref.remove(key: "a") + } + `) + }) + + t.Run("dictionary insert intersects inner auth", func(t *testing.T) { + t.Parallel() + runCases(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + let r: (&S)? = ref.insert(key: "a", &s as auth(E) &S) + } + `) + }) + + t.Run("array remove intersects inner auth", func(t *testing.T) { + t.Parallel() + runCases(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.remove(at: 0) + } + `) + }) }) } diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 92c18839db..ce3aeb150a 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -975,7 +975,13 @@ func (v *DictionaryValue) GetMethod( dictionaryType, ), NativeDictionaryRemoveFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function and any BBQ VM derivation can read accessedType + // from the un-dereferenced receiver and apply the inner- + // reference intersection in sema.DictionaryRemoveFunctionType's + // return. + WithDereferenceReceiver(false) case sema.DictionaryTypeInsertFunctionName: return NewBoundHostFunctionValue( @@ -988,7 +994,13 @@ func (v *DictionaryValue) GetMethod( dictionaryType, ), NativeDictionaryInsertFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function and any BBQ VM derivation can read accessedType + // from the un-dereferenced receiver and apply the inner- + // reference intersection in sema.DictionaryInsertFunctionType's + // return. + WithDereferenceReceiver(false) case sema.DictionaryTypeContainsKeyFunctionName: return NewBoundHostFunctionValue( @@ -1887,7 +1899,7 @@ var NativeDictionaryRemoveFunction = NativeFunction( args []Value, ) Value { keyValue := args[0] - dictionary := AssertValueOfType[*DictionaryValue](receiver) + dictionary := dictionaryValueFromReceiver(context, receiver) return dictionary.Remove(context, keyValue) }, ) @@ -1902,7 +1914,7 @@ var NativeDictionaryInsertFunction = NativeFunction( ) Value { keyValue := args[0] newValue := args[1] - dictionary := AssertValueOfType[*DictionaryValue](receiver) + dictionary := dictionaryValueFromReceiver(context, receiver) return dictionary.Insert(context, keyValue, newValue) }, ) diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index dc5c459dc2..c88852227e 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -9650,6 +9650,147 @@ func TestCheckContainerMethodElementCascading(t *testing.T) { require.NoError(t, err) }) + // Exercises the inner-reference intersection performed by + // intersectContainerElementReferences when the value type contains + // authorized references whose authorization is non-trivially + // intersected with the outer reference's authorization. The cases + // with `&S` above only cover the trivial unauth ∩ outer = unauth. + + t.Run("dictionary remove intersects inner auth", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {"a": &s as auth(E) &S} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + // outer auth(Mutate) ∩ inner auth(E) = empty, + // so the returned reference is unauthorized. + let r: (&S)? = ref.remove(key: "a") + } + `) + require.NoError(t, err) + }) + + t.Run("dictionary remove intersects inner auth, escalation prevented", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {"a": &s as auth(E) &S} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + // Sema-cascaded return is (&S)?, not (auth(E) &S)?, + // so trying to bind to (auth(E) &S)? must fail. + let r: (auth(E) &S)? = ref.remove(key: "a") + } + `) + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + assert.Equal(t, + common.TypeID("(auth(S.test.E)&S.test.S)?"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("(&S.test.S)?"), + typeMismatchError.ActualType.ID(), + ) + }) + + t.Run("dictionary insert intersects inner auth", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + // outer auth(Mutate) ∩ inner auth(E) = empty, + // so the returned previous-value reference is unauthorized. + let r: (&S)? = ref.insert(key: "a", &s as auth(E) &S) + } + `) + require.NoError(t, err) + }) + + t.Run("dictionary insert intersects inner auth, escalation prevented", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var d: {String: auth(E) &S} = {} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + let r: (auth(E) &S)? = ref.insert(key: "a", &s as auth(E) &S) + } + `) + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + assert.Equal(t, + common.TypeID("(auth(S.test.E)&S.test.S)?"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("(&S.test.S)?"), + typeMismatchError.ActualType.ID(), + ) + }) + + // Same cases for array remove*: outer auth Mutate intersects with + // inner element auth E, stripping to unauthorized. + + t.Run("array remove intersects inner auth", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.remove(at: 0) + } + `) + require.NoError(t, err) + }) + + t.Run("array remove intersects inner auth, escalation prevented", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: auth(E) &S = ref.remove(at: 0) + } + `) + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) + assert.Equal(t, + common.TypeID("auth(S.test.E)&S.test.S"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&S.test.S"), + typeMismatchError.ActualType.ID(), + ) + }) + }) } From 0be8382bae0892d09f058512f62c9cfc5df43f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 17:55:09 -0700 Subject: [PATCH 046/139] fix array remove functions --- bbq/vm/value_array.go | 12 ++++++------ interpreter/misc_test.go | 30 ++++++++++++++++++++++++++++++ interpreter/value_array.go | 30 ++++++++++++++++++++++++------ sema/entitlements_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/bbq/vm/value_array.go b/bbq/vm/value_array.go index 175c3c2fab..82c56c205d 100644 --- a/bbq/vm/value_array.go +++ b/bbq/vm/value_array.go @@ -177,11 +177,11 @@ func init() { sema.ArrayTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArrayRemoveFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -190,11 +190,11 @@ func init() { sema.ArrayTypeRemoveFirstFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArrayRemoveFirstFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFirstFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -203,11 +203,11 @@ func init() { sema.ArrayTypeRemoveLastFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) - elementType := arrayElementTypeFromValue(receiver, context) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) return sema.ArrayRemoveLastFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveLastFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index ea6d0dd3d4..e19b556b6e 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -12913,6 +12913,36 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { } `) }) + + t.Run("array removeFirst intersects inner auth", func(t *testing.T) { + t.Parallel() + runCases(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeFirst() + } + `) + }) + + t.Run("array removeLast intersects inner auth", func(t *testing.T) { + t.Parallel() + runCases(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeLast() + } + `) + }) }) } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 6112371b6b..a728ffe847 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1073,7 +1073,13 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArrayRemoveFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function and any BBQ VM derivation can read accessedType + // from the un-dereferenced receiver and apply the inner- + // reference intersection in sema.ArrayRemoveFunctionType's + // return. + WithDereferenceReceiver(false) case sema.ArrayTypeRemoveFirstFunctionName: return NewBoundHostFunctionValue( @@ -1086,7 +1092,13 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArrayRemoveFirstFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function and any BBQ VM derivation can read accessedType + // from the un-dereferenced receiver and apply the inner- + // reference intersection in + // sema.ArrayRemoveFirstFunctionType's return. + WithDereferenceReceiver(false) case sema.ArrayTypeRemoveLastFunctionName: return NewBoundHostFunctionValue( @@ -1099,7 +1111,13 @@ func (v *ArrayValue) GetMethod( arrayType.ElementType(false), ), NativeArrayRemoveLastFunction, - ) + ). + // Receiver is kept as-is (not dereferenced) so the native + // function and any BBQ VM derivation can read accessedType + // from the un-dereferenced receiver and apply the inner- + // reference intersection in + // sema.ArrayRemoveLastFunctionType's return. + WithDereferenceReceiver(false) case sema.ArrayTypeFirstIndexFunctionName: return NewBoundHostFunctionValue( @@ -2507,7 +2525,7 @@ var NativeArrayRemoveFunction = NativeFunction( receiver Value, args []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) index := AssertValueOfType[NumberValue](args[0]) return thisArray.Remove(context, index.ToInt()) @@ -2669,7 +2687,7 @@ var NativeArrayRemoveFirstFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) return thisArray.RemoveFirst(context) }, @@ -2683,7 +2701,7 @@ var NativeArrayRemoveLastFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) return thisArray.RemoveLast(context) }, diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index c88852227e..e3c058c1be 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -9791,6 +9791,38 @@ func TestCheckContainerMethodElementCascading(t *testing.T) { ) }) + t.Run("array removeFirst intersects inner auth", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeFirst() + } + `) + require.NoError(t, err) + }) + + t.Run("array removeLast intersects inner auth", func(t *testing.T) { + t.Parallel() + _, err := ParseAndCheck(t, ` + entitlement E + access(all) struct S {} + fun cases() { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeLast() + } + `) + require.NoError(t, err) + }) + }) } From 91f1cd6d28b24accbff660816e58d6baa191ba9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 18:01:45 -0700 Subject: [PATCH 047/139] clean up array function registration in VM --- bbq/vm/value_array.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/bbq/vm/value_array.go b/bbq/vm/value_array.go index 82c56c205d..993c23c820 100644 --- a/bbq/vm/value_array.go +++ b/bbq/vm/value_array.go @@ -29,8 +29,7 @@ import ( func init() { - // Methods available for both types of arrays (constant-sized and variable-sized), - // and references to them. + // Methods available for both types of arrays (constant-sized and variable-sized) for _, typeQualifier := range []string{ commons.TypeQualifierArrayVariableSized, @@ -72,14 +71,7 @@ func init() { interpreter.NativeArrayReverseFunction, ).WithDereferenceReceiver(false), ) - } - - // Methods available for both types of arrays (constant-sized and variable-sized). - for _, typeQualifier := range []string{ - commons.TypeQualifierArrayVariableSized, - commons.TypeQualifierArrayConstantSized, - } { registerBuiltinTypeBoundFunction( typeQualifier, NewNativeFunctionValueWithDerivedType( @@ -118,8 +110,8 @@ func init() { ) } - // Methods available only for variable-sized arrays, - // and references to them. + // Methods available only for variable-sized arrays + typeQualifier := commons.TypeQualifierArrayVariableSized registerBuiltinTypeBoundFunction( @@ -237,7 +229,7 @@ func init() { ) // Methods available only for constant-sized arrays - // and references to them. + typeQualifier = commons.TypeQualifierArrayConstantSized registerBuiltinTypeBoundFunction( From 4fd72181aa8ff44962e88db858784e846bbd67e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 22 May 2026 18:46:04 -0700 Subject: [PATCH 048/139] test auth recovery via downcast is prevented for container functions --- interpreter/misc_test.go | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index e19b556b6e..3cefe19083 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -12536,6 +12536,24 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { require.NoError(t, err) } + // Shared helper used by the auth-recovery sub-tests on both copy and + // extract methods. The Cadence source must declare `fun test(): Bool` + // returning whether a force-cast back to the original (stronger) + // authorization succeeded. The assertion is that it must return false + // — otherwise the holder could recover the stripped authorization at + // runtime, defeating sema's cascading. + assertCannotRecoverAuth := func(t *testing.T, source string) { + t.Helper() + inter := parseCheckAndPrepare(t, source) + result, err := inter.Invoke("test") + require.NoError(t, err) + require.Equal(t, + interpreter.BoolValue(false), + result, + "force-cast back to stronger auth must fail; soundness gap", + ) + } + t.Run("Public copy methods", func(t *testing.T) { t.Parallel() @@ -12727,6 +12745,139 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { } `) }) + + // Auth-recovery downcast soundness for the public copy methods. + // Each method's runtime implementation calls getReferenceValue per + // extracted element (filter, map, slice, concat, reverse, + // toVariableSized, toConstantSized), so the values flowing into the + // result array — or into the user callback for map — already carry + // the cascaded authorization. The downcast back to the original + // inner authorization must therefore fail. + + t.Run("array filter auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let result: [&S] = ref.filter(view fun (_: &S): Bool { return true }) + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) + + // `map`'s callback receives the cascaded element type. Verify the + // recovery downcast also fails *inside* the callback (not only on + // the result), since the user could observe / leak the recovered + // authorization there. + t.Run("array map auth recovery prevented in callback", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let recovered: [Bool] = ref.map(view fun (elem: &S): Bool { + return elem as? auth(E) &S != nil + }) + return recovered[0] + } + `) + }) + + t.Run("array slice auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let result: [&S] = ref.slice(from: 0, upTo: 1) + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) + + t.Run("array concat auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + let other: [auth(E) &S] = [] + + let result: [&S] = ref.concat(other) + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) + + t.Run("array reverse auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let result: [&S] = ref.reverse() + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) + + t.Run("array toVariableSized auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S; 1] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S; 1] + + let result: [&S] = ref.toVariableSized() + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) + + t.Run("array toConstantSized auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + let a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let result: [&S; 1] = ref.toConstantSized<[&S; 1]>()! + let elem: &S = result[0] + return elem as? auth(E) &S != nil + } + `) + }) }) t.Run("Entitled mutating/extracting methods", func(t *testing.T) { @@ -12943,6 +13094,107 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { } `) }) + + // Auth-recovery downcast soundness: the cascaded return type in sema + // is only sufficient if the runtime also presents the returned value + // with the cascaded authorization — otherwise the holder could + // recover the stripped authorization by force-casting the result + // back to the original element type. + // + // For all five mutating/extracting methods that route through + // intersectContainerElementReferences at sema, this is enforced at + // runtime by ConvertAndBoxWithValidation → applyTargetTypeAuthorization, + // which rewrites the result's static-type reference authorization to + // the function's declared return-type authorization at the function + // return boundary. The downcast against the original inner auth + // must therefore fail. + + t.Run("array remove auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.remove(at: 0) + let strong = r as? auth(E) &S + return strong != nil + } + `) + }) + + t.Run("array removeFirst auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeFirst() + let strong = r as? auth(E) &S + return strong != nil + } + `) + }) + + t.Run("array removeLast auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var a: [auth(E) &S] = [&s as auth(E) &S] + let ref = &a as auth(Mutate) &[auth(E) &S] + + let r: &S = ref.removeLast() + let strong = r as? auth(E) &S + return strong != nil + } + `) + }) + + t.Run("dictionary remove auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var d: {String: auth(E) &S} = {"a": &s as auth(E) &S} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + let r: &S = ref.remove(key: "a")! + let strong = r as? auth(E) &S + return strong != nil + } + `) + }) + + t.Run("dictionary insert auth recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s1 = S() + let s2 = S() + var d: {String: auth(E) &S} = {"a": &s1 as auth(E) &S} + let ref = &d as auth(Mutate) &{String: auth(E) &S} + + // The "previous value" returned by insert is the one being replaced. + let r: &S = ref.insert(key: "a", &s2 as auth(E) &S)! + let strong = r as? auth(E) &S + return strong != nil + } + `) + }) }) } From 2aa87ddc385bbd64819bb36f4af43368e5ef8abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 26 May 2026 12:11:34 -0700 Subject: [PATCH 049/139] fix auth of self in attachment: only provide called function's access --- bbq/vm/context.go | 22 +++++++++++++++++++--- bbq/vm/test/vm_test.go | 32 ++++++++++++++++++++++++++++++++ bbq/vm/vm.go | 14 +++++++++----- interpreter/attachments_test.go | 29 +++++++++++++++++++++++++++++ interpreter/value_composite.go | 11 +++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) diff --git a/bbq/vm/context.go b/bbq/vm/context.go index e1f8a42b81..ea3e5955e6 100644 --- a/bbq/vm/context.go +++ b/bbq/vm/context.go @@ -373,7 +373,11 @@ func (c *Context) GetMethod( // If the value is an attachment, then we must create an authorized reference if v, ok := value.(*interpreter.CompositeValue); ok && v.Kind == common.CompositeKindAttachment { - base = attachmentBaseForMethod(c, v, method, accessedReference) + var narrowedSelf interpreter.ReferenceValue + base, narrowedSelf = attachmentBaseForMethod(c, v, method, accessedReference) + if narrowedSelf != nil { + accessedReference = narrowedSelf + } } return NewBoundFunctionValue( @@ -390,7 +394,7 @@ func attachmentBaseForMethod( attachment *interpreter.CompositeValue, method FunctionValue, accessedReference interpreter.ReferenceValue, -) *interpreter.EphemeralReferenceValue { +) (base *interpreter.EphemeralReferenceValue, narrowedSelf interpreter.ReferenceValue) { fnAccess := functionAccess(c, attachment, method) authorizationNeededForFunction := interpreter.ConvertSemaAccessToStaticAuthorization(c, fnAccess) @@ -409,9 +413,21 @@ func attachmentBaseForMethod( ActualAuthorization: accessedReferenceAuthorization, }) } + + // Narrow `self` to the authorization actually required by the function. + // Sema types `self` inside an attachment method as `auth() &A`, so the + // runtime reference must not carry the stronger authorization of the accessed + // reference — otherwise a downcast inside the method could recover it. + narrowedSelf = interpreter.NewEphemeralReferenceValue( + c, + authorizationNeededForFunction, + attachment, + interpreter.MustSemaTypeOfValue(attachment, c), + ) } - return attachment.GetBaseValue(c, authorizationNeededForFunction) + base = attachment.GetBaseValue(c, authorizationNeededForFunction) + return } func typeLocation(semaType sema.Type) common.Location { diff --git a/bbq/vm/test/vm_test.go b/bbq/vm/test/vm_test.go index 7bf10391e7..8025410b55 100644 --- a/bbq/vm/test/vm_test.go +++ b/bbq/vm/test/vm_test.go @@ -9758,6 +9758,38 @@ func TestAttachments(t *testing.T) { require.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(3), value) }) + + t.Run("self auth is narrowed to function access", func(t *testing.T) { + t.Parallel() + + // Inside an attachment method, sema types `self` as + // `auth() &A`. The runtime reference must match — otherwise + // a downcast inside the method could recover the stronger + // authorization of the accessed reference. + value, err := CompileAndInvoke(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun base() {} + } + + access(all) attachment A for S { + access(X) fun foo(): Bool { + return self as? auth(X, Y) &A != nil + } + } + + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `, "test") + require.NoError(t, err) + + require.Equal(t, interpreter.FalseValue, value) + }) } func TestDynamicMethodInvocationViaOptionalChaining(t *testing.T) { diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index 4075f819bf..f3060b8575 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -1048,17 +1048,21 @@ func opGetMethod(vm *VM, ins opcode.InstructionGetMethod) { var base *interpreter.EphemeralReferenceValue + // Ignore casting failures. Only interested in the receiver as a reference. + // If the receiver is not a reference, then `accessedReference` must be `nil`. + accessedReference, _ := receiver.(interpreter.ReferenceValue) + if reference, ok := receiver.(*interpreter.EphemeralReferenceValue); ok { if compositeValue, ok := reference.Value.(*interpreter.CompositeValue); ok && compositeValue.Kind == common.CompositeKindAttachment { - base = attachmentBaseForMethod(context, compositeValue, method, reference) + var narrowedSelf interpreter.ReferenceValue + base, narrowedSelf = attachmentBaseForMethod(context, compositeValue, method, reference) + if narrowedSelf != nil { + accessedReference = narrowedSelf + } } } - // Ignore casting failures. Only interested in the receiver as a reference. - // If the receiver is not a reference, then `accessedReference` must be `nil`. - accessedReference, _ := receiver.(interpreter.ReferenceValue) - boundFunction := NewBoundFunctionValue( context, receiver, diff --git a/interpreter/attachments_test.go b/interpreter/attachments_test.go index 2fd89075ab..735cc9259b 100644 --- a/interpreter/attachments_test.go +++ b/interpreter/attachments_test.go @@ -2844,3 +2844,32 @@ func TestInterpretAttachmentBaseUnauthorizedInInit(t *testing.T) { require.ErrorAs(t, err, &forceCastTypeMismatchErr) }) } + +func TestInterpretAttachmentSelfAuth(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun bar() {} + } + + access(all) attachment A for S { + access(X) fun foo(): Bool { + return self as? auth(X, Y) &A != nil + } + } + + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `) + + res, err := inter.Invoke("test") + require.NoError(t, err) + assert.Equal(t, interpreter.FalseValue, res) +} diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 99b5c189dd..7af292266c 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -695,6 +695,17 @@ func (v *CompositeValue) GetMethod( ActualAuthorization: accessedReferenceAuthorization, }) } + + // Narrow `self` to the authorization actually required by the function. + // Sema types `self` inside an attachment method as `auth() &A`, so the + // runtime reference must not carry the stronger authorization of the accessed + // reference — otherwise a downcast inside the method could recover it. + accessedReference = NewEphemeralReferenceValue( + context, + authorizationNeededForFunction, + v, + MustSemaTypeOfValue(v, context), + ) } // If the function is already a bound function, then do not re-wrap. From 64d0ab153c111c8c7c42c62871b692941d1bfec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 26 May 2026 14:36:41 -0700 Subject: [PATCH 050/139] improve failure assertion --- bbq/vm/test/vm_test.go | 15 ++++++++------- interpreter/attachments_test.go | 16 +++++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/bbq/vm/test/vm_test.go b/bbq/vm/test/vm_test.go index 8025410b55..2c397640e1 100644 --- a/bbq/vm/test/vm_test.go +++ b/bbq/vm/test/vm_test.go @@ -9766,7 +9766,7 @@ func TestAttachments(t *testing.T) { // `auth() &A`. The runtime reference must match — otherwise // a downcast inside the method could recover the stronger // authorization of the accessed reference. - value, err := CompileAndInvoke(t, ` + _, err := CompileAndInvoke(t, ` entitlement X entitlement Y @@ -9775,20 +9775,21 @@ func TestAttachments(t *testing.T) { } access(all) attachment A for S { - access(X) fun foo(): Bool { - return self as? auth(X, Y) &A != nil + access(X) fun foo() { + self as! auth(X, Y) &A } } - fun test(): Bool { + fun test() { let s = attach A() to S() let ref = &s as auth(X, Y) &S - return ref[A]!.foo() + ref[A]!.foo() } `, "test") - require.NoError(t, err) + RequireError(t, err) - require.Equal(t, interpreter.FalseValue, value) + var forceCastTypeMismatchErr *interpreter.ForceCastTypeMismatchError + require.ErrorAs(t, err, &forceCastTypeMismatchErr) }) } diff --git a/interpreter/attachments_test.go b/interpreter/attachments_test.go index 735cc9259b..369ae7fba9 100644 --- a/interpreter/attachments_test.go +++ b/interpreter/attachments_test.go @@ -2857,19 +2857,21 @@ func TestInterpretAttachmentSelfAuth(t *testing.T) { } access(all) attachment A for S { - access(X) fun foo(): Bool { - return self as? auth(X, Y) &A != nil + access(X) fun foo() { + self as! auth(X, Y) &A } } - fun test(): Bool { + fun test() { let s = attach A() to S() let ref = &s as auth(X, Y) &S - return ref[A]!.foo() + ref[A]!.foo() } `) - res, err := inter.Invoke("test") - require.NoError(t, err) - assert.Equal(t, interpreter.FalseValue, res) + _, err := inter.Invoke("test") + RequireError(t, err) + + var forceCastTypeMismatchErr *interpreter.ForceCastTypeMismatchError + require.ErrorAs(t, err, &forceCastTypeMismatchErr) } From d98d17c8f04d40041b4e6b1174cf51ce5a820ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 26 May 2026 14:59:44 -0700 Subject: [PATCH 051/139] avoid (code) duplication for ShouldReturnReference/MaybeReferenceType/GetDescendantTypeForAccess --- interpreter/value_array.go | 117 ++++---------------------------- interpreter/value_dictionary.go | 23 ++----- sema/check_expression.go | 22 +++--- sema/check_for.go | 3 +- sema/check_member_expression.go | 19 ++++-- sema/type.go | 16 ++--- 6 files changed, 53 insertions(+), 147 deletions(-) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index a728ffe847..314eceadbb 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -453,21 +453,9 @@ func (v *ArrayValue) Concat( otherElementType := v.Type.ElementType() // Cascade outer authorization into the result element type, matching - // sema's ArrayConcatFunctionType — see sema.GetDescendantTypeForAccess. + // sema's ArrayConcatFunctionType. resultElementSemaType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, resultElementSemaType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - resultElementSemaType = sema.GetDescendantReferenceType( - context, - resultElementSemaType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) newCount := v.array.Count() + other.array.Count() @@ -1801,23 +1789,11 @@ func (v *ArrayValue) Slice( // Cascade outer authorization into the result element type, matching // sema's ArraySliceFunctionType: when sliced through a reference, - // elements are exposed as references (with auths intersected) — see - // sema.GetDescendantTypeForAccess. Without this, the new array's - // declared element type would mismatch its actual contents. + // elements are exposed as references (with auths intersected). Without + // this, the new array's declared element type would mismatch its actual + // contents. elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) return NewArrayValueWithIterator( @@ -1867,22 +1843,9 @@ func (v *ArrayValue) Reverse( index := count - 1 // Cascade outer authorization into the result element type, matching - // sema's ArrayReverseFunctionType — see sema.GetDescendantTypeForAccess. - semaArrayType := v.SemaType(context) - elementType := semaArrayType.ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + // sema's ArrayReverseFunctionType. + elementType := v.SemaType(context).ElementType(false) + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) // reverse() preserves the array shape (variable- or constant-sized), // so reuse v.Type's shape but with the cascaded element type. @@ -1934,19 +1897,7 @@ func (v *ArrayValue) Filter( elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) argumentType := elementType argumentTypes := []sema.Type{argumentType} @@ -2048,19 +1999,7 @@ func (v *ArrayValue) Map( elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) argumentType := elementType argumentTypes := []sema.Type{argumentType} @@ -2173,22 +2112,9 @@ func (v *ArrayValue) ToVariableSized( } // Cascade outer authorization into the result element type, matching - // sema's ArrayToVariableSizedFunctionType — see - // sema.GetDescendantTypeForAccess. + // sema's ArrayToVariableSizedFunctionType. elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) variableSizedType := NewVariableSizedStaticType(context, resultElementStaticType) @@ -2265,22 +2191,9 @@ func (v *ArrayValue) ToConstantSized( } // Cascade outer authorization into the result element type, matching - // sema's ArrayToConstantSizedFunctionType — see - // sema.GetDescendantTypeForAccess. + // sema's ArrayToConstantSizedFunctionType. elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) constantSizedType := NewConstantSizedStaticType( context, diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index ce3aeb150a..121f310072 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -593,25 +593,12 @@ func (v *DictionaryValue) ForEachKey( accessedType sema.Type, ) { // Cascade outer authorization into the callback's key parameter type, - // matching sema's DictionaryForEachKeyFunctionType — see - // sema.GetDescendantTypeForAccess. In practice no built-in Hashable type - // has ContainFieldsOrElements=true, so the wrap doesn't trigger today, - // but the symmetry with filter/map keeps the runtime aligned with sema - // if that ever changes. + // matching sema's DictionaryForEachKeyFunctionType. In practice no + // built-in Hashable type has ContainFieldsOrElements=true, so the wrap + // doesn't trigger today, but the symmetry with filter/map keeps the + // runtime aligned with sema if that ever changes. keyType := v.SemaType(context).KeyType - asReference := sema.ShouldReturnReference(accessedType, keyType, false) - if asReference { - outerRef, isRef := sema.MaybeReferenceType(accessedType) - if !isRef { - panic(errors.NewUnreachableError()) - } - keyType = sema.GetDescendantReferenceType( - context, - keyType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + keyType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, keyType, false) argumentTypes := []sema.Type{keyType} diff --git a/sema/check_expression.go b/sema/check_expression.go index 8105c957d4..704688ac84 100644 --- a/sema/check_expression.go +++ b/sema/check_expression.go @@ -400,18 +400,16 @@ func (checker *Checker) visitIndexExpression( // 2) is container-typed, // then the element type should also be a reference. // Otherwise, if the member is already a reference, then again, a reference must be returned. - returnReference := ShouldReturnReference(valueIndexedType, elementType, isAssignment) - if returnReference { - // For index expressions, non-reference elements are un-authorized. - // For reference elements, the authorization is the intersection of - // the outer (container) reference's authorization and the inner (element) reference's authorization. - elementType = GetDescendantTypeForAccess( - checker.memoryGauge, - valueIndexedType, - elementType, - isAssignment, - ) - } + // For index expressions, non-reference elements are un-authorized. + // For reference elements, the authorization is the intersection of + // the outer (container) reference's authorization and the inner (element) reference's authorization. + var returnReference bool + elementType, returnReference = GetDescendantTypeForAccess( + checker.memoryGauge, + valueIndexedType, + elementType, + isAssignment, + ) checker.Elaboration.SetIndexExpressionTypes( indexExpression, diff --git a/sema/check_for.go b/sema/check_for.go index fd7b8b3277..5d6c41dccd 100644 --- a/sema/check_for.go +++ b/sema/check_for.go @@ -158,12 +158,13 @@ func (checker *Checker) loopVariableType(valueType Type, hasPosition ast.HasPosi // - Reference elements (case a'): outer ref's authorization // intersected with inner. // - Primitive elements (case b): exposed as the concrete type. - return GetDescendantTypeForAccess( + loopVarType, _ := GetDescendantTypeForAccess( checker.memoryGauge, valueType, referencedIterableElementType, false, ) + return loopVarType } // If it's not a reference, then simply get the element type. diff --git a/sema/check_member_expression.go b/sema/check_member_expression.go index d082bc9ea8..acc90f3a0a 100644 --- a/sema/check_member_expression.go +++ b/sema/check_member_expression.go @@ -212,24 +212,31 @@ func MaybeReferenceType(typ Type) (*ReferenceType, bool) { } // GetDescendantTypeForAccess returns the type that a descendant (member or element) -// should have when read through `accessedType`. +// should have when read through `accessedType`, and whether the type was rewritten +// (equivalent to ShouldReturnReference's result for the same inputs). // When `accessedType` is a reference, and the descendant warrants becoming a reference per ShouldReturnReference, // the descendant is wrapped via GetDescendantReferenceType with an unauthorized wrapping authorization, -// intersecting any inner reference authorizations with the outer reference's authorization. +// intersecting any inner reference authorizations with the outer reference's authorization, +// and the returned boolean is true. // Otherwise (for owned access, for primitive descendants, or in assignment contexts): -// `descendantType` is returned unchanged. +// `descendantType` is returned unchanged and the returned boolean is false. // This encapsulates the cascading rule that applies uniformly when reading element/member data // out of a referenced container or composite. // Call sites that need a custom wrapping authorization (e.g. mapped field access) // keep using GetDescendantReferenceType directly. +// +// The returned boolean is useful for callers (notably the interpreter's container-method +// implementations) that also need to materialize fresh reference values for each iterated +// element via getReferenceValue when the cascade wraps. Callers that only need the type +// can discard the boolean. func GetDescendantTypeForAccess( memoryGauge common.MemoryGauge, accessedType Type, descendantType Type, isAssignment bool, -) Type { +) (Type, bool) { if !ShouldReturnReference(accessedType, descendantType, isAssignment) { - return descendantType + return descendantType, false } outerRef, isRef := MaybeReferenceType(accessedType) if !isRef { @@ -240,7 +247,7 @@ func GetDescendantTypeForAccess( descendantType, UnauthorizedAccess, outerRef.Authorization, - ) + ), true } func (checker *Checker) visitMember(expression *ast.MemberExpression, isAssignment bool) ( diff --git a/sema/type.go b/sema/type.go index 17bce7305f..783e62a0cd 100644 --- a/sema/type.go +++ b/sema/type.go @@ -3187,7 +3187,7 @@ func ArrayConcatFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - returnElementType := GetDescendantTypeForAccess( + returnElementType, _ := GetDescendantTypeForAccess( memoryGauge, accessedType, arrayType.ElementType(false), @@ -3280,7 +3280,7 @@ func ArraySliceFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - elementType = GetDescendantTypeForAccess( + elementType, _ = GetDescendantTypeForAccess( memoryGauge, accessedType, elementType, @@ -3318,7 +3318,7 @@ func ArrayToVariableSizedFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - elementType = GetDescendantTypeForAccess( + elementType, _ = GetDescendantTypeForAccess( memoryGauge, accessedType, elementType, @@ -3348,7 +3348,7 @@ func ArrayToConstantSizedFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - elementType = GetDescendantTypeForAccess( + elementType, _ = GetDescendantTypeForAccess( memoryGauge, accessedType, elementType, @@ -3425,7 +3425,7 @@ func ArrayReverseFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - returnElementType := GetDescendantTypeForAccess( + returnElementType, _ := GetDescendantTypeForAccess( memoryGauge, accessedType, arrayType.ElementType(false), @@ -3466,7 +3466,7 @@ func ArrayFilterFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - elementType = GetDescendantTypeForAccess( + elementType, _ = GetDescendantTypeForAccess( memoryGauge, accessedType, elementType, @@ -3537,7 +3537,7 @@ func ArrayMapFunctionType( // with the outer authorization. Without this cascade, a method-driven // copy could escalate inner-element entitlements beyond what the outer // reference grants. - elementType := GetDescendantTypeForAccess( + elementType, _ := GetDescendantTypeForAccess( memoryGauge, accessedType, arrayType.ElementType(false), @@ -7279,7 +7279,7 @@ func DictionaryForEachKeyFunctionType( // Keys are passed into the user's callback. When the dictionary is accessed via a reference, // the callback's key parameter is exposed via the same cascading rule as indexing and field access // (see GetDescendantTypeForAccess). - keyType := GetDescendantTypeForAccess(memoryGauge, accessedType, t.KeyType, false) + keyType, _ := GetDescendantTypeForAccess(memoryGauge, accessedType, t.KeyType, false) // fun(K): Bool funcType := NewSimpleFunctionType( From ab1ca9b041f5ac2b8dbb34b2d3e6d73e00849360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 27 May 2026 11:38:36 -0700 Subject: [PATCH 052/139] defensively check container indexing in for-loops --- bbq/compiler/compiler.go | 7 ++++-- bbq/compiler/compiler_test.go | 32 +++++++++++++++++++------- bbq/opcode/instructions.go | 25 ++++++++++++++++---- bbq/opcode/instructions.yml | 3 +++ bbq/opcode/pretty_instructions.go | 7 +++++- bbq/opcode/pretty_test.go | 7 +++++- bbq/opcode/print_test.go | 8 +++---- bbq/vm/vm.go | 11 +++++++-- interpreter/container_mutation_test.go | 30 ++++++++++++++++++++++++ interpreter/interpreter_statement.go | 6 +++-- 10 files changed, 112 insertions(+), 24 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index b5aba04f37..b22e9b7e32 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -1641,8 +1641,12 @@ func (c *Compiler[_, _]) VisitForStatement(statement *ast.ForStatement) (_ struc // Evaluate the expression c.compileExpression(statement.Value) + forStmtTypes := c.DesugaredElaboration.ForStatementType(statement) + // Get an iterator to the resulting value, and store it in a local index. - c.emit(opcode.InstructionIterator{}) + c.emit(opcode.InstructionIterator{ + IndexedType: c.getOrAddType(forStmtTypes.ContainerType), + }) iteratorLocalIndex := c.currentFunction.generateLocalIndex() c.emitSetLocal(iteratorLocalIndex) @@ -1713,7 +1717,6 @@ func (c *Compiler[_, _]) VisitForStatement(statement *ast.ForStatement) (_ struc c.emitGetLocal(iteratorLocalIndex) c.emit(opcode.InstructionIteratorNext{}) - forStmtTypes := c.DesugaredElaboration.ForStatementType(statement) loopVarType := forStmtTypes.ValueVariableType _, isContainerReference := sema.MaybeReferenceType(forStmtTypes.ContainerType) _, isValueReference := sema.MaybeReferenceType(loopVarType) diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index a072243500..28e317db2b 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -5565,6 +5565,10 @@ func TestForLoop(t *testing.T) { elementVarIndex ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ opcode.PrettyInstructionStatement{}, @@ -5572,7 +5576,7 @@ func TestForLoop(t *testing.T) { // Get the iterator and store in local var. // `var = array.Iterator` opcode.PrettyInstructionGetLocal{Local: arrayValueIndex}, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: intArrayType}, opcode.PrettyInstructionSetLocal{Local: iteratorVarIndex}, // Loop condition: Check whether `iterator.hasNext()` @@ -5634,13 +5638,17 @@ func TestForLoop(t *testing.T) { elementVarIndex ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ // Get the iterator and store in local var. // `var = array.Iterator` opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetLocal{Local: arrayValueIndex}, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: intArrayType}, opcode.PrettyInstructionSetLocal{Local: iteratorVarIndex}, // Initialize index. @@ -5744,6 +5752,10 @@ func TestForLoop(t *testing.T) { x2Index ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ @@ -5765,7 +5777,7 @@ func TestForLoop(t *testing.T) { // `var = array.Iterator` opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetLocal{Local: arrayValueIndex}, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: intArrayType}, opcode.PrettyInstructionSetLocal{Local: iteratorVarIndex}, // Loop condition: Check whether `iterator.hasNext()` @@ -5857,6 +5869,10 @@ func TestForLoop(t *testing.T) { yIndex ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ @@ -5864,7 +5880,7 @@ func TestForLoop(t *testing.T) { // `var = a.Iterator` opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetLocal{Local: aIndex}, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: intArrayType}, opcode.PrettyInstructionSetLocal{Local: iter1Index}, // Loop condition: Check whether `iterator.hasNext()` @@ -5890,7 +5906,7 @@ func TestForLoop(t *testing.T) { // `var = b.Iterator` opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetLocal{Local: bIndex}, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: intArrayType}, opcode.PrettyInstructionSetLocal{Local: iter2Index}, // Loop condition: Check whether `iterator.hasNext()` @@ -11387,7 +11403,7 @@ func TestForStatementCapturing(t *testing.T) { }, // get iterator - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: arrayType}, opcode.PrettyInstructionSetLocal{Local: iterIndex}, // set i = -1 @@ -12815,7 +12831,7 @@ func TestNestedLoops(t *testing.T) { Type: arrayType, Size: 2, }, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: arrayType}, opcode.PrettyInstructionSetLocal{Local: outerIterIndex}, opcode.PrettyInstructionGetLocal{Local: outerIterIndex}, opcode.PrettyInstructionIteratorHasNext{}, @@ -12846,7 +12862,7 @@ func TestNestedLoops(t *testing.T) { Type: arrayType, Size: 1, }, - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: arrayType}, opcode.PrettyInstructionSetLocal{Local: innerIterIndex}, opcode.PrettyInstructionGetLocal{Local: innerIterIndex}, opcode.PrettyInstructionIteratorHasNext{}, diff --git a/bbq/opcode/instructions.go b/bbq/opcode/instructions.go index 17d5fceba7..9ee8d9fa02 100644 --- a/bbq/opcode/instructions.go +++ b/bbq/opcode/instructions.go @@ -2979,6 +2979,7 @@ func (i InstructionBitwiseRightShift) Pretty(program ProgramForInstructions) Pre // // Pops an iterable value from the stack, get an iterator to it, and push the iterator back onto the stack. type InstructionIterator struct { + IndexedType uint16 } var _ Instruction = InstructionIterator{} @@ -2988,22 +2989,38 @@ func (InstructionIterator) Opcode() Opcode { } func (i InstructionIterator) String() string { - return i.Opcode().String() + var sb strings.Builder + sb.WriteString(i.Opcode().String()) + i.OperandsString(&sb, false) + return sb.String() } -func (i InstructionIterator) OperandsString(sb *strings.Builder, colorize bool) {} +func (i InstructionIterator) OperandsString(sb *strings.Builder, colorize bool) { + sb.WriteByte(' ') + printfArgument(sb, "indexedType", i.IndexedType, colorize) +} func (i InstructionIterator) ResolvedOperandsString(sb *strings.Builder, program ProgramForInstructions, colorize bool) { + sb.WriteByte(' ') + printfTypeArgument(sb, "indexedType", program.GetTypes()[i.IndexedType], colorize) } func (i InstructionIterator) Encode(code *[]byte) { emitOpcode(code, i.Opcode()) + emitUint16(code, i.IndexedType) +} + +func DecodeIterator(ip *uint16, code []byte) (i InstructionIterator) { + i.IndexedType = decodeUint16(ip, code) + return i } func (i InstructionIterator) Pretty(program ProgramForInstructions) PrettyInstruction { - return PrettyInstructionIterator{} + return PrettyInstructionIterator{ + IndexedType: program.GetTypes()[i.IndexedType], + } } // InstructionIteratorHasNext @@ -3704,7 +3721,7 @@ func DecodeInstruction(ip *uint16, code []byte) Instruction { case BitwiseRightShift: return InstructionBitwiseRightShift{} case Iterator: - return InstructionIterator{} + return DecodeIterator(ip, code) case IteratorHasNext: return InstructionIteratorHasNext{} case IteratorNext: diff --git a/bbq/opcode/instructions.yml b/bbq/opcode/instructions.yml index 6096ab543b..0b1913da03 100644 --- a/bbq/opcode/instructions.yml +++ b/bbq/opcode/instructions.yml @@ -959,6 +959,9 @@ - name: "iterator" description: | Pops an iterable value from the stack, get an iterator to it, and push the iterator back onto the stack. + operands: + - name: "indexedType" + type: "typeIndex" valueEffects: pop: - name: "iterable" diff --git a/bbq/opcode/pretty_instructions.go b/bbq/opcode/pretty_instructions.go index 7bbeb0ae09..addf378614 100644 --- a/bbq/opcode/pretty_instructions.go +++ b/bbq/opcode/pretty_instructions.go @@ -1474,6 +1474,7 @@ func (i PrettyInstructionBitwiseRightShift) String() string { // Pretty form of InstructionIterator with resolved operands. // Pops an iterable value from the stack, get an iterator to it, and push the iterator back onto the stack. type PrettyInstructionIterator struct { + IndexedType interpreter.StaticType } var _ PrettyInstruction = PrettyInstructionIterator{} @@ -1483,7 +1484,11 @@ func (PrettyInstructionIterator) Opcode() Opcode { } func (i PrettyInstructionIterator) String() string { - return i.Opcode().String() + var sb strings.Builder + sb.WriteString(i.Opcode().String()) + sb.WriteByte(' ') + printfTypeArgument(&sb, "indexedType", i.IndexedType, false) + return sb.String() } // PrettyInstructionIteratorHasNext diff --git a/bbq/opcode/pretty_test.go b/bbq/opcode/pretty_test.go index 8bdbf317c1..c5e180d69e 100644 --- a/bbq/opcode/pretty_test.go +++ b/bbq/opcode/pretty_test.go @@ -247,7 +247,6 @@ func TestPrettyInstructionMapping(t *testing.T) { {opcode.InstructionBitwiseAnd{}, opcode.PrettyInstructionBitwiseAnd{}}, {opcode.InstructionBitwiseLeftShift{}, opcode.PrettyInstructionBitwiseLeftShift{}}, {opcode.InstructionBitwiseRightShift{}, opcode.PrettyInstructionBitwiseRightShift{}}, - {opcode.InstructionIterator{}, opcode.PrettyInstructionIterator{}}, {opcode.InstructionIteratorHasNext{}, opcode.PrettyInstructionIteratorHasNext{}}, {opcode.InstructionIteratorNext{}, opcode.PrettyInstructionIteratorNext{}}, {opcode.InstructionIteratorEnd{}, opcode.PrettyInstructionIteratorEnd{}}, @@ -440,6 +439,12 @@ func TestPrettyInstructionMapping(t *testing.T) { IndexedType: interpreter.PrimitiveStaticTypeString, }, }, + { + opcode.InstructionIterator{IndexedType: 1}, + opcode.PrettyInstructionIterator{ + IndexedType: interpreter.PrimitiveStaticTypeString, + }, + }, { opcode.InstructionRemoveIndex{IndexedType: 1}, opcode.PrettyInstructionRemoveIndex{ diff --git a/bbq/opcode/print_test.go b/bbq/opcode/print_test.go index af58b529e5..5a925b8aea 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -290,10 +290,10 @@ func TestPrintInstruction(t *testing.T) { "BitwiseLeftShift": {byte(BitwiseLeftShift)}, "BitwiseRightShift": {byte(BitwiseRightShift)}, - "Iterator": {byte(Iterator)}, - "IteratorHasNext": {byte(IteratorHasNext)}, - "IteratorNext": {byte(IteratorNext)}, - "IteratorEnd": {byte(IteratorEnd)}, + "Iterator indexedType:258": {byte(Iterator), 1, 2}, + "IteratorHasNext": {byte(IteratorHasNext)}, + "IteratorNext": {byte(IteratorNext)}, + "IteratorEnd": {byte(IteratorEnd)}, "EmitEvent type:258 argCount:772": {byte(EmitEvent), 1, 2, 3, 4}, "Loop": {byte(Loop)}, diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index f3060b8575..d770ea583e 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -1874,8 +1874,15 @@ func opNewRef(vm *VM, ins opcode.InstructionNewRef) { vm.push(ref) } -func opIterator(vm *VM) { +func opIterator(vm *VM, ins opcode.InstructionIterator) { value := vm.pop() + + checkIndexedType( + vm, + ins.IndexedType, + value, + ) + iterable := value.(interpreter.IterableValue) context := vm.context iterator := iterable.Iterator(context) @@ -2242,7 +2249,7 @@ func (vm *VM) run() { case opcode.InstructionEmitEvent: opEmitEvent(vm, ins) case opcode.InstructionIterator: - opIterator(vm) + opIterator(vm, ins) case opcode.InstructionIteratorHasNext: opIteratorHasNext(vm) case opcode.InstructionIteratorNext: diff --git a/interpreter/container_mutation_test.go b/interpreter/container_mutation_test.go index cf0c4151eb..5e60d16824 100644 --- a/interpreter/container_mutation_test.go +++ b/interpreter/container_mutation_test.go @@ -1260,3 +1260,33 @@ func TestInterpretInnerContainerMutationWhileIteratingOuter(t *testing.T) { assert.Equal(t, interpreter.NewUnmeteredStringValue("foo"), val) }) } + +// VisitForStatement is missing a checkIndexedValue guard on the iterable. +func TestInterpretForLoopFunctionElementTypeConfusion(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [fun(): Void]) { + for f in arr { + f() + } + } + `) + + // Sema sees [fun(): Void], but the array actually holds an IntValue. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) +} diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index 8174067ded..d47f838f48 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -345,6 +345,10 @@ func (interpreter *Interpreter) VisitForStatement(statement *ast.ForStatement) ( value := interpreter.evalExpression(statement.Value) + forStmtTypes := interpreter.Program.Elaboration.ForStatementType(statement) + + interpreter.checkIndexedValue(forStmtTypes.ContainerType, value) + // Do not transfer the iterable value. // Instead, transfer each iterating element. // This is done in `ForEach` method. @@ -354,8 +358,6 @@ func (interpreter *Interpreter) VisitForStatement(statement *ast.ForStatement) ( panic(errors.NewUnreachableError()) } - forStmtTypes := interpreter.Program.Elaboration.ForStatementType(statement) - var index IntValue if statement.Index != nil { index = NewIntValueFromInt64(interpreter, 0) From fc09c161698b0f16263d6521e8e82cb04a638855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 27 May 2026 14:32:01 -0700 Subject: [PATCH 053/139] add more type confusion tests for container indexing and container methods --- interpreter/container_mutation_test.go | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) diff --git a/interpreter/container_mutation_test.go b/interpreter/container_mutation_test.go index 5e60d16824..fe090b1966 100644 --- a/interpreter/container_mutation_test.go +++ b/interpreter/container_mutation_test.go @@ -1290,3 +1290,325 @@ func TestInterpretForLoopFunctionElementTypeConfusion(t *testing.T) { var indexedTypeError *interpreter.IndexedTypeError require.ErrorAs(t, err, &indexedTypeError) } + +func TestInterpretIndexExpressionFunctionElementTypeConfusion(t *testing.T) { + + t.Parallel() + + t.Run("array", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [fun(): Void]) { + let f = arr[0] + f() + } + `) + + // Sema sees [fun(): Void], but the array actually holds an IntValue. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("dictionary", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(dict: {String: fun(): Void}) { + let f = dict["x"]! + f() + } + `) + + // Sema sees {String: fun(): Void}, but the dictionary actually holds an IntValue. + confusedDictionary := interpreter.NewDictionaryValue( + inter, + interpreter.NewDictionaryStaticType( + nil, + interpreter.PrimitiveStaticTypeString, + interpreter.PrimitiveStaticTypeInt, + ), + interpreter.NewUnmeteredStringValue("x"), + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedDictionary) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) +} + +// Member access on a container with a type-confused element type is caught +// by the defensive MemberAccessTypeError check before the function ever sees +// the wrong-typed receiver. Covers every container function exercised by +// TestInterpretContainerMethodElementCascading. +// +// For every test case, sema sees a container whose element/value type is +// String, but the runtime value's static type carries Int. The defensive +// check on the receiver's static type fires at member access time, before +// any of the function's logic runs. +func TestInterpretContainerFunctionElementTypeConfusion(t *testing.T) { + + t.Parallel() + + type receiverShape int + const ( + variableSizedArray receiverShape = iota + constantSizedArray + dictionary + ) + + buildConfusedReceiver := func(inter Invokable, shape receiverShape) interpreter.Value { + switch shape { + case variableSizedArray: + return interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + case constantSizedArray: + return interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + case dictionary: + return interpreter.NewDictionaryValue( + inter, + interpreter.NewDictionaryStaticType( + nil, + interpreter.PrimitiveStaticTypeString, + interpreter.PrimitiveStaticTypeInt, + ), + interpreter.NewUnmeteredStringValue("x"), + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + } + panic("unknown shape") + } + + type testCase struct { + name string + shape receiverShape + code string + } + + cases := []testCase{ + // Array — mutating methods. + { + name: "array append", + shape: variableSizedArray, + code: ` + fun test(arr: [String]) { + arr.append("hello") + } + `, + }, + { + name: "array appendAll", + shape: variableSizedArray, + code: ` + fun test(arr: [String]) { + arr.appendAll([]) + } + `, + }, + { + name: "array insert", + shape: variableSizedArray, + code: ` + fun test(arr: [String]) { + arr.insert(at: 0, "hello") + } + `, + }, + { + name: "array remove", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): String { + return arr.remove(at: 0) + } + `, + }, + { + name: "array removeFirst", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): String { + return arr.removeFirst() + } + `, + }, + { + name: "array removeLast", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): String { + return arr.removeLast() + } + `, + }, + + // Array — read methods returning new arrays. + { + name: "array concat", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [String] { + return arr.concat([]) + } + `, + }, + { + name: "array slice", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [String] { + return arr.slice(from: 0, upTo: 1) + } + `, + }, + { + name: "array reverse", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [String] { + return arr.reverse() + } + `, + }, + { + name: "array filter", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [String] { + return arr.filter(view fun (_: String): Bool { return true }) + } + `, + }, + { + name: "array map", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [Int] { + return arr.map(view fun (_: String): Int { return 0 }) + } + `, + }, + { + name: "array toVariableSized", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): [String] { + return arr.toVariableSized() + } + `, + }, + { + name: "array toConstantSized", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): [String; 1]? { + return arr.toConstantSized<[String; 1]>() + } + `, + }, + + // Array — read methods returning scalars. + { + name: "array firstIndex", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): Int? { + return arr.firstIndex(of: "hello") + } + `, + }, + { + name: "array contains", + shape: variableSizedArray, + code: ` + fun test(arr: [String]): Bool { + return arr.contains("hello") + } + `, + }, + + // Dictionary methods. + { + name: "dictionary remove", + shape: dictionary, + code: ` + fun test(dict: {String: String}): String? { + return dict.remove(key: "x") + } + `, + }, + { + name: "dictionary insert", + shape: dictionary, + code: ` + fun test(dict: {String: String}): String? { + return dict.insert(key: "x", "hello") + } + `, + }, + { + name: "dictionary containsKey", + shape: dictionary, + code: ` + fun test(dict: {String: String}): Bool { + return dict.containsKey("x") + } + `, + }, + { + name: "dictionary forEachKey", + shape: dictionary, + code: ` + fun test(dict: {String: String}) { + dict.forEachKey(fun (_: String): Bool { return true }) + } + `, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, tc.code) + confusedReceiver := buildConfusedReceiver(inter, tc.shape) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedReceiver) //nolint:staticcheck + RequireError(t, err) + + var memberAccessTypeError *interpreter.MemberAccessTypeError + require.ErrorAs(t, err, &memberAccessTypeError) + }) + } +} From d5a763052b6d307fc1b79f2aa7e5e7acd7239fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 27 May 2026 15:59:28 -0700 Subject: [PATCH 054/139] update version --- npm-packages/cadence-parser/package.json | 2 +- version.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/npm-packages/cadence-parser/package.json b/npm-packages/cadence-parser/package.json index 871990951b..419a9ffc61 100644 --- a/npm-packages/cadence-parser/package.json +++ b/npm-packages/cadence-parser/package.json @@ -1,6 +1,6 @@ { "name": "@onflow/cadence-parser", - "version": "1.10.3", + "version": "1.10.4-rc.2", "description": "The Cadence parser", "homepage": "https://github.com/onflow/cadence", "repository": { diff --git a/version.go b/version.go index 68c4881476..fdd11dba55 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.1" +const Version = "v1.10.4-rc.2" From 8419c6d1ab36878d5ae264050a6603297c16384d Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 28 May 2026 09:07:02 -0700 Subject: [PATCH 055/139] Fix references to stale atree slabs --- interpreter/errors.go | 43 ++++ interpreter/stale_atree_view_test.go | 351 +++++++++++++++++++++++++++ interpreter/value_array.go | 36 +++ interpreter/value_composite.go | 19 ++ interpreter/value_dictionary.go | 19 ++ 5 files changed, 468 insertions(+) create mode 100644 interpreter/stale_atree_view_test.go diff --git a/interpreter/errors.go b/interpreter/errors.go index 1277128b51..637eb5f050 100644 --- a/interpreter/errors.go +++ b/interpreter/errors.go @@ -400,6 +400,49 @@ func (e *InvalidatedResourceError) SetLocationRange(locationRange LocationRange) e.LocationRange = locationRange } +// StaleAtreeViewError is reported when a container value wrapper +// (ArrayValue, DictionaryValue, or CompositeValue) is used to mutate the +// underlying atree container, but the wrapper has been "displaced" by a +// structural change (e.g., a slab split/merge or root promotion) triggered +// through a sibling wrapper that shares the same underlying slab tree. +// +// Internally, multiple Go-level wrappers may point to the same logical atree +// container — e.g., taking two references to the same inlined inner array +// (`&outer[i]` twice) constructs two `*atree.Array` Go objects that both hold +// the same root slab pointer. When one wrapper triggers a structural change, +// atree updates only that wrapper's `root` field; the sibling wrappers keep a +// pointer to the now-demoted slab. Any subsequent mutation through such a +// stale wrapper would write directly to a non-root slab, leaving the canonical +// view (the slab tree's actual root) out of sync with the live data. +// +// We detect this by comparing the wrapper's cached `valueID` (captured at +// construction time, stable across structural changes initiated through the +// same wrapper) with `v.array.ValueID()` (which reflects the slab ID of the +// wrapper's current root pointer). On divergence we reject the operation +// rather than silently corrupt the tree. +type StaleAtreeViewError struct { + LocationRange + ValueID string +} + +var _ errors.InternalError = &StaleAtreeViewError{} +var _ HasLocationRange = &StaleAtreeViewError{} + +func (*StaleAtreeViewError) IsInternalError() {} + +func (e *StaleAtreeViewError) Error() string { + return fmt.Sprintf( + "%s container view %s is stale: the underlying slab tree was restructured "+ + "by a mutation through a sibling wrapper", + errors.InternalErrorMessagePrefix, + e.ValueID, + ) +} + +func (e *StaleAtreeViewError) SetLocationRange(locationRange LocationRange) { + e.LocationRange = locationRange +} + // DestroyedResourceError is the error which is reported // when a user uses a destroyed resource through a reference type DestroyedResourceError struct { diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go new file mode 100644 index 0000000000..a901123cad --- /dev/null +++ b/interpreter/stale_atree_view_test.go @@ -0,0 +1,351 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package interpreter_test + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/onflow/cadence/activations" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" + "github.com/onflow/cadence/stdlib" + "github.com/onflow/cadence/test_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" +) + +// TestInterpretStaleWrapperMutationRejected covers the case where two Go-level +// wrappers (ArrayValue/DictionaryValue/CompositeValue) for the same atree +// container are created (via repeated `&outer[i]` style access), one wrapper +// triggers a slab split, and the sibling wrapper subsequently attempts a +// mutation. Without the staleness check, the mutation writes into the demoted +// (now-leaf) slab and leaves the canonical view of the container out of sync +// with the live data, manifesting as an element that is invisible to +// iteration but visible to consecutive removals — a clear violation of +// resource semantics. +func TestInterpretStaleWrapperMutationRejected(t *testing.T) { + t.Parallel() + + makeEnv := func(t *testing.T) ( + *sema.VariableActivation, + *activations.Activation[interpreter.Variable], + ) { + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + return baseValueActivation, baseActivation + } + + runInvoke := func(t *testing.T, code string) error { + baseValueActivation, baseActivation := makeEnv(t) + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + code, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + _, err = inter.Invoke("main") + return err + } + + t.Run("ArrayValue: append via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + // Two ArrayValue wrappers point to the same inner inlined-then-grown + // array. `ref` appends enough elements to trigger an atree slab split. + // `ref2`'s `*atree.Array` still points to the now-demoted old root data + // slab. The second mutation through `ref2` must be rejected with + // StaleAtreeViewError; otherwise an element ends up parked on the + // demoted slab, hidden from iteration but exposed via removeLast. + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // This mutation goes through the stale wrapper and must be rejected. + ref2.append(<- create Vault(balance: 123.456)) + + destroy outer + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("ArrayValue: insert via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + ref2.insert(at: 0, <- create Vault(balance: 123.456)) + + destroy outer + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("ArrayValue: remove via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + let extra <- ref2.remove(at: 0) + destroy extra + + destroy outer + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("DictionaryValue: insert via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[{Int: Vault}] <- [<-{0: <-create Vault(balance: 0.0)}] + + let ref = &outer[0] as auth(Mutate) &{Int: Vault} + let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} + + var i: Int = 1 + while i < 300 { + let old <- ref.insert(key: i, <-create Vault(balance: UFix64(i))) + assert(old == nil, message: "dict insert should not collide") + destroy old + i = i + 1 + } + + let old2 <- ref2.insert(key: 9999, <- create Vault(balance: 123.456)) + destroy old2 + + destroy outer + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("CompositeValue: field assignment via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + // Two CompositeValue wrappers point to the same Vault resource (via two + // `&arr[0]` references). `ref` inflates attachments enough to split the + // resource's underlying atree map; `ref2` is now a stale view. The + // subsequent field assignment through `ref2` must be rejected. + err := runInvoke(t, ` + access(all) entitlement Mod + access(all) resource R { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + access(Mod) fun setBalance(_ v: UFix64) { self.balance = v } + } + + access(all) attachment A1 for R { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for R { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for R { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for R { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for R { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for R { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + access(all) fun main() { + let r0 <- create R(balance: 0.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r <- attach A6() to <-r5 + + let arr: @[R] <- [<-r] + let ref = &arr[0] as auth(Mod) &R + let ref2 = &arr[0] as auth(Mod) &R + + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + // Field assignment through stale wrapper must be rejected. + ref2.setBalance(123.456) + + destroy arr + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("DictionaryValue: remove via stale wrapper after split", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[{Int: Vault}] <- [<-{0: <-create Vault(balance: 0.0)}] + + let ref = &outer[0] as auth(Mutate) &{Int: Vault} + let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} + + var i: Int = 1 + while i < 300 { + let old <- ref.insert(key: i, <-create Vault(balance: UFix64(i))) + destroy old + i = i + 1 + } + + let removed <- ref2.remove(key: 0) + destroy removed + + destroy outer + } + `) + var staleViewErr *interpreter.StaleAtreeViewError + assert.ErrorAs(t, err, &staleViewErr) + }) +} diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 314eceadbb..3866e628fb 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -581,6 +581,8 @@ func (v *ArrayValue) SetKey(context ContainerMutationContext, key Value, value V func (v *ArrayValue) Set(context ContainerMutationContext, index int, element Value) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -674,6 +676,8 @@ func (v *ArrayValue) MeteredString( func (v *ArrayValue) Append(context ValueTransferContext, element Value) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) // length increases by 1 @@ -754,6 +758,8 @@ func (v *ArrayValue) InsertWithoutTransfer( index int, element Value, ) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -846,6 +852,8 @@ func (v *ArrayValue) RemoveWithoutTransfer( index int, ) atree.Storable { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -1709,6 +1717,34 @@ func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } +// checkNotStale panics with a StaleAtreeViewError if this wrapper has been +// displaced by a structural change (slab split/merge/promotion) that was +// performed through a sibling wrapper sharing the same underlying slab tree. +// +// Two `*atree.Array` Go objects can be created that point to the same root +// slab — e.g., `&outer[i]` taken twice constructs two distinct wrappers via +// repeated calls to `outer.Get(i)`. When one wrapper triggers a structural +// change, atree updates only that wrapper's `root` field. The sibling +// wrappers retain a pointer to the (now demoted) old slab, whose own slab ID +// has been reassigned. Mutations through such a stale wrapper would write +// into a non-root slab, leaving the canonical view of the array (the actual +// root in storage) inconsistent with the live data. +// +// The cached `v.valueID` is captured at construction and remains stable +// across structural changes initiated through this same wrapper (because +// the wrapper's `*atree.Array.root` is updated and the new root inherits the +// original slab ID). For a wrapper that has been demoted by a sibling, +// `v.array.ValueID()` returns the slab ID of the now-leaf slab, which is +// different from the cached `v.valueID` — that's the divergence we detect. +func (v *ArrayValue) checkNotStale() { + if v.array.ValueID() == v.valueID { + return + } + panic(&StaleAtreeViewError{ + ValueID: v.valueID.String(), + }) +} + func (v *ArrayValue) GetOwner() common.Address { return common.Address(v.StorageAddress()) } diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 7af292266c..8960461fec 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -749,6 +749,8 @@ func (v *CompositeValue) OwnerValue(context MemberAccessibleContext) OptionalVal func (v *CompositeValue) RemoveMember(context ValueTransferContext, name string) Value { + v.checkNotStale() + if TracingEnabled { startTime := time.Now() @@ -820,6 +822,8 @@ func (v *CompositeValue) SetMemberWithoutTransfer( value Value, ) bool { + v.checkNotStale() + context.EnforceNotResourceDestruction(v.ValueID()) if TracingEnabled { @@ -1823,6 +1827,19 @@ func (v *CompositeValue) ValueID() atree.ValueID { return v.valueID } +// checkNotStale panics with a StaleAtreeViewError if this wrapper has been +// displaced by a structural change (slab split/merge/promotion) that was +// performed through a sibling wrapper sharing the same underlying slab tree. +// See ArrayValue.checkNotStale for full context. +func (v *CompositeValue) checkNotStale() { + if v.dictionary.ValueID() == v.valueID { + return + } + panic(&StaleAtreeViewError{ + ValueID: v.valueID.String(), + }) +} + // LiveValueID returns the underlying atree map's current value ID. // In contrast to ValueID, which returns a stable value ID cached at // construction, LiveValueID reflects mutations to the atree map's root, @@ -1839,6 +1856,8 @@ func (v *CompositeValue) RemoveField( name string, ) { + v.checkNotStale() + common.UseComputation( context, common.ComputationUsage{ diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 121f310072..890115acda 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -736,6 +736,8 @@ func (v *DictionaryValue) SetKeyWithMutationCheck( value Value, checkMutation bool, ) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) if checkMutation { @@ -1049,6 +1051,8 @@ func (v *DictionaryValue) RemoveWithoutTransfer( existingValueStorable atree.Storable, ) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) valueComparator := newValueComparator(context) @@ -1146,6 +1150,8 @@ func (v *DictionaryValue) InsertWithoutTransfer( keyValue, value atree.Value, ) (existingValueStorable atree.Storable) { + v.checkNotStale() + context.ValidateContainerMutation(v.ValueID()) // length increases by 1 @@ -1835,6 +1841,19 @@ func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } +// checkNotStale panics with a StaleAtreeViewError if this wrapper has been +// displaced by a structural change (slab split/merge/promotion) that was +// performed through a sibling wrapper sharing the same underlying slab tree. +// See ArrayValue.checkNotStale for full context. +func (v *DictionaryValue) checkNotStale() { + if v.dictionary.ValueID() == v.valueID { + return + } + panic(&StaleAtreeViewError{ + ValueID: v.valueID.String(), + }) +} + func (v *DictionaryValue) SemaType(typeConverter TypeConverter) *sema.DictionaryType { if v.semaType == nil { // this function will panic already if this conversion fails From 6119c07096515aaf3565be3329701b4fbf5ea513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 28 May 2026 10:05:31 -0700 Subject: [PATCH 056/139] also test constant-sized arrays --- interpreter/container_mutation_test.go | 354 +++++++++++++++++++++++-- 1 file changed, 334 insertions(+), 20 deletions(-) diff --git a/interpreter/container_mutation_test.go b/interpreter/container_mutation_test.go index fe090b1966..31f71a0571 100644 --- a/interpreter/container_mutation_test.go +++ b/interpreter/container_mutation_test.go @@ -1266,36 +1266,71 @@ func TestInterpretForLoopFunctionElementTypeConfusion(t *testing.T) { t.Parallel() - inter := parseCheckAndPrepare(t, ` - fun test(arr: [fun(): Void]) { - for f in arr { - f() + t.Run("variable-sized array", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [fun(): Void]) { + for f in arr { + f() + } } - } - `) + `) - // Sema sees [fun(): Void], but the array actually holds an IntValue. - confusedArray := interpreter.NewArrayValue( - inter, - &interpreter.VariableSizedStaticType{ - Type: interpreter.PrimitiveStaticTypeInt, - }, - common.ZeroAddress, - interpreter.NewUnmeteredIntValueFromInt64(42), - ) + // Sema sees [fun(): Void], but the array actually holds an IntValue. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("constant-sized array", func(t *testing.T) { + + t.Parallel() - _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck - RequireError(t, err) + inter := parseCheckAndPrepare(t, ` + fun test(arr: [fun(): Void; 1]) { + for f in arr { + f() + } + } + `) + + // Sema sees [fun(): Void; 1], but the array actually holds an IntValue. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) - var indexedTypeError *interpreter.IndexedTypeError - require.ErrorAs(t, err, &indexedTypeError) + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) } func TestInterpretIndexExpressionFunctionElementTypeConfusion(t *testing.T) { t.Parallel() - t.Run("array", func(t *testing.T) { + t.Run("variable-sized array", func(t *testing.T) { t.Parallel() @@ -1323,6 +1358,35 @@ func TestInterpretIndexExpressionFunctionElementTypeConfusion(t *testing.T) { require.ErrorAs(t, err, &indexedTypeError) }) + t.Run("constant-sized array", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [fun(): Void; 1]) { + let f = arr[0] + f() + } + `) + + // Sema sees [fun(): Void; 1], but the array actually holds an IntValue. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + t.Run("dictionary", func(t *testing.T) { t.Parallel() @@ -1528,6 +1592,53 @@ func TestInterpretContainerFunctionElementTypeConfusion(t *testing.T) { } `, }, + + // Constant-sized array — read methods. + { + name: "constant-sized array reverse", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): [String; 1] { + return arr.reverse() + } + `, + }, + { + name: "constant-sized array filter", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): [String] { + return arr.filter(view fun (_: String): Bool { return true }) + } + `, + }, + { + name: "constant-sized array map", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): [Int; 1] { + return arr.map(view fun (_: String): Int { return 0 }) + } + `, + }, + { + name: "constant-sized array firstIndex", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): Int? { + return arr.firstIndex(of: "hello") + } + `, + }, + { + name: "constant-sized array contains", + shape: constantSizedArray, + code: ` + fun test(arr: [String; 1]): Bool { + return arr.contains("hello") + } + `, + }, { name: "array toConstantSized", shape: variableSizedArray, @@ -1612,3 +1723,206 @@ func TestInterpretContainerFunctionElementTypeConfusion(t *testing.T) { }) } } + +// Element-type confusion is one axis of array shape mismatch. The other axes +// — variable-vs-constant kind mismatch and constant-array size mismatch — +// also must be caught by the defensive subtyping checks, since the runtime +// value's static type would otherwise lie about the array's capacity and +// shape. +func TestInterpretConstantSizedArrayShapeConfusion(t *testing.T) { + + t.Parallel() + + t.Run("for-loop: variable-sized expected, constant-sized actual", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String]) { + for s in arr { } + } + `) + + // Sema sees [String], but the array is actually a constant-sized [String; 1]. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("for-loop: constant-sized expected, variable-sized actual", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String; 1]) { + for s in arr { } + } + `) + + // Sema sees [String; 1], but the array is actually a variable-sized [String]. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("for-loop: constant-sized size mismatch", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String; 2]) { + for s in arr { } + } + `) + + // Sema sees [String; 2], but the array is actually a [String; 1]. + // Even though the element type matches, the size differs, so iteration + // would loop fewer times than the declared shape implies. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("indexing: variable-sized expected, constant-sized actual", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String]): String { + return arr[0] + } + `) + + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("indexing: constant-sized expected, variable-sized actual", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String; 1]): String { + return arr[0] + } + `) + + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("indexing: constant-sized size mismatch", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String; 2]): String { + return arr[1] + } + `) + + // Out-of-bounds at runtime if the smaller array escaped the check. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) + + t.Run("member: constant-sized size mismatch", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: [String; 2]): [String; 2] { + return arr.reverse() + } + `) + + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.ConstantSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeString, + Size: 1, + }, + common.ZeroAddress, + interpreter.NewUnmeteredStringValue("hello"), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + var memberAccessTypeError *interpreter.MemberAccessTypeError + require.ErrorAs(t, err, &memberAccessTypeError) + }) +} From fe84fecd0bdef5e7adc9c76d91e309a16d33701c Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 28 May 2026 10:32:35 -0700 Subject: [PATCH 057/139] Move the staleness check to a cenctralized locaiton --- bbq/vm/value_implicit_reference.go | 2 +- bbq/vm/vm.go | 22 +++++------ interpreter/array_test.go | 4 +- interpreter/composite_value_test.go | 4 +- interpreter/dictionary_test.go | 4 +- interpreter/errors.go | 29 ++++---------- interpreter/interpreter_expression.go | 50 ++++++++++++++++++++---- interpreter/interpreter_statement.go | 4 +- interpreter/stale_atree_view_test.go | 14 +++---- interpreter/value.go | 28 +++++++++++++ interpreter/value_array.go | 44 +++++---------------- interpreter/value_composite.go | 29 +++++--------- interpreter/value_dictionary.go | 29 +++++--------- interpreter/value_ephemeral_reference.go | 2 +- interpreter/value_function.go | 7 +++- interpreter/value_storage_reference.go | 2 +- interpreter/variable.go | 2 +- 17 files changed, 143 insertions(+), 133 deletions(-) diff --git a/bbq/vm/value_implicit_reference.go b/bbq/vm/value_implicit_reference.go index 334a7bb1e3..7fab4b904f 100644 --- a/bbq/vm/value_implicit_reference.go +++ b/bbq/vm/value_implicit_reference.go @@ -69,7 +69,7 @@ func (v ImplicitReferenceValue) IsValue() {} func (v ImplicitReferenceValue) ReferencedValue( context interpreter.ValueStaticTypeContext, ) interpreter.Value { - interpreter.CheckInvalidatedResourceOrResourceReference(v.selfRef, context) + interpreter.CheckInvalidatedValueOrValueReference(v.selfRef, context) return v.value } diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index d770ea583e..7903cebb06 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -93,7 +93,7 @@ func (vm *VM) pop() Value { vm.stack[lastIndex] = nil vm.stack = vm.stack[:lastIndex] - interpreter.CheckInvalidatedResourceOrResourceReference(value, vm.context) + interpreter.CheckInvalidatedValueOrValueReference(value, vm.context) return value } @@ -108,8 +108,8 @@ func (vm *VM) pop2() (Value, Value) { vm.stack = vm.stack[:lastIndex-1] context := vm.context - interpreter.CheckInvalidatedResourceOrResourceReference(value1, context) - interpreter.CheckInvalidatedResourceOrResourceReference(value2, context) + interpreter.CheckInvalidatedValueOrValueReference(value1, context) + interpreter.CheckInvalidatedValueOrValueReference(value2, context) return value1, value2 } @@ -124,9 +124,9 @@ func (vm *VM) pop3() (Value, Value, Value) { vm.stack = vm.stack[:lastIndex-2] context := vm.context - interpreter.CheckInvalidatedResourceOrResourceReference(value1, context) - interpreter.CheckInvalidatedResourceOrResourceReference(value2, context) - interpreter.CheckInvalidatedResourceOrResourceReference(value3, context) + interpreter.CheckInvalidatedValueOrValueReference(value1, context) + interpreter.CheckInvalidatedValueOrValueReference(value2, context) + interpreter.CheckInvalidatedValueOrValueReference(value3, context) return value1, value2, value3 } @@ -138,7 +138,7 @@ func (vm *VM) peekN(count int) []Value { context := vm.context for _, value := range values { - interpreter.CheckInvalidatedResourceOrResourceReference(value, context) + interpreter.CheckInvalidatedValueOrValueReference(value, context) } return values @@ -151,7 +151,7 @@ func (vm *VM) popN(count int) []Value { context := vm.context for _, value := range values { - interpreter.CheckInvalidatedResourceOrResourceReference(value, context) + interpreter.CheckInvalidatedValueOrValueReference(value, context) } vm.stack = vm.stack[:startIndex] @@ -162,7 +162,7 @@ func (vm *VM) popN(count int) []Value { func (vm *VM) peek() Value { lastIndex := len(vm.stack) - 1 value := vm.stack[lastIndex] - interpreter.CheckInvalidatedResourceOrResourceReference(value, vm.context) + interpreter.CheckInvalidatedValueOrValueReference(value, vm.context) return value } @@ -179,8 +179,8 @@ func (vm *VM) peekPop() (Value, Value) { poppedValue := vm.pop() context := vm.context - interpreter.CheckInvalidatedResourceOrResourceReference(peekedValue, context) - interpreter.CheckInvalidatedResourceOrResourceReference(poppedValue, context) + interpreter.CheckInvalidatedValueOrValueReference(peekedValue, context) + interpreter.CheckInvalidatedValueOrValueReference(poppedValue, context) return peekedValue, poppedValue } diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 56bdff37c1..f3337f583a 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -303,6 +303,6 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { _, err = inter.Invoke("main") RequireError(t, err) - var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) + var staleAtreeViewError *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleAtreeViewError) } diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 6eadbad10d..18a14715a3 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -643,6 +643,6 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // the others, so the second withdraw must fail. _, err = inter.Invoke("main") RequireError(t, err) - var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) + var staleAtreeViewError *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleAtreeViewError) } diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index a94ab1ef15..fc32253649 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -265,6 +265,6 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { _, err = inter.Invoke("main") RequireError(t, err) - var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) + var staleAtreeViewError *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleAtreeViewError) } diff --git a/interpreter/errors.go b/interpreter/errors.go index 637eb5f050..61bf26fe48 100644 --- a/interpreter/errors.go +++ b/interpreter/errors.go @@ -400,37 +400,22 @@ func (e *InvalidatedResourceError) SetLocationRange(locationRange LocationRange) e.LocationRange = locationRange } -// StaleAtreeViewError is reported when a container value wrapper +// InvalidatedContainerViewError is reported when a container value wrapper // (ArrayValue, DictionaryValue, or CompositeValue) is used to mutate the // underlying atree container, but the wrapper has been "displaced" by a // structural change (e.g., a slab split/merge or root promotion) triggered // through a sibling wrapper that shares the same underlying slab tree. -// -// Internally, multiple Go-level wrappers may point to the same logical atree -// container — e.g., taking two references to the same inlined inner array -// (`&outer[i]` twice) constructs two `*atree.Array` Go objects that both hold -// the same root slab pointer. When one wrapper triggers a structural change, -// atree updates only that wrapper's `root` field; the sibling wrappers keep a -// pointer to the now-demoted slab. Any subsequent mutation through such a -// stale wrapper would write directly to a non-root slab, leaving the canonical -// view (the slab tree's actual root) out of sync with the live data. -// -// We detect this by comparing the wrapper's cached `valueID` (captured at -// construction time, stable across structural changes initiated through the -// same wrapper) with `v.array.ValueID()` (which reflects the slab ID of the -// wrapper's current root pointer). On divergence we reject the operation -// rather than silently corrupt the tree. -type StaleAtreeViewError struct { +type InvalidatedContainerViewError struct { LocationRange ValueID string } -var _ errors.InternalError = &StaleAtreeViewError{} -var _ HasLocationRange = &StaleAtreeViewError{} +var _ errors.InternalError = &InvalidatedContainerViewError{} +var _ HasLocationRange = &InvalidatedContainerViewError{} -func (*StaleAtreeViewError) IsInternalError() {} +func (*InvalidatedContainerViewError) IsInternalError() {} -func (e *StaleAtreeViewError) Error() string { +func (e *InvalidatedContainerViewError) Error() string { return fmt.Sprintf( "%s container view %s is stale: the underlying slab tree was restructured "+ "by a mutation through a sibling wrapper", @@ -439,7 +424,7 @@ func (e *StaleAtreeViewError) Error() string { ) } -func (e *StaleAtreeViewError) SetLocationRange(locationRange LocationRange) { +func (e *InvalidatedContainerViewError) SetLocationRange(locationRange LocationRange) { e.LocationRange = locationRange } diff --git a/interpreter/interpreter_expression.go b/interpreter/interpreter_expression.go index 440285892c..e5f74b59d4 100644 --- a/interpreter/interpreter_expression.go +++ b/interpreter/interpreter_expression.go @@ -111,7 +111,7 @@ func (interpreter *Interpreter) typeIndexExpressionGetterSetter( return value, nil }, set: func(_ Value) { - CheckInvalidatedResourceOrResourceReference(target, interpreter) + CheckInvalidatedValueOrValueReference(target, interpreter) // writing to composites with indexing syntax is not supported panic(errors.NewUnreachableError()) }, @@ -363,7 +363,7 @@ func (interpreter *Interpreter) checkMemberAccess( target Value, ) { - CheckInvalidatedResourceOrResourceReference(target, interpreter) + CheckInvalidatedValueOrValueReference(target, interpreter) memberInfo, _ := interpreter.Program.Elaboration.MemberExpressionMemberAccessInfo(memberExpression) expectedType := memberInfo.AccessedType @@ -448,7 +448,7 @@ func (interpreter *Interpreter) checkIndexedValue( indexedType sema.Type, indexedValue Value, ) { - CheckInvalidatedResourceOrResourceReference(indexedValue, interpreter) + CheckInvalidatedValueOrValueReference(indexedValue, interpreter) CheckIndexedType( interpreter, @@ -489,7 +489,7 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value result := ast.AcceptExpression[Value](expression, interpreter) interpreter.expression = previousExpression - CheckInvalidatedResourceOrResourceReference( + CheckInvalidatedValueOrValueReference( result, interpreter, ) @@ -497,7 +497,11 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value return result } -func CheckInvalidatedResourceOrResourceReference( +// CheckInvalidatedValueOrValueReference checks whether a value is either: +// - an invalidated resource +// - a value pointing to a stale atree slab +// - or a reference to any of the above +func CheckInvalidatedValueOrValueReference( value Value, context ValueStaticTypeContext, ) { @@ -521,12 +525,44 @@ func CheckInvalidatedResourceOrResourceReference( // This step is not really needed, since reference tracking is supposed to clear the // `value.Value` if the referenced-value was moved/deleted. // However, have this as a second layer of defensive. - CheckInvalidatedResourceOrResourceReference( + // The staleness check below is also transitively triggered for the + // referenced value through this recursion. + CheckInvalidatedValueOrValueReference( value.Value, context, ) } } + + // After the resource/reference invalidation check above, additionally + // check for atree-backed container wrappers whose underlying slab tree has + // been restructured by a sibling wrapper sharing the same atree slab tree. + // + // Internally, multiple Go-level wrappers may point to the same logical atree + // container — e.g., taking two references to the same inlined inner array + // (`&outer[i]` twice) constructs two `*atree.Array` Go objects that both hold + // the same root slab pointer. When one wrapper triggers a structural change, + // atree updates only that wrapper's `root` field; the sibling wrappers keep a + // pointer to the now-demoted slab. Any subsequent mutation through such a + // stale wrapper would write directly to a non-root slab, leaving the canonical + // view (the slab tree's actual root) out of sync with the live data. + // + // We detect this by comparing the wrapper's cached `valueID` (captured at + // construction time, stable across structural changes initiated through the + // same wrapper) with `v.array.ValueID()` (which reflects the slab ID of the + // wrapper's current root pointer). + // On divergence we reject the operation rather than silently corrupt the tree. + // + // We do this here, rather than at each individual use site, so that every + // code path that already calls this helper transparently gains the check. + // See the `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the + // mechanism and rationale. + if atreeBackedValue, ok := value.(AtreeBackedValue); ok && + atreeBackedValue.isStaleAtreeView() { + panic(&InvalidatedContainerViewError{ + ValueID: atreeBackedValue.ValueID().String(), + }) + } } func (interpreter *Interpreter) VisitBinaryExpression(expression *ast.BinaryExpression) Value { @@ -1576,7 +1612,7 @@ func CreateReferenceValue( case ReferenceValue: if isImplicit { - CheckInvalidatedResourceOrResourceReference(value, context) + CheckInvalidatedValueOrValueReference(value, context) // During implicit reference creation (e.g: member/index access on a reference), // if the value is already a reference, we need to create a new reference diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index d47f838f48..a6477edad6 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -711,10 +711,10 @@ func (interpreter *Interpreter) VisitSwapStatement(swap *ast.SwapStatement) Stat // Set right value to left target, // and left value to right target - CheckInvalidatedResourceOrResourceReference(rightValue, interpreter) + CheckInvalidatedValueOrValueReference(rightValue, interpreter) transferredRightValue := TransferAndConvert(interpreter, rightValue, rightType, leftType) - CheckInvalidatedResourceOrResourceReference(leftValue, interpreter) + CheckInvalidatedValueOrValueReference(leftValue, interpreter) transferredLeftValue := TransferAndConvert(interpreter, leftValue, leftType, rightType) leftGetterSetter.set(transferredRightValue) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index a901123cad..a88f8555af 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -93,7 +93,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { // array. `ref` appends enough elements to trigger an atree slab split. // `ref2`'s `*atree.Array` still points to the now-demoted old root data // slab. The second mutation through `ref2` must be rejected with - // StaleAtreeViewError; otherwise an element ends up parked on the + // InvalidatedContainerViewError; otherwise an element ends up parked on the // demoted slab, hidden from iteration but exposed via removeLast. err := runInvoke(t, ` access(all) resource Vault { @@ -119,7 +119,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy outer } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) @@ -149,7 +149,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy outer } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) @@ -180,7 +180,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy outer } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) @@ -213,7 +213,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy outer } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) @@ -313,7 +313,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy arr } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) @@ -345,7 +345,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { destroy outer } `) - var staleViewErr *interpreter.StaleAtreeViewError + var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) } diff --git a/interpreter/value.go b/interpreter/value.go index 5f5fb71324..09cb900535 100644 --- a/interpreter/value.go +++ b/interpreter/value.go @@ -241,6 +241,34 @@ type ReferenceTrackedResourceKindedValue interface { IsStaleResource(ValueStaticTypeContext) bool } +// AtreeBackedValue is implemented by container value wrappers +// (`ArrayValue`/`DictionaryValue`/`CompositeValue`) that may have been +// displaced by a structural change (slab split/merge/promotion) performed +// through a sibling wrapper sharing the same atree slab tree. +// +// Two Go-level atree wrappers (`*atree.Array` / `*atree.OrderedMap`) for the +// same logical container can be constructed when the Cadence interpreter calls +// `Get` on an outer container twice (e.g. `&outer[i]` taken twice). Both +// wrappers then share the same root-slab Go pointer. When one wrapper triggers +// a structural change, atree updates only that wrapper's `root` field; the +// sibling wrappers retain a pointer to the demoted slab, whose own slab ID has +// been reassigned. Any subsequent use through such a stale wrapper would +// either write into a non-root slab (mutation) or read partial/incorrect data +// (read), leaving the canonical view of the container inconsistent with the +// live data. +// +// The cached `valueID` on each wrapper is captured at construction time and +// remains stable across structural changes initiated through the same wrapper +// (because the wrapper's atree `root` field is updated, and the new root +// inherits the original slab ID). For a wrapper that has been demoted by a +// sibling, the underlying atree value's live `ValueID()` returns the slab ID +// of the now-leaf slab, which differs from the cached `valueID` — that is the +// divergence detected by `isStaleAtreeView()`. +type AtreeBackedValue interface { + isStaleAtreeView() bool + ValueID() atree.ValueID +} + // ContractValue is the value of a contract. // Under normal circumstances, a contract value is always a CompositeValue. // However, in the test framework, an imported contract is constructed via a constructor function. diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 3866e628fb..1eb043c605 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -304,7 +304,7 @@ func (v *ArrayValue) iterate( // atree.Array iteration provides low-level atree.Value, // convert to high-level interpreter.Value elementValue := MustConvertStoredValue(context, element) - CheckInvalidatedResourceOrResourceReference(elementValue, context) + CheckInvalidatedValueOrValueReference(elementValue, context) if transferElements { // Each element must be transferred before passing onto the function. @@ -581,8 +581,6 @@ func (v *ArrayValue) SetKey(context ContainerMutationContext, key Value, value V func (v *ArrayValue) Set(context ContainerMutationContext, index int, element Value) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -676,8 +674,6 @@ func (v *ArrayValue) MeteredString( func (v *ArrayValue) Append(context ValueTransferContext, element Value) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) // length increases by 1 @@ -758,8 +754,6 @@ func (v *ArrayValue) InsertWithoutTransfer( index int, element Value, ) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -852,8 +846,6 @@ func (v *ArrayValue) RemoveWithoutTransfer( index int, ) atree.Storable { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) // We only need to check the lower bound before converting from `int` (signed) to `uint64` (unsigned). @@ -1717,32 +1709,14 @@ func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } -// checkNotStale panics with a StaleAtreeViewError if this wrapper has been -// displaced by a structural change (slab split/merge/promotion) that was -// performed through a sibling wrapper sharing the same underlying slab tree. -// -// Two `*atree.Array` Go objects can be created that point to the same root -// slab — e.g., `&outer[i]` taken twice constructs two distinct wrappers via -// repeated calls to `outer.Get(i)`. When one wrapper triggers a structural -// change, atree updates only that wrapper's `root` field. The sibling -// wrappers retain a pointer to the (now demoted) old slab, whose own slab ID -// has been reassigned. Mutations through such a stale wrapper would write -// into a non-root slab, leaving the canonical view of the array (the actual -// root in storage) inconsistent with the live data. -// -// The cached `v.valueID` is captured at construction and remains stable -// across structural changes initiated through this same wrapper (because -// the wrapper's `*atree.Array.root` is updated and the new root inherits the -// original slab ID). For a wrapper that has been demoted by a sibling, -// `v.array.ValueID()` returns the slab ID of the now-leaf slab, which is -// different from the cached `v.valueID` — that's the divergence we detect. -func (v *ArrayValue) checkNotStale() { - if v.array.ValueID() == v.valueID { - return - } - panic(&StaleAtreeViewError{ - ValueID: v.valueID.String(), - }) +// isStaleAtreeView reports whether this wrapper has been displaced by a +// structural change (slab split/merge/promotion) that was performed through +// a sibling wrapper sharing the same underlying slab tree. See the +// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full +// context. Detected uses of a stale wrapper are rejected centrally in +// `CheckInvalidatedValueOrValueReference`. +func (v *ArrayValue) isStaleAtreeView() bool { + return v.array.ValueID() != v.valueID } func (v *ArrayValue) GetOwner() common.Address { diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 8960461fec..030a14adef 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -749,8 +749,6 @@ func (v *CompositeValue) OwnerValue(context MemberAccessibleContext) OptionalVal func (v *CompositeValue) RemoveMember(context ValueTransferContext, name string) Value { - v.checkNotStale() - if TracingEnabled { startTime := time.Now() @@ -822,8 +820,6 @@ func (v *CompositeValue) SetMemberWithoutTransfer( value Value, ) bool { - v.checkNotStale() - context.EnforceNotResourceDestruction(v.ValueID()) if TracingEnabled { @@ -1801,7 +1797,7 @@ func (v *CompositeValue) forEachField( ) { err := atreeIterate(func(key atree.Value, atreeValue atree.Value) (resume bool, err error) { value := MustConvertStoredValue(context, atreeValue) - CheckInvalidatedResourceOrResourceReference(value, context) + CheckInvalidatedValueOrValueReference(value, context) resume = f( string(key.(StringAtreeValue)), @@ -1827,17 +1823,14 @@ func (v *CompositeValue) ValueID() atree.ValueID { return v.valueID } -// checkNotStale panics with a StaleAtreeViewError if this wrapper has been -// displaced by a structural change (slab split/merge/promotion) that was -// performed through a sibling wrapper sharing the same underlying slab tree. -// See ArrayValue.checkNotStale for full context. -func (v *CompositeValue) checkNotStale() { - if v.dictionary.ValueID() == v.valueID { - return - } - panic(&StaleAtreeViewError{ - ValueID: v.valueID.String(), - }) +// isStaleAtreeView reports whether this wrapper has been displaced by a +// structural change (slab split/merge/promotion) that was performed through +// a sibling wrapper sharing the same underlying slab tree. See the +// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full +// context. Detected uses of a stale wrapper are rejected centrally in +// `CheckInvalidatedValueOrValueReference`. +func (v *CompositeValue) isStaleAtreeView() bool { + return v.dictionary.ValueID() != v.valueID } // LiveValueID returns the underlying atree map's current value ID. @@ -1856,8 +1849,6 @@ func (v *CompositeValue) RemoveField( name string, ) { - v.checkNotStale() - common.UseComputation( context, common.ComputationUsage{ @@ -2135,7 +2126,7 @@ func forEachAttachment( for { // Check that the implicit composite reference was not invalidated during iteration - CheckInvalidatedResourceOrResourceReference(compositeReference, context) + CheckInvalidatedValueOrValueReference(compositeReference, context) key, value, err := iterator.Next() if err != nil { panic(errors.NewExternalError(err)) diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 890115acda..cd7f554f55 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -458,8 +458,8 @@ func (v *DictionaryValue) iterate( keyValue := MustConvertStoredValue(context, key) valueValue := MustConvertStoredValue(context, value) - CheckInvalidatedResourceOrResourceReference(keyValue, context) - CheckInvalidatedResourceOrResourceReference(valueValue, context) + CheckInvalidatedValueOrValueReference(keyValue, context) + CheckInvalidatedValueOrValueReference(valueValue, context) resume = f( keyValue, @@ -736,8 +736,6 @@ func (v *DictionaryValue) SetKeyWithMutationCheck( value Value, checkMutation bool, ) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) if checkMutation { @@ -1051,8 +1049,6 @@ func (v *DictionaryValue) RemoveWithoutTransfer( existingValueStorable atree.Storable, ) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) valueComparator := newValueComparator(context) @@ -1150,8 +1146,6 @@ func (v *DictionaryValue) InsertWithoutTransfer( keyValue, value atree.Value, ) (existingValueStorable atree.Storable) { - v.checkNotStale() - context.ValidateContainerMutation(v.ValueID()) // length increases by 1 @@ -1841,17 +1835,14 @@ func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } -// checkNotStale panics with a StaleAtreeViewError if this wrapper has been -// displaced by a structural change (slab split/merge/promotion) that was -// performed through a sibling wrapper sharing the same underlying slab tree. -// See ArrayValue.checkNotStale for full context. -func (v *DictionaryValue) checkNotStale() { - if v.dictionary.ValueID() == v.valueID { - return - } - panic(&StaleAtreeViewError{ - ValueID: v.valueID.String(), - }) +// isStaleAtreeView reports whether this wrapper has been displaced by a +// structural change (slab split/merge/promotion) that was performed through +// a sibling wrapper sharing the same underlying slab tree. See the +// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full +// context. Detected uses of a stale wrapper are rejected centrally in +// `CheckInvalidatedValueOrValueReference`. +func (v *DictionaryValue) isStaleAtreeView() bool { + return v.dictionary.ValueID() != v.valueID } func (v *DictionaryValue) SemaType(typeConverter TypeConverter) *sema.DictionaryType { diff --git a/interpreter/value_ephemeral_reference.go b/interpreter/value_ephemeral_reference.go index fbced72fd3..78ee0194cf 100644 --- a/interpreter/value_ephemeral_reference.go +++ b/interpreter/value_ephemeral_reference.go @@ -423,7 +423,7 @@ var _ ValueIterator = &ReferenceValueIterator{} func (i *ReferenceValueIterator) Next(context ValueIteratorContext) Value { // Iterator implicitly captures the reference. // Therefore, check whether the reference is valid, everytime the iterator is used. - CheckInvalidatedResourceOrResourceReference(i.reference, context) + CheckInvalidatedValueOrValueReference(i.reference, context) return i.iterator.Next(context) } diff --git a/interpreter/value_function.go b/interpreter/value_function.go index e045918df5..e53fa5507c 100644 --- a/interpreter/value_function.go +++ b/interpreter/value_function.go @@ -498,7 +498,12 @@ func MaybeDereferenceReceiver( isNative bool, ) Value { - CheckInvalidatedResourceOrResourceReference(receiverReference, context) + // CheckInvalidatedValueOrValueReference also detects atree-backed + // container wrappers whose underlying slab tree was restructured by a + // sibling wrapper (see `AtreeBackedValue` / `InvalidatedContainerViewError`). + // Since every method dispatch on a container reference funnels through + // this function, that single check guards all method-dispatch paths. + CheckInvalidatedValueOrValueReference(receiverReference, context) // Receiver needs to be dereferenced, if: // - The function always required the receiver to be dereferenced (e.g: interpreted functions). diff --git a/interpreter/value_storage_reference.go b/interpreter/value_storage_reference.go index 59a3a55b43..4cbe8f67e7 100644 --- a/interpreter/value_storage_reference.go +++ b/interpreter/value_storage_reference.go @@ -472,7 +472,7 @@ func forEachReference( // The loop dereference the reference once, and hold onto that referenced-value. // But the reference could get invalidated during the iteration, making that referenced-value invalid. // So check the validity of the reference, before each iteration. - CheckInvalidatedResourceOrResourceReference(reference, context) + CheckInvalidatedValueOrValueReference(reference, context) if isResultReference { value = getReferenceValue( diff --git a/interpreter/variable.go b/interpreter/variable.go index 4f645d3971..65c9e95c98 100644 --- a/interpreter/variable.go +++ b/interpreter/variable.go @@ -135,7 +135,7 @@ func (v *SelfVariable) InitializeWithGetter(func() Value) { func (v *SelfVariable) GetValue(context ValueStaticTypeContext) Value { // TODO: pass proper location range - CheckInvalidatedResourceOrResourceReference(v.selfRef, context) + CheckInvalidatedValueOrValueReference(v.selfRef, context) return v.value } From c111536051e08b4673fbf4775a2710dfbe9a55db Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 28 May 2026 11:01:01 -0700 Subject: [PATCH 058/139] Simplify tests --- interpreter/stale_atree_view_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index a88f8555af..177312bac1 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -13,8 +13,6 @@ package interpreter_test import ( - "fmt" - "os" "testing" "github.com/stretchr/testify/assert" @@ -45,17 +43,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { *sema.VariableActivation, *activations.Activation[interpreter.Variable], ) { - logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { - fmt.Fprintln(os.Stderr, message) - return nil - })) baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) - baseValueActivation.DeclareValue(logFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) - interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation From 6d90db8a6a23365116d7c6be1f7d8d2cc8a66e5a Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Thu, 28 May 2026 11:07:59 -0700 Subject: [PATCH 059/139] Update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index fdd11dba55..70c8151e24 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.2" +const Version = "v1.10.4-rc.3" From cced37c2c2a626a121f5e271f569596aa7c71c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 28 May 2026 11:38:30 -0700 Subject: [PATCH 060/139] test liveValueID --- interpreter/stale_atree_view_test.go | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 177312bac1..263069f637 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -44,10 +44,57 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { *activations.Activation[interpreter.Variable], ) { + // liveValueID exposes the underlying atree container's current value ID + // so the Cadence code can confirm the slab split actually occurred + // before attempting the stale-wrapper mutation. + liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueID", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + }, + sema.StringTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + ref := args[0].(*interpreter.EphemeralReferenceValue) + var id string + switch v := ref.Value.(type) { + case *interpreter.ArrayValue: + id = v.LiveValueID().String() + case *interpreter.DictionaryValue: + id = v.LiveValueID().String() + case *interpreter.CompositeValue: + id = v.LiveValueID().String() + default: + t.Fatalf("unexpected value type %T", ref.Value) + } + return interpreter.NewUnmeteredStringValue(id) + }, + ) + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(liveValueIDFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, liveValueIDFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation @@ -99,12 +146,22 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &outer[0] as auth(Mutate) &[Vault] let ref2 = &outer[0] as auth(Mutate) &[Vault] + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + var i: Int = 0 while i < 200 { ref.append(<-create Vault(balance: UFix64(i))) i = i + 1 } + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + // This mutation goes through the stale wrapper and must be rejected. ref2.append(<- create Vault(balance: 123.456)) @@ -130,12 +187,22 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &outer[0] as auth(Mutate) &[Vault] let ref2 = &outer[0] as auth(Mutate) &[Vault] + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + var i: Int = 0 while i < 200 { ref.append(<-create Vault(balance: UFix64(i))) i = i + 1 } + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + ref2.insert(at: 0, <- create Vault(balance: 123.456)) destroy outer @@ -160,12 +227,22 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &outer[0] as auth(Mutate) &[Vault] let ref2 = &outer[0] as auth(Mutate) &[Vault] + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + var i: Int = 0 while i < 200 { ref.append(<-create Vault(balance: UFix64(i))) i = i + 1 } + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + let extra <- ref2.remove(at: 0) destroy extra @@ -191,6 +268,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &outer[0] as auth(Mutate) &{Int: Vault} let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + var i: Int = 1 while i < 300 { let old <- ref.insert(key: i, <-create Vault(balance: UFix64(i))) @@ -199,6 +281,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { i = i + 1 } + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + let old2 <- ref2.insert(key: 9999, <- create Vault(balance: 123.456)) destroy old2 @@ -292,6 +379,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &arr[0] as auth(Mod) &R let ref2 = &arr[0] as auth(Mod) &R + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + ref[A1]!.inflate() ref[A2]!.inflate() ref[A3]!.inflate() @@ -299,6 +391,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ref[A5]!.inflate() ref[A6]!.inflate() + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + // Field assignment through stale wrapper must be rejected. ref2.setBalance(123.456) @@ -324,6 +421,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref = &outer[0] as auth(Mutate) &{Int: Vault} let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} + assert( + liveValueID(ref) == liveValueID(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + var i: Int = 1 while i < 300 { let old <- ref.insert(key: i, <-create Vault(balance: UFix64(i))) @@ -331,6 +433,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { i = i + 1 } + assert( + liveValueID(ref) != liveValueID(ref2), + message: "after split: refs should observe diverged live atree value IDs" + ) + let removed <- ref2.remove(key: 0) destroy removed From 541b41d222d9e3e6f745f0113408bf3c5f2591b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 28 May 2026 13:38:28 -0700 Subject: [PATCH 061/139] look up variables by name, assert line number --- interpreter/array_test.go | 89 +++++++++++++++++----------- interpreter/composite_value_test.go | 77 +++++++++++++----------- interpreter/dictionary_test.go | 74 ++++++++++++----------- interpreter/stale_atree_view_test.go | 66 ++++++++++++--------- 4 files changed, 177 insertions(+), 129 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index f3337f583a..4ecde75f16 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -166,14 +166,23 @@ func TestCheckArrayReferenceTypeInferenceWithDowncasting(t *testing.T) { require.ErrorAs(t, err, &forceCastTypeMismatchError) } -// TestInterpretArrayValueIDTracking is the ArrayValue counterpart to -// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split -// stale-view scenario, where two ArrayValue instances wrap the same underlying -// atree array (created by accessing the same outer array element twice) and a -// split through one instance leaves the other with a different live value ID. -// Without the cached valueID on ArrayValue, an EphemeralReferenceValue created -// from the stale view would register under that different ID, bypass -// invalidation when the inner array is moved, and survive as a dangling ref. +// TestInterpretArrayValueIDTracking verifies that the Cadence-level +// "immortal reference" exploit attempt is rejected: an attacker takes two +// `&outer[i]` references to the same inner inlined array, drives an atree +// slab split through one ref, and then casts the now-stale sibling ref +// through `&AnyResource` and back hoping to obtain a reference that survives +// invalidation when the inner array is moved. +// +// The runtime defends this in two layers: (1) the staleness check on any +// expression that dereferences the stale ref (the primary defense, which +// fires at the conversion site), and (2) the cached-valueID on ArrayValue +// that keeps a converted reference registered under the same stable ID as +// the original so it gets invalidated alongside it (the secondary defense, +// reachable only if the staleness check is bypassed). +// +// This test exercises the full Cadence-level exploit and asserts that the +// runtime rejects it. With the staleness check in place, the rejection +// surfaces as InvalidatedContainerViewError at the cast site. func TestInterpretArrayValueIDTracking(t *testing.T) { t.Parallel() @@ -182,35 +191,37 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { return nil })) - // liveValueID exposes the underlying atree array's current value ID so the - // Cadence code can confirm the slab split actually occurred. - liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( - "liveValueID", + // liveValueIDOf exposes the underlying atree array's current value ID so + // the Cadence code can confirm the slab split actually occurred. + // + // It takes the *name* of the reference variable rather than the reference + // itself: passing the stale ref directly would trip the staleness check on + // the call-site expression and shadow the real exploit-site error. + // Resolving the variable internally via GetValueOfVariable bypasses the + // per-expression check. + liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "ref", - TypeAnnotation: sema.NewTypeAnnotation( - &sema.ReferenceType{ - Type: sema.AnyResourceType, - Authorization: sema.UnauthorizedAccess, - }, - ), + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, }, }, sema.StringTypeAnnotation, ), "", func( - _ interpreter.NativeFunctionContext, + context interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - ref := args[0].(*interpreter.EphemeralReferenceValue) + name := args[0].(*interpreter.StringValue).Str + ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) arrayValue := ref.Value.(*interpreter.ArrayValue) return interpreter.NewUnmeteredStringValue(arrayValue.LiveValueID().String()) }, @@ -218,12 +229,12 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(logFunction) - baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(liveValueIDOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, logFunction) - interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( @@ -246,7 +257,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // Both refs initially see the same root slab in the underlying atree array. assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -262,23 +273,28 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // ref2's array.root still points to the old root slab (with a freshly // assigned slab ID), so their live value IDs diverge. assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) - // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue - // from ref2. Without the stable cached valueID on ArrayValue, this reference - // would be tracked under the (now-different) live ValueID of the stale view. + // Exploit attempt: conversion roundtrip via AnyResource to obtain an + // EphemeralReferenceValue from the stale ref2. If neither the + // staleness check nor the cached-valueID mechanism caught this, the + // resulting reference would be tracked under the (now-different) live + // ValueID of the stale view and could survive the subsequent move as + // an "immortal" reference. let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &[Vault] - // Replace the inner array with an empty one. The move invalidates all - // tracked references to the old inner array. + // Replace the inner array with an empty one. The move would invalidate + // a properly-tracked immortalRef. var empty: @[Vault] <- [] var extracted <- outer[0] <- empty destroy extracted - // immortalRef must be invalidated; touching it must panic with - // InvalidatedResourceReferenceError. + // The exploit aims for this access to succeed and return the length + // of the (now moved-out) old inner array — exposing the resources it + // held. If the runtime defends correctly, execution never reaches + // this line. log(immortalRef.length.toString()) destroy outer @@ -305,4 +321,11 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { RequireError(t, err) var staleAtreeViewError *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleAtreeViewError) + // The staleness check (the primary defense) fires at the cast operand + // `ref2` during expression evaluation, before the conversion completes. + // This pins the rejection to the exploit's cast site; if a future change + // shifts the rejection earlier (e.g. into a sanity assertion) or later + // (e.g. all the way to `immortalRef.length`), this assertion will fail + // and force a re-evaluation of which defense is actually firing. + assert.Equal(t, 45, staleAtreeViewError.StartPosition().Line) } diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 18a14715a3..729cefc7dc 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -413,6 +413,15 @@ func TestInterpretSimpleCompositeTypeFunctionMember(t *testing.T) { }) } +// TestInterpretCompositeValueIDTracking is the CompositeValue counterpart to +// TestInterpretArrayValueIDTracking: it exercises the same Cadence-level +// "immortal reference" exploit against a resource whose underlying atree map +// has been split, and the exploit goes further by attempting to double-spend +// the resource by withdrawing through both the original ref and the +// "immortal" one. The runtime must reject the exploit; with the staleness +// check in place, the rejection surfaces at the cast site as +// InvalidatedContainerViewError. See TestInterpretArrayValueIDTracking for +// the full rationale around the two defense layers. func TestInterpretCompositeValueIDTracking(t *testing.T) { t.Parallel() @@ -421,23 +430,21 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { return nil })) - // liveValueID exposes the underlying atree map's current value ID for a - // composite resource, used by the Cadence code to assert that the slab - // split actually occurred. - liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( - "liveValueID", + // liveValueIDOf exposes the underlying atree map's current value ID for a + // composite resource so the Cadence code can confirm the slab split + // actually occurred. It takes the *name* of the reference variable so + // resolving the (potentially stale) reference happens inside Go via + // GetValueOfVariable, bypassing the per-expression staleness check that + // would otherwise fire at this call. + liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "ref", - TypeAnnotation: sema.NewTypeAnnotation( - &sema.ReferenceType{ - Type: sema.AnyResourceType, - Authorization: sema.UnauthorizedAccess, - }, - ), + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, }, }, sema.StringTypeAnnotation, @@ -450,7 +457,8 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - ref := args[0].(*interpreter.EphemeralReferenceValue) + name := args[0].(*interpreter.StringValue).Str + ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) composite := ref.Value.(*interpreter.CompositeValue) return interpreter.NewUnmeteredStringValue(composite.LiveValueID().String()) }, @@ -458,12 +466,12 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(logFunction) - baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(liveValueIDOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, logFunction) - interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( @@ -563,7 +571,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // Both refs initially see the same root slab in the underlying atree map. assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -587,23 +595,25 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // original root ID, while ref2's live value ID is the freshly assigned ID of // the now-demoted slab. assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) - // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. - // Without the stable cached valueID on CompositeValue, this reference would be - // tracked under the (now-different) live ValueID of the stale view, which would - // let it bypass invalidation when the vault is moved. + // Exploit attempt: conversion roundtrip via AnyResource to obtain an + // EphemeralReferenceValue from the stale ref2. If neither the + // staleness check nor the cached-valueID mechanism caught this, the + // resulting reference would be tracked under the (now-different) live + // ValueID of the stale view and survive the subsequent move, enabling + // the doubling exploit below. let immortalRef = (ref2 as auth(Withdraw) &AnyResource) as! auth(Withdraw) &Vault - // Move the vault. Reference invalidation must void "immortalRef" alongside - // "ref" and "ref2": the cached valueID ensures all three are tracked under the - // same stable ID. + // Move the vault. A properly-tracked immortalRef gets invalidated by + // the move alongside ref/ref2. var extracted <- arr[0] <- empty stash.deposit(from: <- extracted) - // This second withdraw must panic with InvalidatedResourceReferenceError, - // because immortalRef was invalidated when the vault was moved above. + // The exploit's payoff: a second withdraw through immortalRef would + // double-spend the vault's balance. If the runtime defends correctly, + // execution never reaches this line. stash.deposit(from: <- immortalRef.withdraw(amount: immortalRef.balance)) destroy arr @@ -634,15 +644,14 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { ) require.NoError(t, err) - // After an atree slab split, a stale view of the resource's dictionary can - // produce a different live ValueID than the original. Without the cached - // valueID on CompositeValue, an EphemeralReferenceValue created from such - // a stale view would register under a different ID, bypassing invalidation - // when the resource is moved, and allow the balance to be withdrawn twice. - // The cached valueID ensures the stale reference is invalidated alongside - // the others, so the second withdraw must fail. _, err = inter.Invoke("main") RequireError(t, err) var staleAtreeViewError *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleAtreeViewError) + // The staleness check (the primary defense) fires at the cast operand + // `ref2` during expression evaluation, before the conversion completes. + // Pinning the rejection here ensures the test fails loudly if a future + // change shifts the rejection somewhere else (e.g. into a sanity + // assertion above, or all the way to `immortalRef.withdraw(...)`). + assert.Equal(t, 130, staleAtreeViewError.StartPosition().Line) } diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index fc32253649..7368e6e491 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -125,15 +125,13 @@ func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { }) } -// TestInterpretDictionaryValueIDTracking is the DictionaryValue counterpart to -// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split -// stale-view scenario, where two DictionaryValue instances wrap the same -// underlying atree map (created by accessing the same outer dictionary key -// twice) and a split through one instance leaves the other with a different -// live value ID. Without the cached valueID on DictionaryValue, an -// EphemeralReferenceValue created from the stale view would register under -// that different ID, bypass invalidation when the inner dictionary is moved, -// and survive as a dangling ref. +// TestInterpretDictionaryValueIDTracking is the DictionaryValue counterpart +// to TestInterpretArrayValueIDTracking: it exercises the same Cadence-level +// "immortal reference" exploit attempt against an inner inlined dictionary +// and asserts the runtime rejects it. See TestInterpretArrayValueIDTracking +// for the full rationale around the two defense layers (staleness check +// then cached-valueID) and why the rejection currently surfaces at the +// cast site as InvalidatedContainerViewError. func TestInterpretDictionaryValueIDTracking(t *testing.T) { t.Parallel() @@ -142,35 +140,34 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { return nil })) - // liveValueID exposes the underlying atree map's current value ID so the - // Cadence code can confirm the slab split actually occurred. - liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( - "liveValueID", + // liveValueIDOf exposes the underlying atree map's current value ID so the + // Cadence code can confirm the slab split actually occurred. It takes the + // *name* of the reference variable so that resolving the (potentially + // stale) reference happens inside Go via GetValueOfVariable, bypassing the + // per-expression staleness check that would otherwise fire at this call. + liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "ref", - TypeAnnotation: sema.NewTypeAnnotation( - &sema.ReferenceType{ - Type: sema.AnyResourceType, - Authorization: sema.UnauthorizedAccess, - }, - ), + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, }, }, sema.StringTypeAnnotation, ), "", func( - _ interpreter.NativeFunctionContext, + context interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - ref := args[0].(*interpreter.EphemeralReferenceValue) + name := args[0].(*interpreter.StringValue).Str + ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) dictValue := ref.Value.(*interpreter.DictionaryValue) return interpreter.NewUnmeteredStringValue(dictValue.LiveValueID().String()) }, @@ -178,12 +175,12 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(logFunction) - baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(liveValueIDOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, logFunction) - interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( @@ -206,7 +203,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { // Both refs initially see the same root slab in the underlying atree map. assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -223,24 +220,25 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { // ref2's dictionary.root still points to the old root slab (with a freshly // assigned slab ID), so their live value IDs diverge. assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) - // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue - // from ref2. Without the stable cached valueID on DictionaryValue, this - // reference would be tracked under the (now-different) live ValueID of the - // stale view. + // Exploit attempt: conversion roundtrip via AnyResource to obtain an + // EphemeralReferenceValue from the stale ref2. If neither the + // staleness check nor the cached-valueID mechanism caught this, the + // resulting reference would be tracked under the (now-different) live + // ValueID of the stale view and survive the subsequent move. let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &{String: Vault} - // Replace the inner dictionary with an empty one. The move invalidates - // all tracked references to the old inner dictionary. + // Replace the inner dictionary with an empty one. The move would + // invalidate a properly-tracked immortalRef. var empty: @{String: Vault} <- {} var extracted <- outer["a"] <- empty destroy extracted - // immortalRef must be invalidated; touching it must panic with - // InvalidatedResourceReferenceError. + // The exploit aims for this access to succeed. If the runtime defends + // correctly, execution never reaches this line. log(immortalRef.length.toString()) destroy outer @@ -267,4 +265,10 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { RequireError(t, err) var staleAtreeViewError *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleAtreeViewError) + // The staleness check (the primary defense) fires at the cast operand + // `ref2` during expression evaluation, before the conversion completes. + // Pinning the rejection here ensures the test fails loudly if a future + // change shifts the rejection somewhere else (e.g. into a sanity + // assertion above, or all the way to `immortalRef.length`). + assert.Equal(t, 45, staleAtreeViewError.StartPosition().Line) } diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 263069f637..8b5585da88 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -44,36 +44,42 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { *activations.Activation[interpreter.Variable], ) { - // liveValueID exposes the underlying atree container's current value ID + // liveValueIDOf exposes the underlying atree container's current value ID // so the Cadence code can confirm the slab split actually occurred // before attempting the stale-wrapper mutation. - liveValueIDFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( - "liveValueID", + // + // It takes the *name* of the reference variable rather than the + // reference itself: passing a stale reference as a function argument + // would trip the staleness check during expression evaluation (every + // expression result goes through CheckInvalidatedValueOrValueReference, + // which recursively descends into reference values), so the check would + // fire at the call-site rather than the mutation. Resolving the + // variable internally via GetValueOfVariable bypasses the per- + // expression check. + liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "ref", - TypeAnnotation: sema.NewTypeAnnotation( - &sema.ReferenceType{ - Type: sema.AnyResourceType, - Authorization: sema.UnauthorizedAccess, - }, - ), + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, }, }, sema.StringTypeAnnotation, ), "", func( - _ interpreter.NativeFunctionContext, + context interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - ref := args[0].(*interpreter.EphemeralReferenceValue) + name := args[0].(*interpreter.StringValue).Str + value := context.GetValueOfVariable(name) + ref := value.(*interpreter.EphemeralReferenceValue) var id string switch v := ref.Value.(type) { case *interpreter.ArrayValue: @@ -90,11 +96,11 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ) baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) - baseValueActivation.DeclareValue(liveValueIDFunction) + baseValueActivation.DeclareValue(liveValueIDOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) - interpreter.Declare(baseActivation, liveValueIDFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation @@ -147,7 +153,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -158,7 +164,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { } assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -170,6 +176,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 30, staleViewErr.StartPosition().Line) }) t.Run("ArrayValue: insert via stale wrapper after split", func(t *testing.T) { @@ -188,7 +195,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -199,7 +206,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { } assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -210,6 +217,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 29, staleViewErr.StartPosition().Line) }) t.Run("ArrayValue: remove via stale wrapper after split", func(t *testing.T) { @@ -228,7 +236,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -239,7 +247,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { } assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -251,6 +259,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 29, staleViewErr.StartPosition().Line) }) t.Run("DictionaryValue: insert via stale wrapper after split", func(t *testing.T) { @@ -269,7 +278,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -282,7 +291,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { } assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -294,6 +303,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 31, staleViewErr.StartPosition().Line) }) t.Run("CompositeValue: field assignment via stale wrapper after split", func(t *testing.T) { @@ -380,7 +390,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &arr[0] as auth(Mod) &R assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -392,7 +402,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ref[A6]!.inflate() assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -404,6 +414,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 95, staleViewErr.StartPosition().Line) }) t.Run("DictionaryValue: remove via stale wrapper after split", func(t *testing.T) { @@ -422,7 +433,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} assert( - liveValueID(ref) == liveValueID(ref2), + liveValueIDOf("ref") == liveValueIDOf("ref2"), message: "before split: both refs should observe the same live atree value ID" ) @@ -434,7 +445,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { } assert( - liveValueID(ref) != liveValueID(ref2), + liveValueIDOf("ref") != liveValueIDOf("ref2"), message: "after split: refs should observe diverged live atree value IDs" ) @@ -446,5 +457,6 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { `) var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 30, staleViewErr.StartPosition().Line) }) } From 99759c6696838f3227730ed1ecb95dd75d180f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 28 May 2026 16:53:56 -0700 Subject: [PATCH 062/139] canonicalize atree values --- interpreter/array_test.go | 683 +++++++++++++++++++++++++++- interpreter/composite_value_test.go | 383 +++++++++++++++- interpreter/dictionary_test.go | 135 +++++- interpreter/interpreter.go | 21 + interpreter/sharedstate.go | 23 + interpreter/storage.go | 103 +++++ interpreter/value_array.go | 175 +++++-- interpreter/value_composite.go | 16 +- interpreter/value_dictionary.go | 36 +- 9 files changed, 1473 insertions(+), 102 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 56bdff37c1..954f634cf1 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -239,40 +239,40 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // will take two references to. let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] - // Two EphemeralReferenceValues pointing to two different ArrayValues - // which wrap the same underlying atree array. + // Two EphemeralReferenceValues to the same logical inner array. + // The shared-state cache (ConvertStoredValue) deduplicates the + // Cadence wrappers, so both refs hold the same ArrayValue and observe + // the same underlying *atree.Array. let ref = &outer[0] as auth(Mutate) &[Vault] let ref2 = &outer[0] as auth(Mutate) &[Vault] - // Both refs initially see the same root slab in the underlying atree array. + // Both refs see the same live atree value ID. assert( liveValueID(ref) == liveValueID(ref2), message: "before split: both refs should observe the same live atree value ID" ) - // Append enough vaults via ref to grow the inner array past one slab - // and trigger an atree array slab split. + // Append enough vaults to force the inner array's root to split. var i: Int = 0 while i < 200 { ref.append(<-create Vault(balance: UFix64(i))) i = i + 1 } - // After the split, ref's array.root points to the new root slab while - // ref2's array.root still points to the old root slab (with a freshly - // assigned slab ID), so their live value IDs diverge. + // Both refs share the canonical wrapper, so the slab split through ref + // is visible to ref2; their live value IDs continue to agree. assert( - liveValueID(ref) != liveValueID(ref2), - message: "after split: refs should observe diverged live atree value IDs" + liveValueID(ref) == liveValueID(ref2), + message: "after split: refs must still observe the same live atree value ID" ) - // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue - // from ref2. Without the stable cached valueID on ArrayValue, this reference - // would be tracked under the (now-different) live ValueID of the stale view. + // Conversion roundtrip via AnyResource. Because ref and ref2 wrap the + // same canonical ArrayValue, immortalRef is registered under the same + // value ID as the others. let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &[Vault] // Replace the inner array with an empty one. The move invalidates all - // tracked references to the old inner array. + // tracked references to the old inner array, including immortalRef. var empty: @[Vault] <- [] var extracted <- outer[0] <- empty destroy extracted @@ -306,3 +306,658 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError assert.ErrorAs(t, err, &invalidatedResourceReferenceError) } + +// TestInterpretArrayAliasedMutationConsistency verifies that mutations +// through one of multiple references to the same inner array are observed +// consistently by the others. Specifically: 200 appends through `ref` +// trigger an atree slab split, then 1 append through `ref2` must land in +// the canonical structure (not in a stale child slab). Iteration count +// and length-driven removeLast count must agree after extraction. +// +// Before the canonical wrapper cache, each `&outer[0]` produced its own +// wrapper around a separate *atree.Array. After the split through `ref`, +// `ref2`'s root pointer went stale; the stale append silently inserted +// a phantom Vault into a demoted child slab without updating the +// canonical root's count, producing iteration/length disagreement and a +// resource-duplication vector. +func TestInterpretArrayAliasedMutationConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + ref2.append(<- create Vault(balance: 123.456)) + + var empty: @[Vault] <- [] + var extracted <- outer[0] <- empty + + var iteratedCount: Int = 0 + var removalCount: Int = 0 + var elementFoundIterating = false + var elementFoundRemoving = false + + for element in &extracted as &[Vault] { + iteratedCount = iteratedCount + 1 + if element.balance == 123.456 { + elementFoundIterating = true + } + } + while extracted.length > 0 { + let element <- extracted.removeLast() + if element.balance == 123.456 { + elementFoundRemoving = true + } + destroy element + removalCount = removalCount + 1 + } + + assert(iteratedCount == extracted.length + removalCount, + message: "for-iteration count must match length seen at start") + assert(iteratedCount == removalCount, + message: "iteration count must match removeLast count") + assert(elementFoundIterating == elementFoundRemoving, + message: "stale-append must be either seen by both traversals or by neither") + + destroy extracted + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretArrayForLoopAliasingConsistency verifies the for-loop +// iteration path (ArrayIterator.Next) hands back canonical wrappers, so a +// loop variable taken via `for x in &arr as &[T]` is aliased with an +// `&arr[i]` reference. Without canonicalization at the iterator, the loop +// variable would hold a fresh `*atree.Array` over the same slab; a split +// triggered through the external reference would leave the loop's wrapper +// stale (and vice versa). +func TestInterpretArrayForLoopAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let externalRef = &outer[0] as auth(Mutate) &[Vault] + + // Cause a split through the external reference so any non- + // canonical wrapper the for-loop subsequently constructs would + // observe a different live root. + var i: Int = 0 + while i < 200 { + externalRef.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // Iterate via reference. The loop variable's element references + // are unauthorized (Cadence doesn't propagate the outer auth into + // element refs), but they must still observe the canonical + // post-split state - which means the iterator's element load + // (ArrayIterator.Next) must hand back the canonical wrapper. + var loopMaxLength: Int = 0 + var loopCount: Int = 0 + for inner in &outer as &[[Vault]] { + loopCount = loopCount + 1 + if inner.length > loopMaxLength { + loopMaxLength = inner.length + } + } + + assert(loopCount == 1, + message: "outer has exactly one inner array") + assert(loopMaxLength == 201, + message: "for-loop ref must see all 201 elements through canonical wrapper") + + // After the loop, a freshly-taken authorized ref must still be + // aliased with externalRef. + let postRef = &outer[0] as auth(Mutate) &[Vault] + postRef.append(<-create Vault(balance: 999.0)) + assert(externalRef.length == 202, + message: "append through fresh ref must be visible to original external ref") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretArraySliceDoesNotInvalidateAliases verifies that calling +// container methods that build a new array (slice, reverse, filter, map) +// on a source whose elements are externally aliased does not invalidate +// the source's aliased references. The slice/reverse/etc. element load +// is deliberately *not* canonicalized (the element is fed through a +// closure and/or transferred into the result via a read-only iterator), +// so it must not poison the cache for the source. The complementary +// asReference aliasing bug (where the reference stored in the result +// holds a transient wrapper that goes stale after an external split) +// is tracked separately - fixing it requires switching slice's +// iterator to non-read-only so canonicalization can adopt a fresh +// mutable atree instance. +func TestInterpretArraySliceDoesNotInvalidateAliases(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) fun main() { + let outer: [[Int]] = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + + let innerRef = &outer[0] as auth(Mutate) &[Int] + + // Slice the outer array. This iterates outer's elements and + // transfers them into a new array. It must not invalidate + // innerRef, which is a separate aliased reference to outer[0]. + let outerRef = &outer as &[[Int]] + let sliced = outerRef.slice(from: 0, upTo: 2) + assert(sliced.length == 2) + + // innerRef must still observe outer[0] correctly. + assert(innerRef.length == 3, + message: "outer slice must not invalidate alias to outer[0]") + innerRef.append(99) + assert(innerRef.length == 4, + message: "alias must remain mutable after outer.slice") + assert(outer[0].length == 4, + message: "mutation through alias must be visible on outer[0]") + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + + + +// TestInterpretArrayAsReferenceContainerMethodAliasingConsistency covers +// the asReference branch of Array.slice / .concat / .filter / .map / +// .toVariableSized / .toConstantSized: each builds a result whose +// elements are references back to source elements (stored in the result +// via NonStorable, surviving the call). Without canonicalization + +// mutable iterator, the reference wraps a transient *atree.Array that +// goes stale after a split triggered through a separate canonical +// reference - result[0].length would report a child-slab count rather +// than the full count after 200 large-string appends. +// +// Uses large strings so 200 appends genuinely exceed a single slab and +// trigger splitRoot. With smaller payloads (e.g. small ints), the array +// fits in one data slab and both wrappers share that slab struct, +// hiding the bug. +func TestInterpretArrayAsReferenceContainerMethodAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + cases := []struct { + name string + setup string + expr string + alias string + mutVia string + }{ + { + name: "slice", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.slice(from: 0, upTo: 1)", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "reverse", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.reverse()", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "concat", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.concat([])", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "filter", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.filter(view fun (e: &[String]): Bool { return true })", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "map", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.map(view fun (e: &[String]): &[String] { return e })", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "toVariableSized", + setup: `let outer: [[String]; 1] = [["x"]]; let outerRef = &outer as &[[String]; 1]`, + expr: "outerRef.toVariableSized()", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + { + name: "toConstantSized", + setup: `let outer: [[String]] = [["x"]]; let outerRef = &outer as &[[String]]`, + expr: "outerRef.toConstantSized<[&[String]; 1]>()!", + alias: "&outer[0] as auth(Mutate) &[String]", + mutVia: "aliasRef", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + code := fmt.Sprintf(` + access(all) fun main(): Int { + %s + + let result = %s + + let aliasRef = %s + + let big = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + var i: Int = 0 + while i < 200 { + %s.append(big) + i = i + 1 + } + + return result[0].length + } +`, tc.setup, tc.expr, tc.alias, tc.mutVia) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + code, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + result, err := inter.Invoke("main") + require.NoError(t, err) + require.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(201), result, + "result[0].length must reflect post-split state via canonicalized wrapper") + }) + } +} + +// TestInterpretOptionalContainerAliasingConsistency verifies the +// SomeStorable.StoredValue path: when a stored optional wraps a +// container (e.g. `{String: [Vault]?}`), accessing the optional should +// produce a SomeValue whose inner container wrapper is canonical, so +// aliased references through different access paths share state. +// +// SomeStorable.StoredValue itself does not have cache access (gauge is +// the decode-time gauge, not the current context), so canonicalization +// happens in canonicalizeContainerElement by recursing into SomeValue. +func TestInterpretOptionalContainerAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) resource Wallet { + access(all) var vaults: @[Vault]? + init() { + self.vaults <- [<-create Vault(balance: 0.0)] + } + } + + access(all) fun main() { + // Wallet has an optional resource-array field. In storage the + // field is Some<[Vault]>; both refs taken through &w.vaults + // must alias the same canonical inner ArrayValue. Without + // SomeValue handling in canonicalizeContainerElement, each + // GetField would yield a fresh SomeValue whose inner ArrayValue + // is non-canonical, and split-through-one would leave the other + // stale. + let w <- create Wallet() + + let ref = (&w.vaults as auth(Mutate) &[Vault]?)! + let ref2 = (&w.vaults as auth(Mutate) &[Vault]?)! + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + ref2.append(<-create Vault(balance: 999.0)) + + assert(ref.length == 202, + message: "ref must see ref2's append") + assert(ref2.length == 202, + message: "ref2 must see all appends through canonical wrapper") + + destroy w + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretOptionalContainerAliasingViaArrayIndex exercises the +// SomeStorable path through array indexing: when an array's elements +// are optional containers, ArrayValue.Get retrieves a SomeValue whose +// inner must be canonical so aliased references through different +// outer[i] lookups share state. +func TestInterpretOptionalContainerAliasingViaArrayIndex(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + // Outer array of optional resource arrays - the storable for + // each element is SomeStorable wrapping an ArrayStorable. + let outer: @[[Vault]?] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = (&outer[0] as auth(Mutate) &[Vault]?)! + let ref2 = (&outer[0] as auth(Mutate) &[Vault]?)! + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + ref2.append(<-create Vault(balance: 999.0)) + + assert(ref.length == 202, + message: "ref must see ref2's append") + assert(ref2.length == 202, + message: "ref2 must see all appends") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretOptionalContainerAliasingViaDictionaryLookup exercises +// the SomeStorable path through dictionary lookup: when a dict's value +// type is an optional container, DictionaryValue.Get retrieves a +// SomeValue whose inner must be canonical. +func TestInterpretOptionalContainerAliasingViaDictionaryLookup(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + // Dictionary value type is itself an optional resource array. + // Storage element is SomeStorable wrapping ArrayStorable; the + // dictionary lookup is itself optional, so the final type after + // unwrapping is auth(Mutate) &[Vault]?. + let outer: @{String: [Vault]?} <- {"a": <-[<-create Vault(balance: 0.0)]} + + let ref = (&outer["a"] as auth(Mutate) &[Vault]??)!! + let ref2 = (&outer["a"] as auth(Mutate) &[Vault]??)!! + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + ref2.append(<-create Vault(balance: 999.0)) + + assert(ref.length == 202, + message: "ref must see ref2's append") + assert(ref2.length == 202, + message: "ref2 must see all appends") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 6eadbad10d..b8eb732f4a 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -561,7 +561,9 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { let ref = &arr[0] as auth(Withdraw) &Vault let ref2 = &arr[0] as auth(Withdraw) &Vault - // Both refs initially see the same root slab in the underlying atree map. + // The shared-state cache (ConvertStoredValue) deduplicates the Cadence + // wrappers, so both refs hold the same CompositeValue and observe the + // same underlying atree map. assert( liveValueID(ref) == liveValueID(ref2), message: "before split: both refs should observe the same live atree value ID" @@ -576,29 +578,19 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { ref[A5]!.inflate() ref[A6]!.inflate() - // At this point, the CompositeValue inside "ref" has been properly updated such - // that its dictionary.root points to the newly created root slab. However, - // the CompositeValue inside "ref2" did not get updated and its dictionary.root - // still points to the old slab which is no longer the root node. The atree slab - // split reassigned slab IDs such that the OLD root (still pointed to by ref2's - // dictionary) now has a different slab ID, while the NEW root inherits the - // original slab ID. - // Confirm the split actually happened: ref's live value ID is the preserved - // original root ID, while ref2's live value ID is the freshly assigned ID of - // the now-demoted slab. + // Both refs share the canonical wrapper, so the slab split through ref + // is visible to ref2; their live value IDs continue to agree. assert( - liveValueID(ref) != liveValueID(ref2), - message: "after split: refs should observe diverged live atree value IDs" + liveValueID(ref) == liveValueID(ref2), + message: "after split: refs must still observe the same live atree value ID" ) - // Do a conversion roundtrip to create an EphemeralReferenceValue from ref2. - // Without the stable cached valueID on CompositeValue, this reference would be - // tracked under the (now-different) live ValueID of the stale view, which would - // let it bypass invalidation when the vault is moved. + // Conversion roundtrip via AnyResource. Because ref and ref2 wrap the + // same canonical CompositeValue, immortalRef is registered under the + // same value ID as the others. let immortalRef = (ref2 as auth(Withdraw) &AnyResource) as! auth(Withdraw) &Vault - // Move the vault. Reference invalidation must void "immortalRef" alongside - // "ref" and "ref2": the cached valueID ensures all three are tracked under the - // same stable ID. + // Move the vault. Reference invalidation must void immortalRef alongside + // ref and ref2: all three are tracked under the same value ID. var extracted <- arr[0] <- empty stash.deposit(from: <- extracted) @@ -646,3 +638,354 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError assert.ErrorAs(t, err, &invalidatedResourceReferenceError) } + +// TestInterpretCompositeAliasedMutationConsistency is the CompositeValue +// counterpart to TestInterpretArrayAliasedMutationConsistency / Dictionary +// version. It inflates attachments through `ref` to force an atree slab +// split of the resource's underlying dictionary, then withdraws through +// `ref2` (which would previously have observed a stale root). The +// canonical state must reflect both withdrawals; pre-fix, a withdrawal +// through the stale ref2 either silently wrote into a demoted child slab +// or read a stale balance, allowing double-spend. +func TestInterpretCompositeAliasedMutationConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) entitlement Withdraw + access(all) resource Vault { + access(all) var balance: UFix64 + + init(balance: UFix64) { + self.balance = balance + } + + access(Withdraw) fun withdraw(amount: UFix64): @Vault { + self.balance = self.balance - amount + return <- create Vault(balance: amount) + } + + access(all) fun deposit(from: @Vault) { + self.balance = self.balance + from.balance + destroy from + } + } + + access(all) attachment A1 for Vault { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for Vault { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for Vault { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for Vault { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for Vault { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for Vault { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + access(all) fun main() { + // 1000 = 100 (withdraw via ref) + 200 (withdraw via ref2) + 700 (remaining) + let v <- create Vault(balance: 1000.0) + let r1 <- attach A1() to <-v + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r <- attach A6() to <-r5 + + var arr: @[Vault] <- [<-r] + let ref = &arr[0] as auth(Withdraw) &Vault + let ref2 = &arr[0] as auth(Withdraw) &Vault + + // Inflate attachments to force a slab split of the resource's + // underlying dictionary. After the split ref2 would, pre-fix, hold + // a stale root pointer. + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + // Both withdraw paths must observe the same canonical balance, so + // their cumulative effect is exactly the sum. + let w1 <- ref.withdraw(amount: 100.0) + let w2 <- ref2.withdraw(amount: 200.0) + + assert(w1.balance == 100.0, message: "first withdrawal must produce 100") + assert(w2.balance == 200.0, message: "second withdrawal must produce 200") + assert(ref.balance == 700.0, + message: "canonical balance after two withdrawals must be 1000 - 100 - 200 = 700") + assert(ref.balance == ref2.balance, + message: "both refs must observe the same canonical balance") + + destroy w1 + destroy w2 + destroy arr + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretCompositeFieldAliasedMutationConsistency exercises +// CompositeValue.GetField's canonicalization: two `&owner.bucket` +// references must alias the same inner-container wrapper, so a split +// triggered through one ref is observable through the other. Without +// canonicalization at the GetField path, the second `&owner.bucket` +// would build a fresh `*atree.Array` over the same slab and the first +// ref's appends - which trigger the split - would leave the second +// ref's root pointer stale. +func TestInterpretCompositeFieldAliasedMutationConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) resource Wallet { + access(all) var bucket: @[Vault] + init() { + self.bucket <- [<-create Vault(balance: 0.0)] + } + } + + access(all) fun main() { + let w <- create Wallet() + + // Two refs to the same composite field. Both must observe the + // same canonical inner container, including after the underlying + // atree slab splits. + let ref = &w.bucket as auth(Mutate) &[Vault] + let ref2 = &w.bucket as auth(Mutate) &[Vault] + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // Append through ref2. Pre-fix this would have written into a + // demoted child slab, leaving the canonical root's count stale. + ref2.append(<-create Vault(balance: 123.456)) + + assert(ref.length == 202, + message: "first ref must see the second ref's append") + assert(ref2.length == 202, + message: "second ref must see all appends through the canonical wrapper") + + // A fresh ref taken via GetField after the splits must still be + // aliased with the originals. + let postRef = &w.bucket as auth(Mutate) &[Vault] + postRef.append(<-create Vault(balance: 999.0)) + assert(ref.length == 203, + message: "post-split GetField must hand back the canonical wrapper") + + destroy w + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretCompositeForEachAttachmentAliasingConsistency verifies +// the ForEachAttachment iteration path canonicalizes the attachment +// wrappers it yields. A mutation through the iteration's callback must +// be visible to an externally-held reference to the same attachment +// (and vice versa), since both must wrap the same canonical +// CompositeValue. +func TestInterpretCompositeForEachAttachmentAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) attachment A1 for Vault { + access(all) var s: String + init() { self.s = "" } + access(all) fun inflate() { + self.s = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + } + access(all) attachment A2 for Vault { + access(all) var s: String + init() { self.s = "" } + access(all) fun inflate() { + self.s = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + } + } + + access(all) fun main() { + let v <- create Vault(balance: 1000.0) + let v1 <- attach A1() to <-v + let v2 <- attach A2() to <-v1 + + var arr: @[Vault] <- [<-v2] + + // External reference to attachment A1, taken before iteration. + // The forEachAttachment callback must yield the same canonical + // A1 wrapper, so a mutation in the callback is observable here. + let extA1Ref = arr[0][A1]! + + // Iterate attachments and inflate A1 via the callback's reference. + // If the callback's wrapper is not canonical, the mutation lands + // in a fresh wrapper and extA1Ref observes the pre-inflation + // state. + arr[0].forEachAttachment(fun (attRef: &AnyResourceAttachment) { + if let a1Ref = attRef as? &A1 { + a1Ref.inflate() + } + }) + + assert(extA1Ref.s == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + message: "mutation in forEachAttachment must be visible through external ref") + + destroy arr + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index a94ab1ef15..850c007e60 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -199,19 +199,20 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { // is the inner dictionary we will take two references to. let outer: @{String: {String: Vault}} <- {"a": <-{"k0": <-create Vault(balance: 0.0)}} - // Two EphemeralReferenceValues pointing to two different DictionaryValues - // which wrap the same underlying atree map. + // Two EphemeralReferenceValues to the same logical inner dictionary. + // The shared-state cache (ConvertStoredValue) deduplicates the + // Cadence wrappers, so both refs hold the same DictionaryValue and + // observe the same underlying atree map. let ref = (&outer["a"] as auth(Mutate) &{String: Vault}?)! let ref2 = (&outer["a"] as auth(Mutate) &{String: Vault}?)! - // Both refs initially see the same root slab in the underlying atree map. + // Both refs see the same live atree value ID. assert( liveValueID(ref) == liveValueID(ref2), message: "before split: both refs should observe the same live atree value ID" ) - // Insert enough entries via ref to grow the inner map past one slab - // and trigger an atree map slab split. + // Insert enough entries to force the inner map's root to split. var i: Int = 0 while i < 200 { let old <- ref.insert(key: "k".concat(i.toString()), <-create Vault(balance: UFix64(i))) @@ -219,18 +220,16 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { i = i + 1 } - // After the split, ref's dictionary.root points to the new root slab while - // ref2's dictionary.root still points to the old root slab (with a freshly - // assigned slab ID), so their live value IDs diverge. + // Both refs share the canonical wrapper, so the slab split through ref + // is visible to ref2; their live value IDs continue to agree. assert( - liveValueID(ref) != liveValueID(ref2), - message: "after split: refs should observe diverged live atree value IDs" + liveValueID(ref) == liveValueID(ref2), + message: "after split: refs must still observe the same live atree value ID" ) - // Conversion roundtrip via AnyResource to create an EphemeralReferenceValue - // from ref2. Without the stable cached valueID on DictionaryValue, this - // reference would be tracked under the (now-different) live ValueID of the - // stale view. + // Conversion roundtrip via AnyResource. Because ref and ref2 wrap the + // same canonical DictionaryValue, immortalRef is registered under the + // same value ID as the others. let immortalRef = (ref2 as auth(Mutate) &AnyResource) as! auth(Mutate) &{String: Vault} // Replace the inner dictionary with an empty one. The move invalidates @@ -268,3 +267,111 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError assert.ErrorAs(t, err, &invalidatedResourceReferenceError) } + +// TestInterpretDictionaryAliasedMutationConsistency is the DictionaryValue +// counterpart to TestInterpretArrayAliasedMutationConsistency. 200 inserts +// through ref trigger an atree slab split; an insert through ref2 (which +// previously would have observed a stale root) must land in the canonical +// structure. Iteration count and length-driven removal count must agree +// after extraction, and the ref2-inserted key must be observable from +// each traversal in the same way. +func TestInterpretDictionaryAliasedMutationConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @{String: {String: Vault}} <- {"a": <-{"k0": <-create Vault(balance: 0.0)}} + + let ref = (&outer["a"] as auth(Mutate) &{String: Vault}?)! + let ref2 = (&outer["a"] as auth(Mutate) &{String: Vault}?)! + + var i: Int = 0 + while i < 200 { + let old <- ref.insert(key: "k".concat(i.toString()), <-create Vault(balance: UFix64(i))) + destroy old + i = i + 1 + } + + // Insert through ref2. Pre-fix this would have written into a + // demoted child slab, leaving the canonical root's count stale. + let staleOld <- ref2.insert(key: "stale", <-create Vault(balance: 123.456)) + destroy staleOld + + var empty: @{String: Vault}? <- {} + var extractedOpt <- outer["a"] <- empty + var extracted <- extractedOpt! + + let expectedLength = extracted.length + + var iteratedCount: Int = 0 + var keyFoundIterating = false + for key in extracted.keys { + iteratedCount = iteratedCount + 1 + if key == "stale" { + keyFoundIterating = true + } + } + + var removalCount: Int = 0 + var keyFoundRemoving = false + let keys = extracted.keys + for key in keys { + let removed <- extracted.remove(key: key)! + if key == "stale" { + keyFoundRemoving = true + } + destroy removed + removalCount = removalCount + 1 + } + + assert(expectedLength == iteratedCount, + message: "length must match for-iteration count") + assert(iteratedCount == removalCount, + message: "iteration count must match key-driven removal count") + assert(keyFoundIterating == keyFoundRemoving, + message: "stale-insert must be either seen by both traversals or by neither") + + destroy extracted + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index ab1e5695f6..ebff535f3f 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -6498,6 +6498,27 @@ func (interpreter *Interpreter) ReferencedResourceKindedValues(valueID atree.Val return interpreter.SharedState.referencedResourceKindedValues[valueID] } +// CanonicalAtreeContainer returns the cached canonical Cadence-level +// wrapper (ArrayValue, DictionaryValue, or CompositeValue) for the given +// atree value ID, or nil if none has been recorded yet. +func (interpreter *Interpreter) CanonicalAtreeContainer(valueID atree.ValueID) Value { + return interpreter.SharedState.canonicalAtreeContainers[valueID] +} + +// SetCanonicalAtreeContainer records the given wrapper as the canonical +// Cadence-level wrapper for the given atree value ID. +func (interpreter *Interpreter) SetCanonicalAtreeContainer(valueID atree.ValueID, v Value) { + interpreter.SharedState.canonicalAtreeContainers[valueID] = v +} + +// ClearCanonicalAtreeContainer removes the cache entry for the given atree +// value ID. Call this when the corresponding wrapper is invalidated +// (Destroy, Transfer's `array`/`dictionary` nil-out) so the now-invalid +// wrapper is not handed out to subsequent loads. +func (interpreter *Interpreter) ClearCanonicalAtreeContainer(valueID atree.ValueID) { + delete(interpreter.SharedState.canonicalAtreeContainers, valueID) +} + // startResourceTracking starts tracking the life-span of a resource. // A resource can only be associated with one variable at most, at a given time. func (interpreter *Interpreter) startResourceTracking( diff --git a/interpreter/sharedstate.go b/interpreter/sharedstate.go index f5a5c9f52c..57e0df7160 100644 --- a/interpreter/sharedstate.go +++ b/interpreter/sharedstate.go @@ -45,6 +45,28 @@ type SharedState struct { MutationDuringCapabilityControllerIteration bool containerValueIteration map[atree.ValueID]struct{} destroyedResources map[atree.ValueID]struct{} + // canonicalAtreeContainers deduplicates the Cadence-level wrappers + // (ArrayValue, DictionaryValue, CompositeValue) created for atree + // containers, keyed by their atree value ID. The first wrapper created + // for a given value ID via `ConvertStoredValue` becomes canonical; + // subsequent retrievals reuse the same wrapper instance. The wrapper's + // `array`/`dictionary` field is updated on each retrieval to the + // `*atree.Array`/`*atree.OrderedMap` instance that atree just handed + // out (and on which it set up the parent updater), so operations + // through the canonical wrapper notify the parent correctly. + // + // Without this, atree.Array.Get / OrderedMap.Get returns a fresh + // `*atree.Array`/`*atree.OrderedMap` per call - two `&outer[0]` + // evaluations would hold separate wrappers around separate atree + // instances over the same slab. An atree slab split through one + // wrapper reassigns root pointers on that instance only; the others go + // stale and mutations through them silently corrupt the container. + // + // Entries are removed when a wrapper is invalidated by Destroy or + // Transfer (its `array`/`dictionary` is nilled). Subsequent retrievals + // for the same value ID create a fresh wrapper, leaving the held + // reference to the invalidated wrapper unaffected. + canonicalAtreeContainers map[atree.ValueID]Value } func NewSharedState(config *Config) *SharedState { @@ -63,6 +85,7 @@ func NewSharedState(config *Config) *SharedState { CapabilityControllerIterations: map[AddressPath]int{}, containerValueIteration: map[atree.ValueID]struct{}{}, destroyedResources: map[atree.ValueID]struct{}{}, + canonicalAtreeContainers: map[atree.ValueID]Value{}, } } diff --git a/interpreter/storage.go b/interpreter/storage.go index 2e18888732..ecb44ef6f4 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -59,6 +59,109 @@ func MustConvertUnmeteredStoredValue(value atree.Value) Value { return converted } +// AtreeContainerCache deduplicates Cadence-level wrappers (ArrayValue, +// DictionaryValue, CompositeValue) created for atree containers, keyed by +// their atree value ID. See SharedState.canonicalAtreeContainers for the +// rationale. +type AtreeContainerCache interface { + CanonicalAtreeContainer(valueID atree.ValueID) Value + SetCanonicalAtreeContainer(valueID atree.ValueID, v Value) + ClearCanonicalAtreeContainer(valueID atree.ValueID) +} + +// canonicalizeContainerElement returns the canonical cached wrapper for a +// container element, populating the cache on first sight or adopting the +// freshly-loaded `*atree.Array`/`*atree.OrderedMap` into the existing +// cached wrapper. atree's `Array.Get`/`OrderedMap.Get` sets up the +// parent updater (via setCallbackWithChild) on the +// `*atree.Array`/`*atree.OrderedMap` instance it just returned, not on +// the one we may have previously cached, so the freshly-returned +// instance is the one that will correctly notify the parent on +// mutation. +func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value { + switch v := fresh.(type) { + case *ArrayValue: + if v.array == nil || v.isDestroyed { + return fresh + } + if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*ArrayValue); ok { + if existing.array != nil && !existing.isDestroyed { + existing.array = v.array + return existing + } + cache.ClearCanonicalAtreeContainer(v.valueID) + } + cache.SetCanonicalAtreeContainer(v.valueID, v) + return v + case *DictionaryValue: + if v.dictionary == nil || v.isDestroyed { + return fresh + } + if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*DictionaryValue); ok { + if existing.dictionary != nil && !existing.isDestroyed { + existing.dictionary = v.dictionary + return existing + } + cache.ClearCanonicalAtreeContainer(v.valueID) + } + cache.SetCanonicalAtreeContainer(v.valueID, v) + return v + case *CompositeValue: + if v.dictionary == nil || v.isDestroyed { + return fresh + } + if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*CompositeValue); ok { + if existing.dictionary != nil && !existing.isDestroyed { + existing.dictionary = v.dictionary + return existing + } + cache.ClearCanonicalAtreeContainer(v.valueID) + } + cache.SetCanonicalAtreeContainer(v.valueID, v) + return v + case *SomeValue: + // An optional wrapping a container must canonicalize its inner so + // that aliased references see a shared wrapper. SomeStorable. + // StoredValue produces a fresh SomeValue per load whose inner is + // built via the non-canonicalizing StoredValue path (it has no + // access to the current context's cache); re-canonicalize the + // inner here so the SomeValue we return contains the canonical + // wrapper. The recursion also handles nested optionals (T??, ...). + if v.value == nil { + return fresh + } + canonicalized := canonicalizeContainerElement(cache, v.value) + if canonicalized != v.value { + v.value = canonicalized + } + return v + } + return fresh +} + +// MustConvertStoredContainerElement wraps an atree value retrieved as a +// container element (e.g. via `*atree.Array.Get` or +// `*atree.OrderedMap.Get`) as a Cadence-level `Value`, deduplicating the +// resulting wrapper via the canonical wrapper cache when supported. Use +// this instead of `MustConvertStoredValue` whenever a container's +// element is being returned to user code (e.g. for `&outer[0]`), so that +// aliased references share state. Internal callers that immediately +// Transfer (and thereby invalidate) the wrapper must continue to use +// `MustConvertStoredValue` so their transient wrapper does not poison +// the cache. +func MustConvertStoredContainerElement(gauge common.MemoryGauge, value atree.Value) Value { + result := MustConvertStoredValue(gauge, value) + if cache, ok := gauge.(AtreeContainerCache); ok { + return canonicalizeContainerElement(cache, result) + } + return result +} + +// ConvertStoredValue wraps the given atree value as a Cadence-level +// `Value` without canonicalization. Callers that return the wrapper to +// user code as a container element (e.g. `&outer[0]`) should use +// `MustConvertStoredContainerElement` instead, so aliased references +// see a shared wrapper. func ConvertStoredValue(gauge common.MemoryGauge, value atree.Value) (Value, error) { switch value := value.(type) { case *atree.Array: diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 314eceadbb..cad4c8888f 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -302,8 +302,20 @@ func (v *ArrayValue) iterate( ) // atree.Array iteration provides low-level atree.Value, - // convert to high-level interpreter.Value - elementValue := MustConvertStoredValue(context, element) + // convert to high-level interpreter.Value. + // + // When the element will be passed to `f` without transfer, + // canonicalize it: `f` may stash it or hand it back to user + // code, where it could be aliased by an `&...` reference. When + // the element will be Transfer'd below, leave it as a + // transient fresh wrapper - Transfer's `array`/`dictionary` + // nil-out would poison the cache otherwise. + var elementValue Value + if transferElements { + elementValue = MustConvertStoredValue(context, element) + } else { + elementValue = MustConvertStoredContainerElement(context, element) + } CheckInvalidatedResourceOrResourceReference(elementValue, context) if transferElements { @@ -421,6 +433,9 @@ func (v *ArrayValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.array = nil } @@ -436,18 +451,6 @@ func (v *ArrayValue) Concat( first := true - // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. - firstIterator, err := v.array.ReadOnlyIterator() - if err != nil { - panic(errors.NewExternalError(err)) - } - - // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. - secondIterator, err := other.array.ReadOnlyIterator() - if err != nil { - panic(errors.NewExternalError(err)) - } - // `other`'s elements are checked against the receiver's declared element // type (param type stayed at the declared type — the caller provided it). otherElementType := v.Type.ElementType() @@ -458,6 +461,29 @@ func (v *ArrayValue) Concat( resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) + // In the asReference branch each yielded inner is wrapped in an + // EphemeralReferenceValue stored in the result via NonStorable; its + // wrapper must be canonicalized, which requires the iterator to yield + // mutable inner instances (parentUpdater wired). + var firstIterator, secondIterator atree.ArrayIterator + var err error + if asReference { + firstIterator, err = v.array.Iterator() + } else { + firstIterator, err = v.array.ReadOnlyIterator() + } + if err != nil { + panic(errors.NewExternalError(err)) + } + if asReference { + secondIterator, err = other.array.Iterator() + } else { + secondIterator, err = other.array.ReadOnlyIterator() + } + if err != nil { + panic(errors.NewExternalError(err)) + } + newCount := v.array.Count() + other.array.Count() common.UseComputation( @@ -487,6 +513,8 @@ func (v *ArrayValue) Concat( if atreeValue == nil { first = false + } else if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) } else { value = MustConvertStoredValue(context, atreeValue) } @@ -501,7 +529,11 @@ func (v *ArrayValue) Concat( } if atreeValue != nil { - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } checkContainerMutation(context, otherElementType, value) } @@ -511,6 +543,15 @@ func (v *ArrayValue) Concat( return nil } + // NOTE: the asReference branch below wraps the freshly-loaded + // wrapper in an EphemeralReferenceValue stored in the result + // array via NonStorable. The wrapper is transient and not + // canonicalized; an external alias to the same source element + // would observe diverging state after a split. See + // TestInterpretArraySliceDoesNotInvalidateAliases for the + // non-asReference safety property this code already maintains; + // the asReference aliasing fix requires switching to a non- + // read-only iterator and is tracked separately. if asReference { value = getReferenceValue(context, value, resultElementSemaType) } @@ -569,9 +610,7 @@ func (v *ArrayValue) Get(context ContainerReadContext, index int) Value { panic(errors.NewExternalError(err)) } - result := MustConvertStoredValue(context, storedValue) - - return result + return MustConvertStoredContainerElement(context, storedValue) } func (v *ArrayValue) SetKey(context ContainerMutationContext, key Value, value Value) { @@ -1581,6 +1620,9 @@ func (v *ArrayValue) Transfer( InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.array = nil } @@ -1753,8 +1795,28 @@ func (v *ArrayValue) Slice( }) } - // Use ReadOnlyIterator here because new ArrayValue is created from elements copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) + // Cascade outer authorization into the result element type, matching + // sema's ArraySliceFunctionType: when sliced through a reference, + // elements are exposed as references (with auths intersected). Without + // this, the new array's declared element type would mismatch its actual + // contents. + elementType := v.SemaType(context).ElementType(false) + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) + resultElementStaticType := ConvertSemaToStaticType(context, elementType) + + // In the asReference branch, each yielded inner is wrapped in an + // EphemeralReferenceValue that is stored in the result via NonStorable + // and outlives this call. The reference's wrapper must therefore be + // canonicalized, which requires the iterator to yield mutable inner + // instances (parentUpdater wired) - ReadOnly iterator yields + // trap-callback instances that would downgrade the canonical wrapper. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.RangeIterator(uint64(fromIndex), uint64(toIndex)) + } else { + iterator, err = v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) + } if err != nil { var sliceOutOfBoundsError *atree.SliceOutOfBoundsError @@ -1787,15 +1849,6 @@ func (v *ArrayValue) Slice( }, ) - // Cascade outer authorization into the result element type, matching - // sema's ArraySliceFunctionType: when sliced through a reference, - // elements are exposed as references (with auths intersected). Without - // this, the new array's declared element type would mismatch its actual - // contents. - elementType := v.SemaType(context).ElementType(false) - elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) - resultElementStaticType := ConvertSemaToStaticType(context, elementType) - return NewArrayValueWithIterator( context, NewVariableSizedStaticType(context, resultElementStaticType), @@ -1812,7 +1865,11 @@ func (v *ArrayValue) Slice( var value Value if atreeValue != nil { - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } } if value == nil { @@ -1944,7 +2001,11 @@ func (v *ArrayValue) Filter( return nil } - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if value == nil { return nil } @@ -2059,7 +2120,12 @@ func (v *ArrayValue) Map( return nil } - value := MustConvertStoredValue(context, atreeValue) + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue( context, @@ -2120,8 +2186,16 @@ func (v *ArrayValue) ToVariableSized( // Convert the array to a variable-sized array. - // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyIterator() + // asReference path keeps the wrapper alive in a reference stored in + // the result; canonicalize and use a mutable iterator. Otherwise the + // element is Transfer'd and the read-only iterator suffices. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.Iterator() + } else { + iterator, err = v.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } @@ -2152,7 +2226,12 @@ func (v *ArrayValue) ToVariableSized( return nil } - value := MustConvertStoredValue(context, atreeValue) + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue(context, value, elementType) @@ -2203,8 +2282,16 @@ func (v *ArrayValue) ToConstantSized( // Convert the array to a constant-sized array. - // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyIterator() + // asReference path keeps the wrapper alive in a reference stored in + // the result; canonicalize and use a mutable iterator. Otherwise the + // element is Transfer'd and the read-only iterator suffices. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.Iterator() + } else { + iterator, err = v.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } @@ -2235,7 +2322,12 @@ func (v *ArrayValue) ToConstantSized( return nil } - value := MustConvertStoredValue(context, atreeValue) + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue(context, value, elementType) @@ -2355,9 +2447,10 @@ func (i *ArrayIterator) Next(context ValueIteratorContext) Value { } // atree.Array iterator returns low-level atree.Value, - // convert to high-level interpreter.Value - result := MustConvertStoredValue(context, atreeValue) - return result + // convert to high-level interpreter.Value. The result is handed back + // to user code (e.g. as the loop variable of `for x in arr`), so + // canonicalize to keep aliased references consistent. + return MustConvertStoredContainerElement(context, atreeValue) } func (i *ArrayIterator) ValueID() (atree.ValueID, bool) { diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 7af292266c..343e641cdb 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -462,6 +462,9 @@ func (v *CompositeValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.dictionary = nil } @@ -1006,7 +1009,7 @@ func (v *CompositeValue) GetField(gauge common.Gauge, name string) Value { panic(errors.NewExternalError(err)) } - return MustConvertStoredValue(gauge, storedValue) + return MustConvertStoredContainerElement(gauge, storedValue) } func (v *CompositeValue) Equal(context ValueComparisonContext, other Value) bool { @@ -1555,6 +1558,9 @@ func (v *CompositeValue) Transfer( InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.dictionary = nil } @@ -1796,7 +1802,9 @@ func (v *CompositeValue) forEachField( f func(fieldName string, fieldValue Value) (resume bool), ) { err := atreeIterate(func(key atree.Value, atreeValue atree.Value) (resume bool, err error) { - value := MustConvertStoredValue(context, atreeValue) + // The field value is handed to `f` without transfer, so + // canonicalize so aliased references see a shared wrapper. + value := MustConvertStoredContainerElement(context, atreeValue) CheckInvalidatedResourceOrResourceReference(value, context) resume = f( @@ -2125,7 +2133,9 @@ func forEachAttachment( break } if strings.HasPrefix(string(key.(StringAtreeValue)), unrepresentableNamePrefix) { - attachment, ok := MustConvertStoredValue(context, value).(*CompositeValue) + // The attachment is handed to `f` without transfer, so + // canonicalize so aliased references see a shared wrapper. + attachment, ok := MustConvertStoredContainerElement(context, value).(*CompositeValue) if !ok { panic(errors.NewExternalError(err)) } diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 121f310072..2f5b7097c2 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -370,9 +370,17 @@ func (v *DictionaryValue) iterateKeys( ) // atree.OrderedMap iteration provides low-level atree.Value, - // convert to high-level interpreter.Value + // convert to high-level interpreter.Value. When the element + // will be passed to `f` without transfer, canonicalize so + // aliased references see a shared wrapper; when it will be + // Transfer'd, leave it as a transient fresh wrapper. - keyValue := MustConvertStoredValue(interpreter, key) + var keyValue Value + if transferElements { + keyValue = MustConvertStoredValue(interpreter, key) + } else { + keyValue = MustConvertStoredContainerElement(interpreter, key) + } // Handle transfer if requested if transferElements { @@ -453,10 +461,12 @@ func (v *DictionaryValue) iterate( ) // atree.OrderedMap iteration provides low-level atree.Value, - // convert to high-level interpreter.Value + // convert to high-level interpreter.Value. The pair is + // passed to `f` without transfer, so canonicalize both so + // aliased references see a shared wrapper. - keyValue := MustConvertStoredValue(context, key) - valueValue := MustConvertStoredValue(context, value) + keyValue := MustConvertStoredContainerElement(context, key) + valueValue := MustConvertStoredContainerElement(context, value) CheckInvalidatedResourceOrResourceReference(keyValue, context) CheckInvalidatedResourceOrResourceReference(valueValue, context) @@ -584,6 +594,9 @@ func (v *DictionaryValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.dictionary = nil } @@ -707,9 +720,7 @@ func (v *DictionaryValue) Get( panic(errors.NewExternalError(err)) } - result := MustConvertStoredValue(context, storedValue) - - return result, true + return MustConvertStoredContainerElement(context, storedValue), true } func (v *DictionaryValue) GetKey(context ContainerReadContext, keyValue Value) Value { @@ -1688,6 +1699,9 @@ func (v *DictionaryValue) Transfer( InvalidateReferencedResources(context, v) + if cache, ok := context.(AtreeContainerCache); ok { + cache.ClearCanonicalAtreeContainer(v.valueID) + } v.dictionary = nil } @@ -2039,8 +2053,10 @@ func (i *DictionaryKeyIterator) Next(context ValueIteratorContext) Value { } // atree.Map iterator returns low-level atree.Value, - // convert to high-level interpreter.Value - return MustConvertStoredValue(context, atreeKeyValue) + // convert to high-level interpreter.Value. The key is handed back to + // user code (e.g. loop variable of `for k in dict.keys`), so + // canonicalize to keep aliased references consistent. + return MustConvertStoredContainerElement(context, atreeKeyValue) } func (i *DictionaryKeyIterator) ValueID() (atree.ValueID, bool) { From c2a816791e8258bce93c20d2f351fed967423475 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 10:59:03 -0700 Subject: [PATCH 063/139] Add reproducer for iterator corruption --- interpreter/stale_atree_view_test.go | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 8b5585da88..f267be2ab3 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -459,4 +459,79 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { assert.ErrorAs(t, err, &staleViewErr) assert.Equal(t, 30, staleViewErr.StartPosition().Line) }) + + t.Run("ArrayValue: map procedure mutates sibling wrapper into split mid-iteration", func(t *testing.T) { + t.Parallel() + + // ArrayValue.Map (and similarly Filter / Reverse / Slice / Concat / ToVariableSized) + // creates an atree iterator once via v.array.Iterator() and walks it across many + // user-callback invocations. + // Between callback invocations there is: + // - no WithContainerMutationPrevention(v.ValueID()) (unlike Iterate); + // - no CheckInvalidatedValueOrValueReference(v, ...) between iterator.Next() calls. + // + // If the user procedure mutates a sibling wrapper of v, the sibling + // mutation triggers a slab split. The active atree iterator continues + // walking the now-demoted slab, potentially yielding duplicate elements, + // skipping elements, or yielding stale state — all silently. + // + // Safe contract: as soon as v becomes stale, the next iterator.Next() + // (or a per-iteration staleness check) must raise InvalidatedContainerViewError. + // + // Non-resource Int elements are used because Cadence forbids + // `map` on resource arrays even when accessed via reference. The + // iterator-corruption gap is independent of resource-ness. + err := runInvoke(t, ` + access(all) fun main() { + // Two ArrayValue wrappers for the same inner Int array. + let outer: [[Int]] = [[0, 1]] + + let ref1 = &outer[0] as auth(Mutate) &[Int] + let ref2 = &outer[0] as auth(Mutate) &[Int] + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + // ref.map's atree iterator is created when this expression begins evaluating. + // The procedure runs between iterator.Next() calls. + // the first invocation mutates ref2 enough to split the slab tree, demoting ref1's view. + // The map's iterator should then be rejected. + var calls: Int = 0 + let mapped = ref1.map(fun (v: Int): Int { + if calls == 0 { + // Push ref2 past the slab-split threshold while ref's + // iterator is paused between elements. + var j = 0 + while j < 300 { + ref2.append(j + 100) + j = j + 1 + } + } + calls = calls + 1 + return v + }) + + // If the check fires correctly, execution never reaches this point. + // If the gap is unpatched, ref's wrapper is now stale and mapped + // contains corrupt data (wrong length, duplicates, or stale reads). + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after callback-induced split: refs should observe diverged live atree value IDs" + ) + + // Without the safety check, this is silent corruption: the canonical + // view sees 302 elements (2 original + 300 appended); a stale map + // iterator may yield a different count or duplicate the first element. + assert( + mapped.length == 302, + message: "mapped length mismatch — stale iterator yielded wrong element count, got " + .concat(mapped.length.toString()) + ) + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) } From 964c992cfadb9d85716d7196ff291c63c2c2a241 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 12:24:01 -0700 Subject: [PATCH 064/139] Add reproducer for attachment self reference corruption --- interpreter/stale_atree_view_test.go | 201 +++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index f267be2ab3..dc376fe8ee 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -534,4 +534,205 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) + + // When an attachment method is bound via `composite[B]`, the bound function + // captures `base = v.GetBaseValue(...)`, a fresh `EphemeralReferenceValue` + // pointing at the parent composite. `MaybeDereferenceReceiver` at invoke + // time only validates the *attachment* receiver, not the captured base. + // + // The safe contract is that any code path that ultimately walks back through + // the parent via `base` must re-trigger the staleness check on the parent + // wrapper — either at the index expression `ref2[B]` (because the stale + // ref2 is evaluated as the index target) or inside the method body when + // `base.X` is evaluated. + // + // Both directions are exercised by the two sub-tests below. They currently + // PASS, confirming Gap 3 is fully covered by the centralized check; they + // serve as positive regression coverage so a future refactor that removes + // either the index-target check or the in-method `base` re-check would be + // caught. + + typeDeclarations := ` + access(all) resource R { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) attachment A1 for R { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for R { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for R { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for R { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for R { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for R { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + // Attachment B exposes a method that reads back through base. + // A correct invariant requires the staleness check to fire any time + // base ultimately walks a stale parent wrapper. + access(all) attachment B for R { + access(all) fun readBaseBalance(): UFix64 { + return base.balance + } + } + ` + + t.Run("CompositeValue: attachment method via stale parent wrapper (direct index)", func(t *testing.T) { + t.Parallel() + + // Scenario A: invoke an attachment method directly through the stale + // parent wrapper. The check should fire when ref2 is evaluated as the + // target of the index expression `ref2[B]`. + err := runInvoke(t, typeDeclarations+` + access(all) fun main() { + let r0 <- create R(balance: 42.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r6 <- attach A6() to <-r5 + let r <- attach B() to <-r6 + + let arr: @[R] <- [<-r] + let ref1 = &arr[0] as &R + let ref2 = &arr[0] as &R + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref1[A1]!.inflate() + ref1[A2]!.inflate() + ref1[A3]!.inflate() + ref1[A4]!.inflate() + ref1[A5]!.inflate() + ref1[A6]!.inflate() + + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // The stale ref2 is the target of the index expression and is + // evaluated first by evalExpression, which runs + // CheckInvalidatedValueOrValueReference and fires + // InvalidatedContainerViewError before the attachment lookup or + // method binding can complete. + let stashed = ref2[B]!.readBaseBalance() + assert(stashed == 42.0, message: "unreachable if check fires") + + destroy arr + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("CompositeValue: attachment method via stale parent wrapper (captured-before-split)", func(t *testing.T) { + t.Parallel() + + // Scenario B: capture the attachment reference BEFORE the split, then + // trigger the split through ref, then invoke the attachment method via + // the captured reference. ref2 no longer appears in the post-split code + // path, so the check must fire elsewhere — inside the method body, when + // `base` is evaluated as an identifier and CheckInvalidatedValueOrValueReference + // recurses into the captured base reference's stale parent composite. + err := runInvoke(t, typeDeclarations+` + access(all) fun main() { + let r0 <- create R(balance: 42.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r6 <- attach A6() to <-r5 + let r <- attach B() to <-r6 + + let arr: @[R] <- [<-r] + let ref1 = &arr[0] as &R + let ref2 = &arr[0] as &R + + // Capture the attachment reference BEFORE the split. Internally + // this also wires the attachment's v.base to ref2's CompositeValue. + let bRef = ref2[B]! + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref1[A1]!.inflate() + ref1[A2]!.inflate() + ref1[A3]!.inflate() + ref1[A4]!.inflate() + ref1[A5]!.inflate() + ref1[A6]!.inflate() + + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // The receiver bRef refers to the attachment (not stale), so + // MaybeDereferenceReceiver does not fire on the attachment ref1. + // The check must fire when the method body evaluates base + // (which resolves to a reference at the now-stale parent). + let stashed = bRef.readBaseBalance() + assert(stashed == 42.0, message: "unreachable if check fires") + + destroy arr + } + `) + + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) } From 2e9afb5ee3130cb7bf9487087822baae65507f2f Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 12:30:54 -0700 Subject: [PATCH 065/139] Revert attachment test --- interpreter/stale_atree_view_test.go | 201 --------------------------- 1 file changed, 201 deletions(-) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index dc376fe8ee..f267be2ab3 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -534,205 +534,4 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) - - // When an attachment method is bound via `composite[B]`, the bound function - // captures `base = v.GetBaseValue(...)`, a fresh `EphemeralReferenceValue` - // pointing at the parent composite. `MaybeDereferenceReceiver` at invoke - // time only validates the *attachment* receiver, not the captured base. - // - // The safe contract is that any code path that ultimately walks back through - // the parent via `base` must re-trigger the staleness check on the parent - // wrapper — either at the index expression `ref2[B]` (because the stale - // ref2 is evaluated as the index target) or inside the method body when - // `base.X` is evaluated. - // - // Both directions are exercised by the two sub-tests below. They currently - // PASS, confirming Gap 3 is fully covered by the centralized check; they - // serve as positive regression coverage so a future refactor that removes - // either the index-target check or the in-method `base` re-check would be - // caught. - - typeDeclarations := ` - access(all) resource R { - access(all) var balance: UFix64 - init(balance: UFix64) { self.balance = balance } - } - - access(all) attachment A1 for R { - access(all) var a1: String; access(all) var a2: String - access(all) var a3: String; access(all) var a4: String - init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } - access(all) fun inflate() { - self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 - } - } - access(all) attachment A2 for R { - access(all) var b1: String; access(all) var b2: String - access(all) var b3: String; access(all) var b4: String - init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } - access(all) fun inflate() { - self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 - } - } - access(all) attachment A3 for R { - access(all) var d1: String; access(all) var d2: String - access(all) var d3: String; access(all) var d4: String - init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } - access(all) fun inflate() { - self.d1 = "dddddddddddddddddddddddddddddddddddddd" - self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 - } - } - access(all) attachment A4 for R { - access(all) var e1: String; access(all) var e2: String - access(all) var e3: String; access(all) var e4: String - init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } - access(all) fun inflate() { - self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 - } - } - access(all) attachment A5 for R { - access(all) var g1: String; access(all) var g2: String - access(all) var g3: String; access(all) var g4: String - init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } - access(all) fun inflate() { - self.g1 = "gggggggggggggggggggggggggggggggggggggg" - self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 - } - } - access(all) attachment A6 for R { - access(all) var h1: String; access(all) var h2: String - access(all) var h3: String; access(all) var h4: String - init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } - access(all) fun inflate() { - self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" - self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 - } - } - - // Attachment B exposes a method that reads back through base. - // A correct invariant requires the staleness check to fire any time - // base ultimately walks a stale parent wrapper. - access(all) attachment B for R { - access(all) fun readBaseBalance(): UFix64 { - return base.balance - } - } - ` - - t.Run("CompositeValue: attachment method via stale parent wrapper (direct index)", func(t *testing.T) { - t.Parallel() - - // Scenario A: invoke an attachment method directly through the stale - // parent wrapper. The check should fire when ref2 is evaluated as the - // target of the index expression `ref2[B]`. - err := runInvoke(t, typeDeclarations+` - access(all) fun main() { - let r0 <- create R(balance: 42.0) - let r1 <- attach A1() to <-r0 - let r2 <- attach A2() to <-r1 - let r3 <- attach A3() to <-r2 - let r4 <- attach A4() to <-r3 - let r5 <- attach A5() to <-r4 - let r6 <- attach A6() to <-r5 - let r <- attach B() to <-r6 - - let arr: @[R] <- [<-r] - let ref1 = &arr[0] as &R - let ref2 = &arr[0] as &R - - assert( - liveValueIDOf("ref1") == liveValueIDOf("ref2"), - message: "before split: both refs should observe the same live atree value ID" - ) - - ref1[A1]!.inflate() - ref1[A2]!.inflate() - ref1[A3]!.inflate() - ref1[A4]!.inflate() - ref1[A5]!.inflate() - ref1[A6]!.inflate() - - assert( - liveValueIDOf("ref1") != liveValueIDOf("ref2"), - message: "after split: refs should observe diverged live atree value IDs" - ) - - // The stale ref2 is the target of the index expression and is - // evaluated first by evalExpression, which runs - // CheckInvalidatedValueOrValueReference and fires - // InvalidatedContainerViewError before the attachment lookup or - // method binding can complete. - let stashed = ref2[B]!.readBaseBalance() - assert(stashed == 42.0, message: "unreachable if check fires") - - destroy arr - } - `) - var staleViewErr *interpreter.InvalidatedContainerViewError - assert.ErrorAs(t, err, &staleViewErr) - }) - - t.Run("CompositeValue: attachment method via stale parent wrapper (captured-before-split)", func(t *testing.T) { - t.Parallel() - - // Scenario B: capture the attachment reference BEFORE the split, then - // trigger the split through ref, then invoke the attachment method via - // the captured reference. ref2 no longer appears in the post-split code - // path, so the check must fire elsewhere — inside the method body, when - // `base` is evaluated as an identifier and CheckInvalidatedValueOrValueReference - // recurses into the captured base reference's stale parent composite. - err := runInvoke(t, typeDeclarations+` - access(all) fun main() { - let r0 <- create R(balance: 42.0) - let r1 <- attach A1() to <-r0 - let r2 <- attach A2() to <-r1 - let r3 <- attach A3() to <-r2 - let r4 <- attach A4() to <-r3 - let r5 <- attach A5() to <-r4 - let r6 <- attach A6() to <-r5 - let r <- attach B() to <-r6 - - let arr: @[R] <- [<-r] - let ref1 = &arr[0] as &R - let ref2 = &arr[0] as &R - - // Capture the attachment reference BEFORE the split. Internally - // this also wires the attachment's v.base to ref2's CompositeValue. - let bRef = ref2[B]! - - assert( - liveValueIDOf("ref1") == liveValueIDOf("ref2"), - message: "before split: both refs should observe the same live atree value ID" - ) - - ref1[A1]!.inflate() - ref1[A2]!.inflate() - ref1[A3]!.inflate() - ref1[A4]!.inflate() - ref1[A5]!.inflate() - ref1[A6]!.inflate() - - assert( - liveValueIDOf("ref1") != liveValueIDOf("ref2"), - message: "after split: refs should observe diverged live atree value IDs" - ) - - // The receiver bRef refers to the attachment (not stale), so - // MaybeDereferenceReceiver does not fire on the attachment ref1. - // The check must fire when the method body evaluates base - // (which resolves to a reference at the now-stale parent). - let stashed = bRef.readBaseBalance() - assert(stashed == 42.0, message: "unreachable if check fires") - - destroy arr - } - `) - - var staleViewErr *interpreter.InvalidatedContainerViewError - assert.ErrorAs(t, err, &staleViewErr) - }) } From fa1e15386e3b9f909bca39f23b498a7ec81b8e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 29 May 2026 13:15:27 -0700 Subject: [PATCH 066/139] use WithContainerMutationPrevention to prevent mutations during iteration in Array.map/filter --- interpreter/stale_atree_view_test.go | 75 ++++++++- interpreter/value_array.go | 220 ++++++++++++++------------- 2 files changed, 189 insertions(+), 106 deletions(-) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index f267be2ab3..807fb1f10d 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -106,7 +106,14 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { return baseValueActivation, baseActivation } - runInvoke := func(t *testing.T, code string) error { + // Some tests need to bypass a specific checker diagnostic (e.g. + // Filter's procedure must be `view` per sema, but to exercise the + // runtime mutation-prevention barrier we need an impure procedure). + runInvokeWithHandleCheckerError := func( + t *testing.T, + code string, + handleCheckerError func(error), + ) error { baseValueActivation, baseActivation := makeEnv(t) inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( t, @@ -124,12 +131,16 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { return baseActivation }, }, + HandleCheckerError: handleCheckerError, }, ) require.NoError(t, err) _, err = inter.Invoke("main") return err } + runInvoke := func(t *testing.T, code string) error { + return runInvokeWithHandleCheckerError(t, code, nil) + } t.Run("ArrayValue: append via stale wrapper after split", func(t *testing.T) { t.Parallel() @@ -531,7 +542,65 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ) } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - assert.ErrorAs(t, err, &staleViewErr) + var containerMutationErr *interpreter.ContainerMutatedDuringIterationError + assert.ErrorAs(t, err, &containerMutationErr) }) + + t.Run("ArrayValue: filter procedure mutates sibling wrapper into split mid-iteration", func(t *testing.T) { + t.Parallel() + + // Parallel to the Map test, with one wrinkle: sema requires the + // Filter procedure to be `view` (pure), so a Cadence program that + // mutates a sibling wrapper inside the procedure fails type-checking. + // To exercise the runtime mutation-prevention barrier on Filter, we + // pass a HandleCheckerError that swallows the impurity diagnostic. + // + // This test guards against the "view enforcement has a hole" scenario: + // if any future checker change accidentally lets an impure call slip + // into a Filter procedure, the runtime barrier must still reject the + // resulting sibling mutation. + err := runInvokeWithHandleCheckerError( + t, + ` + access(all) fun main() { + let outer: [[Int]] = [[0, 1]] + + let ref1 = &outer[0] as auth(Mutate) &[Int] + let ref2 = &outer[0] as auth(Mutate) &[Int] + + var calls: Int = 0 + let filtered = ref1.filter(view fun (v: Int): Bool { + if calls == 0 { + var j = 0 + while j < 300 { + ref2.append(j + 100) + j = j + 1 + } + } + calls = calls + 1 + return true + }) + + // Unreachable: the ref2.append inside the procedure must + // raise ContainerMutatedDuringIterationError on the very + // first callback invocation. + assert(false, message: "unreachable") + } + `, + func(checkerErr error) { + // Swallow the impurity diagnostics that come from running + // state-mutating code inside the view-typed filter procedure. + // We deliberately exercise an impure procedure to verify the + // runtime barrier; the impurity errors are not what this + // test is about. + errs := RequireCheckerErrors(t, checkerErr, 2) + for _, e := range errs { + assert.IsType(t, &sema.PurityError{}, e) + } + }, + ) + var containerMutationErr *interpreter.ContainerMutatedDuringIterationError + assert.ErrorAs(t, err, &containerMutationErr) + }) + } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 1eb043c605..4af6656a7f 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1923,81 +1923,87 @@ func (v *ArrayValue) Filter( resultElementStaticType := ConvertSemaToStaticType(context, argumentType) - return NewArrayValueWithIterator( - context, - // NOTE: result is NOT v.Type, which could be a constant-sized array type. - // Instead, result is always a variable-sized array type, - // because filtering can change the number of elements in the array. - NewVariableSizedStaticType(context, resultElementStaticType), - common.ZeroAddress, - uint64(v.Count()), // worst case estimation. - func() Value { - - var value Value + // Block mutations to v through any sibling wrapper while the lazy + // iterator is being consumed by the user procedure. See ArrayValue.Map. + var result Value + context.WithContainerMutationPrevention(v.ValueID(), func() { + result = NewArrayValueWithIterator( + context, + // NOTE: result is NOT v.Type, which could be a constant-sized array type. + // Instead, result is always a variable-sized array type, + // because filtering can change the number of elements in the array. + NewVariableSizedStaticType(context, resultElementStaticType), + common.ZeroAddress, + uint64(v.Count()), // worst case estimation. + func() Value { + + var value Value + + for { + common.UseComputation( + context, + common.ComputationUsage{ + Kind: common.ComputationKindAtreeArrayReadIteration, + Intensity: 1, + }, + ) - for { - common.UseComputation( - context, - common.ComputationUsage{ - Kind: common.ComputationKindAtreeArrayReadIteration, - Intensity: 1, - }, - ) + atreeValue, err := iterator.Next() + if err != nil { + panic(errors.NewExternalError(err)) + } - atreeValue, err := iterator.Next() - if err != nil { - panic(errors.NewExternalError(err)) - } + // Also handles the end of array case since iterator.Next() returns nil for that. + if atreeValue == nil { + return nil + } - // Also handles the end of array case since iterator.Next() returns nil for that. - if atreeValue == nil { - return nil - } + value = MustConvertStoredValue(context, atreeValue) + if value == nil { + return nil + } - value = MustConvertStoredValue(context, atreeValue) - if value == nil { - return nil - } + if asReference { + value = getReferenceValue( + context, + value, + argumentType, + ) + } - if asReference { - value = getReferenceValue( + procResult := invokeFunctionValue( context, - value, - argumentType, + procedure, + []Value{value}, + argumentTypes, + parameterTypes, + sema.BoolType, + nil, ) + + shouldInclude, ok := procResult.(BoolValue) + if !ok { + panic(errors.NewUnreachableError()) + } + + // We found the next entry of the filtered array. + if shouldInclude { + break + } } - result := invokeFunctionValue( + return value.Transfer( context, - procedure, - []Value{value}, - argumentTypes, - parameterTypes, - sema.BoolType, + atree.Address{}, + false, nil, + nil, + false, // value has a parent container because it is from iterator. ) - - shouldInclude, ok := result.(BoolValue) - if !ok { - panic(errors.NewUnreachableError()) - } - - // We found the next entry of the filtered array. - if shouldInclude { - break - } - } - - return value.Transfer( - context, - atree.Address{}, - false, - nil, - nil, - false, // value has a parent container because it is from iterator. - ) - }, - ) + }, + ) + }) + return result } func (v *ArrayValue) Map( @@ -2051,53 +2057,61 @@ func (v *ArrayValue) Map( }, ) - return NewArrayValueWithIterator( - context, - returnArrayStaticType, - common.ZeroAddress, - uint64(count), - func() Value { + // Block mutations to v through any sibling wrapper while the lazy + // iterator is being consumed by the user procedure. Without this, + // a callback can mutate a sibling wrapper into a slab split/promote + // and the iterator continues walking the orphaned root silently. + var result Value + context.WithContainerMutationPrevention(v.ValueID(), func() { + result = NewArrayValueWithIterator( + context, + returnArrayStaticType, + common.ZeroAddress, + uint64(count), + func() Value { - // Computation was already metered above + // Computation was already metered above - atreeValue, err := iterator.Next() - if err != nil { - panic(errors.NewExternalError(err)) - } + atreeValue, err := iterator.Next() + if err != nil { + panic(errors.NewExternalError(err)) + } - if atreeValue == nil { - return nil - } + if atreeValue == nil { + return nil + } - value := MustConvertStoredValue(context, atreeValue) - if asReference { - value = getReferenceValue( + value := MustConvertStoredValue(context, atreeValue) + if asReference { + value = getReferenceValue( + context, + value, + argumentType, + ) + } + + mapped := invokeFunctionValue( context, - value, - argumentType, + procedure, + []Value{value}, + argumentTypes, + parameterTypes, + returnType, + nil, ) - } - result := invokeFunctionValue( - context, - procedure, - []Value{value}, - argumentTypes, - parameterTypes, - returnType, - nil, - ) - - return result.Transfer( - context, - atree.Address{}, - false, - nil, - nil, - false, // value has a parent container because it is from iterator. - ) - }, - ) + return mapped.Transfer( + context, + atree.Address{}, + false, + nil, + nil, + false, // value has a parent container because it is from iterator. + ) + }, + ) + }) + return result } func (v *ArrayValue) ForEach( From 464a2b07eb5e74b0b2054c706998987170dff6a5 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 13:24:43 -0700 Subject: [PATCH 067/139] Update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 70c8151e24..219d8a26e9 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.3" +const Version = "v1.10.4-rc.4" From e73e3a052918019bdfae614bcc00d5d2d90dc394 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 13:33:47 -0700 Subject: [PATCH 068/139] Add more tests --- interpreter/stale_atree_view_test.go | 454 +++++++++++++++++++++++++++ interpreter/value_array.go | 11 + interpreter/value_composite.go | 8 + interpreter/value_dictionary.go | 8 + 4 files changed, 481 insertions(+) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index f267be2ab3..9eaf6789f8 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -95,12 +95,61 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { }, ) + // liveInlinedOf exposes whether the underlying atree container of a + // reference variable is currently stored inlined inside its parent's + // slab. Atree may transition a container between inlined and + // standalone-slab storage when its parent grows or shrinks. Such + // transitions are NOT observable through LiveValueID (atree assigns + // a stable ValueID across inline/uninline transitions), so this + // helper is needed to assert that uninlining actually occurred in + // tests that probe the safety of the post-uninlining state. + liveInlinedOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveInlinedOf", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, + }, + }, + sema.BoolTypeAnnotation, + ), + "", + func( + context interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + name := args[0].(*interpreter.StringValue).Str + value := context.GetValueOfVariable(name) + ref := value.(*interpreter.EphemeralReferenceValue) + var inlined bool + switch v := ref.Value.(type) { + case *interpreter.ArrayValue: + inlined = v.LiveInlined() + case *interpreter.DictionaryValue: + inlined = v.LiveInlined() + case *interpreter.CompositeValue: + inlined = v.LiveInlined() + default: + t.Fatalf("unexpected value type %T", ref.Value) + } + return interpreter.BoolValue(inlined) + }, + ) + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(liveValueIDOfFunction) + baseValueActivation.DeclareValue(liveInlinedOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, liveValueIDOfFunction) + interpreter.Declare(baseActivation, liveInlinedOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation @@ -534,4 +583,409 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) + + // When an attachment method is bound via `composite[B]`, the bound function + // captures `base = v.GetBaseValue(...)`, a fresh `EphemeralReferenceValue` + // pointing at the parent composite. `MaybeDereferenceReceiver` at invoke + // time only validates the *attachment* receiver, not the captured base. + // + // The safe contract is that any code path that ultimately walks back through + // the parent via `base` must re-trigger the staleness check on the parent + // wrapper — either at the index expression `ref2[B]` (because the stale + // ref2 is evaluated as the index target) or inside the method body when + // `base.X` is evaluated. + // + // Both directions are exercised by the two sub-tests below. They currently + // PASS, confirming Gap 3 is fully covered by the centralized check; they + // serve as positive regression coverage so a future refactor that removes + // either the index-target check or the in-method `base` re-check would be + // caught. + + typeDeclarations := ` + access(all) resource R { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) attachment A1 for R { + access(all) var a1: String; access(all) var a2: String + access(all) var a3: String; access(all) var a4: String + init() { self.a1 = ""; self.a2 = ""; self.a3 = ""; self.a4 = "" } + access(all) fun inflate() { + self.a1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + self.a2 = self.a1; self.a3 = self.a1; self.a4 = self.a1 + } + } + access(all) attachment A2 for R { + access(all) var b1: String; access(all) var b2: String + access(all) var b3: String; access(all) var b4: String + init() { self.b1 = ""; self.b2 = ""; self.b3 = ""; self.b4 = "" } + access(all) fun inflate() { + self.b1 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + self.b2 = self.b1; self.b3 = self.b1; self.b4 = self.b1 + } + } + access(all) attachment A3 for R { + access(all) var d1: String; access(all) var d2: String + access(all) var d3: String; access(all) var d4: String + init() { self.d1 = ""; self.d2 = ""; self.d3 = ""; self.d4 = "" } + access(all) fun inflate() { + self.d1 = "dddddddddddddddddddddddddddddddddddddd" + self.d2 = self.d1; self.d3 = self.d1; self.d4 = self.d1 + } + } + access(all) attachment A4 for R { + access(all) var e1: String; access(all) var e2: String + access(all) var e3: String; access(all) var e4: String + init() { self.e1 = ""; self.e2 = ""; self.e3 = ""; self.e4 = "" } + access(all) fun inflate() { + self.e1 = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + self.e2 = self.e1; self.e3 = self.e1; self.e4 = self.e1 + } + } + access(all) attachment A5 for R { + access(all) var g1: String; access(all) var g2: String + access(all) var g3: String; access(all) var g4: String + init() { self.g1 = ""; self.g2 = ""; self.g3 = ""; self.g4 = "" } + access(all) fun inflate() { + self.g1 = "gggggggggggggggggggggggggggggggggggggg" + self.g2 = self.g1; self.g3 = self.g1; self.g4 = self.g1 + } + } + access(all) attachment A6 for R { + access(all) var h1: String; access(all) var h2: String + access(all) var h3: String; access(all) var h4: String + init() { self.h1 = ""; self.h2 = ""; self.h3 = ""; self.h4 = "" } + access(all) fun inflate() { + self.h1 = "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" + self.h2 = self.h1; self.h3 = self.h1; self.h4 = self.h1 + } + } + + // Attachment B exposes a method that reads back through base. + // A correct invariant requires the staleness check to fire any time + // base ultimately walks a stale parent wrapper. + access(all) attachment B for R { + access(all) fun readBaseBalance(): UFix64 { + return base.balance + } + } + ` + + t.Run("CompositeValue: attachment method via stale parent wrapper (direct index)", func(t *testing.T) { + t.Parallel() + + // Scenario A: invoke an attachment method directly through the stale + // parent wrapper. The check should fire when ref2 is evaluated as the + // target of the index expression `ref2[B]`. + err := runInvoke(t, typeDeclarations+` + access(all) fun main() { + let r0 <- create R(balance: 42.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r6 <- attach A6() to <-r5 + let r <- attach B() to <-r6 + + let arr: @[R] <- [<-r] + let ref1 = &arr[0] as &R + let ref2 = &arr[0] as &R + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref1[A1]!.inflate() + ref1[A2]!.inflate() + ref1[A3]!.inflate() + ref1[A4]!.inflate() + ref1[A5]!.inflate() + ref1[A6]!.inflate() + + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // The stale ref2 is the target of the index expression and is + // evaluated first by evalExpression, which runs + // CheckInvalidatedValueOrValueReference and fires + // InvalidatedContainerViewError before the attachment lookup or + // method binding can complete. + let stashed = ref2[B]!.readBaseBalance() + assert(stashed == 42.0, message: "unreachable if check fires") + + destroy arr + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("CompositeValue: attachment method via stale parent wrapper (captured-before-split)", func(t *testing.T) { + t.Parallel() + + // Scenario B: capture the attachment reference BEFORE the split, then + // trigger the split through ref, then invoke the attachment method via + // the captured reference. ref2 no longer appears in the post-split code + // path, so the check must fire elsewhere — inside the method body, when + // `base` is evaluated as an identifier and CheckInvalidatedValueOrValueReference + // recurses into the captured base reference's stale parent composite. + err := runInvoke(t, typeDeclarations+` + access(all) fun main() { + let r0 <- create R(balance: 42.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r6 <- attach A6() to <-r5 + let r <- attach B() to <-r6 + + let arr: @[R] <- [<-r] + let ref1 = &arr[0] as &R + let ref2 = &arr[0] as &R + + // Capture the attachment reference BEFORE the split. Internally + // this also wires the attachment's v.base to ref2's CompositeValue. + let bRef = ref2[B]! + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref1[A1]!.inflate() + ref1[A2]!.inflate() + ref1[A3]!.inflate() + ref1[A4]!.inflate() + ref1[A5]!.inflate() + ref1[A6]!.inflate() + + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // The receiver bRef refers to the attachment (not stale), so + // MaybeDereferenceReceiver does not fire on the attachment ref1. + // The check must fire when the method body evaluates base + // (which resolves to a reference at the now-stale parent). + let stashed = bRef.readBaseBalance() + assert(stashed == 42.0, message: "unreachable if check fires") + + destroy arr + } + `) + + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + // Resource-linearity-specific scenarios (Invariant 3) from + // atree-slab-change-security-analysis.md. These tests re-frame the + // previously-identified gaps through the lens of resource linearity: + // a resource must exist in exactly one location at any time, and no + // sibling wrapper may be used to read, mutate, or extract a resource + // after its slab tree has been restructured. + + t.Run("ArrayValue.removeFirst via stale sibling", func(t *testing.T) { + t.Parallel() + + // Scenario from "Destroy + sibling resurrection": + // the canonical ref1 removes-and-destroys a resource via removeFirst, + // then the sibling ref1's removeFirst attempt must be rejected. + // Without the centralized staleness check, the sibling's removeFirst + // could read from the demoted slab and yield a phantom resource — + // a resource-linearity violation (the resource ref1 already destroyed + // would now exist in a second location). + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 1.0)]] + + let ref1 = &outer[0] as auth(Mutate, Remove) &[Vault] + let ref2 = &outer[0] as auth(Mutate, Remove) &[Vault] + + // Pre-grow ref1's array to trigger split, demoting ref2's view. + var i: Int = 0 + while i < 200 { + ref1.append(<-create Vault(balance: UFix64(i) + 10.0)) + i = i + 1 + } + + assert( + liveValueIDOf("ref1") != liveValueIDOf("ref2"), + message: "after split: refs should observe diverged live atree value IDs" + ) + + // ref1 removes and destroys the original first vault. + let v <- ref1.removeFirst() + assert(v.balance == 1.0, message: "ref1 removed canonical first vault") + destroy v + + // Sibling's attempt to remove must be rejected. Without the + // staleness check, it could read the demoted slab and yield + // a phantom copy of the already-destroyed resource. + let phantom <- ref2.removeFirst() + destroy phantom + + destroy outer + } + `) + + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("ArrayValue: inner-growth uninlining surfaces via liveInlinedOf; sibling rejected", func(t *testing.T) { + t.Parallel() + + // `outer: @[[Vault]]` begins with its single inner array stored + // inlined inside outer's slab. Atree inlines a child container + // whenever the child's full content fits within its parent's + // inline-element budget. When the child grows past that budget, + // atree uninlines it — physically moving its data to a standalone + // slab. + // + // Important observation: outer-side growth alone (i.e., calling + // `outer.append(<-[<-Vault])` many times) does NOT uninline a + // specific child; atree just splits outer into more leaf slabs, + // each of which can independently hold inlined children. The way + // to actually trigger uninlining of `outer[0]` is to grow `outer[0]` + // itself past the inline-element budget. + // + // Atree's ValueID is stable across the inline ↔ standalone-slab + // transition, so the cached-vs-live ValueID comparison used by the + // staleness check elsewhere in this file cannot detect uninlining. + // To make the transition observable at the Cadence level, this test + // uses `liveInlinedOf`, which taps `*atree.Array.Inlined()` (and + // the analogous method on `*atree.OrderedMap`). + // + // Both sibling refs are stale post-uninlining/split, because growth + // triggered through `ref1` necessarily restructures the slab tree + // they shared at construction. The centralized check is expected + // to reject the sibling's subsequent mutation with + // `InvalidatedContainerViewError`. The Cadence assertions below + // pin down each observable transition. + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 1.0)]] + + let ref1 = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + assert( + liveInlinedOf("ref1"), + message: "precondition: inner[0] should start inlined inside outer's slab" + ) + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "precondition: siblings should observe the same live atree value ID" + ) + + // Grow the inner array via ref1. As inner[0]'s content + // exceeds atree's inline-element budget, atree uninlines it + // into its own standalone slab. + var i: Int = 0 + while i < 200 { + ref1.append(<-create Vault(balance: UFix64(i) + 10.0)) + i = i + 1 + } + + // Concretely confirm uninlining happened. Without this + // assertion the test would be vacuously true if atree + // happened to keep inner[0] inlined. + assert( + !liveInlinedOf("ref1"), + message: "expected inner[0] to be uninlined after growth via ref1; if this fires, " + .concat("the iteration count is too small for atree's current inline budget") + ) + + // Append through the sibling. The slab tree restructuring + // (which includes the uninline transition) is also a split + // demotion at the inner array's tree level, so the cached-vs- + // live ValueID comparison fires here and the centralized + // check rejects the mutation. + ref2.append(<-create Vault(balance: 999.0)) + + destroy outer + } + `) + + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) + + t.Run("forEachAttachment + sibling parent mutation in callback", func(t *testing.T) { + t.Parallel() + + // `ref1.forEachAttachment(...)` opens an atree iterator on the parent + // composite's attachment dictionary. The user callback then mutates + // the parent via a sibling reference `ref2`, growing the dictionary + // enough to split it. The safe contract is that *some* defense + // (per-iteration `CheckInvalidatedValueOrValueReference` at the + // loop head, or `WithContainerMutationPrevention(v.ValueID())` + // covering the iterator) must fire before any subsequent + // `iterator.Next()` reads from a demoted slab. + err := runInvoke(t, typeDeclarations+` + access(all) fun main() { + let r0 <- create R(balance: 42.0) + let r1 <- attach A1() to <-r0 + let r2 <- attach A2() to <-r1 + let r3 <- attach A3() to <-r2 + let r4 <- attach A4() to <-r3 + let r5 <- attach A5() to <-r4 + let r6 <- attach A6() to <-r5 + let r <- attach B() to <-r6 + + let arr: @[R] <- [<-r] + let ref1 = &arr[0] as &R + let ref2 = &arr[0] as &R + + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + // Inside the forEachAttachment callback, mutate every + // attachment via the sibling. Each inflate() writes long + // strings into an attachment's atree map; the cumulative + // effect restructures the parent's atree dictionary. + // + // The expected safe outcome is that either: + // (a) the next-iteration head re-check on compositeReference + // fires InvalidatedContainerViewError, or + // (b) atree's mutation-prevention machinery fires + // ContainerMutatedDuringIterationError, because ref1 + // and ref2 share the same parent ValueID. + // Either outcome preserves invariants. + ref1.forEachAttachment(fun (a: &AnyResourceAttachment): Void { + ref2[A1]!.inflate() + ref2[A2]!.inflate() + ref2[A3]!.inflate() + ref2[A4]!.inflate() + ref2[A5]!.inflate() + ref2[A6]!.inflate() + }) + + destroy arr + } + `) + + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + }) } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 1eb043c605..69b52be230 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1709,6 +1709,17 @@ func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } +// LiveInlined reports whether the underlying atree array is currently stored +// inlined inside its parent container's slab, as opposed to as a standalone +// slab. Atree may transition an array between these representations when its +// parent container grows or shrinks. The transition does not change the +// array's ValueID (which is stable across structural changes), so it is not +// observable through LiveValueID. +// Intended for testing only. +func (v *ArrayValue) LiveInlined() bool { + return v.array.Inlined() +} + // isStaleAtreeView reports whether this wrapper has been displaced by a // structural change (slab split/merge/promotion) that was performed through // a sibling wrapper sharing the same underlying slab tree. See the diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 030a14adef..dcfc92c4d9 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1844,6 +1844,14 @@ func (v *CompositeValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } +// LiveInlined reports whether the underlying atree map is currently stored +// inlined inside its parent container's slab, as opposed to as a standalone +// slab. See ArrayValue.LiveInlined for context. +// Intended for testing only. +func (v *CompositeValue) LiveInlined() bool { + return v.dictionary.Inlined() +} + func (v *CompositeValue) RemoveField( context ValueRemoveContext, name string, diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index cd7f554f55..3b14b209e6 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -1835,6 +1835,14 @@ func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } +// LiveInlined reports whether the underlying atree map is currently stored +// inlined inside its parent container's slab, as opposed to as a standalone +// slab. See ArrayValue.LiveInlined for context. +// Intended for testing only. +func (v *DictionaryValue) LiveInlined() bool { + return v.dictionary.Inlined() +} + // isStaleAtreeView reports whether this wrapper has been displaced by a // structural change (slab split/merge/promotion) that was performed through // a sibling wrapper sharing the same underlying slab tree. See the From da7561d97060715894fd356af4c3afe6a3f4eaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 29 May 2026 13:55:15 -0700 Subject: [PATCH 069/139] fix gaps in stale atree slab detection by also taking into account slab mutation count --- go.mod | 2 + go.sum | 4 +- interpreter/stale_atree_view_test.go | 280 +++++++++++++++++++++++++++ interpreter/value_array.go | 43 ++-- interpreter/value_composite.go | 36 +++- interpreter/value_dictionary.go | 43 ++-- 6 files changed, 377 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index 5edda1b5f2..f5125a4835 100644 --- a/go.mod +++ b/go.mod @@ -62,3 +62,5 @@ require ( gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e diff --git a/go.sum b/go.sum index 28dbf96f6e..d2ded5eb90 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU= -github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e h1:N9a/eHZBf8mQ8+GgiB9428oKLPJhuAhHbWdBjLqeqQ4= +github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 8b5585da88..98b945a0d1 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -95,12 +95,57 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { }, ) + // liveMutationCountOf exposes the underlying atree container's current + // root-slab mutation counter — the second signal isStaleAtreeView + // compares against the cached snapshot. Returning UInt64 lets Cadence + // test code compare counts directly with == / >. + liveMutationCountOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "liveMutationCountOf", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "name", + TypeAnnotation: sema.StringTypeAnnotation, + }, + }, + sema.UInt64TypeAnnotation, + ), + "", + func( + context interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + name := args[0].(*interpreter.StringValue).Str + value := context.GetValueOfVariable(name) + ref := value.(*interpreter.EphemeralReferenceValue) + var count uint64 + switch v := ref.Value.(type) { + case *interpreter.ArrayValue: + count = v.LiveMutationCount() + case *interpreter.DictionaryValue: + count = v.LiveMutationCount() + case *interpreter.CompositeValue: + count = v.LiveMutationCount() + default: + t.Fatalf("unexpected value type %T", ref.Value) + } + return interpreter.NewUnmeteredUInt64Value(count) + }, + ) + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) baseValueActivation.DeclareValue(liveValueIDOfFunction) + baseValueActivation.DeclareValue(liveMutationCountOfFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) interpreter.Declare(baseActivation, liveValueIDOfFunction) + interpreter.Declare(baseActivation, liveMutationCountOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation @@ -179,6 +224,87 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { assert.Equal(t, 30, staleViewErr.StartPosition().Line) }) + t.Run("ArrayValue: silent-corruption reproducer is now blocked", func(t *testing.T) { + t.Parallel() + + // Pre-patch silent-corruption demonstration: the stale-wrapper + // append parks the new element on the demoted leaf slab. The + // canonical view's iteration would not see the parked element, + // but removeLast in reverse order would surface it — meaning the + // inner array has divergent length depending on which path you + // read it through. + // + // With the staleness check in place, ref2.append now panics with + // InvalidatedContainerViewError; execution never reaches the + // post-mutation forensics. The forensics are retained here as + // documentation of what the attack looked like — they would + // surface iteratedCount != removalCount or + // elementFoundIterating != elementFoundRemoving on a vulnerable + // build. + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "before split: both refs should observe the same live atree value ID" + ) + + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // Mutation via the now-stale ref2 must be rejected here. + ref2.append(<- create Vault(balance: 123.456)) + + // Unreachable in a fixed build. On a vulnerable build the + // following would observe the divergence between the + // canonical view's iterator and a reverse-removeLast walk. + var empty: @[Vault] <- [] + var extracted <- outer[0] <- empty + var iteratedCount: Int = 0 + var removalCount: Int = 0 + var elementFoundIterating = false + var elementFoundRemoving = false + + for element in &extracted as &[Vault] { + iteratedCount = iteratedCount + 1 + if element.balance == 123.456 { + elementFoundIterating = true + } + } + while extracted.length > 0 { + let element <- extracted.removeLast() + if element.balance == 123.456 { + elementFoundRemoving = true + } + destroy element + removalCount = removalCount + 1 + } + + assert(iteratedCount == removalCount) + assert(elementFoundIterating == elementFoundRemoving) + destroy extracted + destroy outer + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + // The ref2.append line — the staleness check must fire here, not + // at any later point. + assert.Equal(t, 25, staleViewErr.StartPosition().Line) + }) + t.Run("ArrayValue: insert via stale wrapper after split", func(t *testing.T) { t.Parallel() @@ -459,4 +585,158 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { assert.ErrorAs(t, err, &staleViewErr) assert.Equal(t, 30, staleViewErr.StartPosition().Line) }) + + // The remaining subtests cover the gap that motivated the + // MutationCount mechanism: atree's promoteChildAsNewRoot leaves the + // orphaned old root's SlabID unchanged, so the ValueID-only check + // (which works for splitRoot via SetSlabID side effects) cannot see it. + // + // Setup pattern: + // 1. Grow the inner container past one split via `ref` alone — the + // root is now a metaslab. + // 2. Create `ref2` AFTER the split — ref2 caches the post-split + // ValueID AND mutationCount, both reflecting the metaslab root. + // 3. Through `ref`, remove enough elements to collapse the tree back + // to a single data slab. This triggers promoteChildAsNewRoot, + // which preserves ValueID but bumps the orphaned metaslab's + // mutation counter. + // 4. Through `ref2`, attempt a mutation. The valueID check passes, + // but the mutationCount check trips — InvalidatedContainerViewError. + // + // Note on the intermediate assertions: liveMutationCountOf reads the + // live root's counter for the wrapper passed in. After promote, ref's + // live root is the freshly-promoted child (counter 0), while ref2's + // .root pointer still references the orphaned metaslab (counter > 0). + // So the divergence check is between the two wrappers, not pre/post on + // a single wrapper. This guards against the false-pass mode + // "promote did not fire" cleanly rather than producing a confusing + // "did not expect a stale-view error". + + t.Run("ArrayValue: append via stale wrapper after promote", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + + // Construct ref2 AFTER the split — it caches the + // multi-level state's valueID and mutationCount. + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "after split, before promote: refs share the same live atree value ID" + ) + assert( + liveMutationCountOf("ref") == liveMutationCountOf("ref2"), + message: "after split, before promote: refs share the same live mutation counter" + ) + + // Collapse: must shrink all the way to one element so the + // tree fully collapses and promoteChildAsNewRoot fires. + while i > 0 { + let v <- ref.removeLast() + destroy v + i = i - 1 + } + + // Guard against "promote did not fire". After promote, ref + // reads the new (freshly-promoted) root's counter, ref2's + // .root pointer still references the orphaned metaslab — + // whose counter was bumped pre-swap. So the two diverge. + assert( + liveMutationCountOf("ref") != liveMutationCountOf("ref2"), + message: "after promote: ref's live root (new, fresh counter) must diverge from ref2's live root (orphaned, bumped)" + ) + // ValueID alone would NOT diverge (the gap). + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "ValueID is preserved across promote — this is the gap the counter closes" + ) + + // Mutation through the stale ref2 must be rejected. + ref2.append(<- create Vault(balance: 123.456)) + + destroy outer + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 53, staleViewErr.StartPosition().Line) + }) + + t.Run("DictionaryValue: insert via stale wrapper after promote", func(t *testing.T) { + t.Parallel() + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[{Int: Vault}] <- [<-{0: <-create Vault(balance: 0.0)}] + + let ref = &outer[0] as auth(Mutate) &{Int: Vault} + var i: Int = 1 + while i < 300 { + let old <- ref.insert(key: i, <-create Vault(balance: UFix64(i))) + assert(old == nil, message: "dict insert should not collide") + destroy old + i = i + 1 + } + + let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} + + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "after split, before promote: refs share the same live atree value ID" + ) + assert( + liveMutationCountOf("ref") == liveMutationCountOf("ref2"), + message: "after split, before promote: refs share the same live mutation counter" + ) + + // Collapse via removes (remove ALL keys including the + // original 0-key) so promote fires. + let original <- ref.remove(key: 0) + destroy original + while i > 1 { + i = i - 1 + let removed <- ref.remove(key: i) + destroy removed + } + + assert( + liveMutationCountOf("ref") != liveMutationCountOf("ref2"), + message: "after promote: ref's live root (new, fresh counter) must diverge from ref2's live root (orphaned, bumped)" + ) + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "ValueID is preserved across promote — this is the gap the counter closes" + ) + + let old <- ref2.insert(key: 9999, <- create Vault(balance: 123.456)) + destroy old + + destroy outer + } + `) + var staleViewErr *interpreter.InvalidatedContainerViewError + assert.ErrorAs(t, err, &staleViewErr) + assert.Equal(t, 49, staleViewErr.StartPosition().Line) + }) + } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 1eb043c605..4b967aef5d 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -45,6 +45,11 @@ type ArrayValue struct { // stable even if the array's runtime root slab ID changes. // See the equivalent field on CompositeValue for the underlying scenario. valueID atree.ValueID + + // mutationCount is the atree root-slab mutation counter captured at construction. + // Together with valueID it detects sibling-wrapper staleness. + // See isStaleAtreeView. + mutationCount uint64 } func NewArrayValue( @@ -220,10 +225,11 @@ func newArrayValueFromAtreeArray( common.UseMemory(gauge, common.ArrayValueBaseMemoryUsage) return &ArrayValue{ - Type: staticType, - array: atreeArray, - valueID: atreeArray.ValueID(), - elementSize: elementSize, + Type: staticType, + array: atreeArray, + valueID: atreeArray.ValueID(), + mutationCount: atreeArray.MutationCount(), + elementSize: elementSize, } } @@ -1709,14 +1715,29 @@ func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } -// isStaleAtreeView reports whether this wrapper has been displaced by a -// structural change (slab split/merge/promotion) that was performed through -// a sibling wrapper sharing the same underlying slab tree. See the -// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full -// context. Detected uses of a stale wrapper are rejected centrally in -// `CheckInvalidatedValueOrValueReference`. +// LiveMutationCount returns the underlying atree array's current root-slab mutation counter. +// Counterpart to LiveValueID for the second signal that isStaleAtreeView checks. +// Intended for testing only; production code must use the cached mutationCount captured at wrapper construction. +func (v *ArrayValue) LiveMutationCount() uint64 { + return v.array.MutationCount() +} + +// isStaleAtreeView reports whether this wrapper has been displaced by a structural change +// (slab split/promotion/PopIterate) that was performed through a sibling wrapper +// sharing the same underlying slab tree. +// See the `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full context. +// Detected uses of a stale wrapper are rejected centrally in `CheckInvalidatedValueOrValueReference`. +// +// Two independent signals are compared against the cached snapshot: +// - ValueID: detects splitRoot (atree mutates the demoted root's SlabID via SetSlabID; +// sibling wrappers reading via the orphaned-pointer see the new SlabID). +// - MutationCount: detects promoteChildAsNewRoot and PopIterate +// (atree does NOT perturb the orphaned root's SlabID in these cases; +// a slab-level counter bumped on the OLD root pre-swap is the sibling-visible signal). +// See atree.ArraySlab.MutationCount for the contract. func (v *ArrayValue) isStaleAtreeView() bool { - return v.array.ValueID() != v.valueID + return v.array.ValueID() != v.valueID || + v.array.MutationCount() != v.mutationCount } func (v *ArrayValue) GetOwner() common.Address { diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 030a14adef..f2684ecf4e 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -64,6 +64,11 @@ type CompositeValue struct { // under a different ID, bypassing invalidation. valueID atree.ValueID + // mutationCount is the atree root-slab mutation counter captured at construction. + // Together with valueID it detects sibling-wrapper staleness. + // See isStaleAtreeView. + mutationCount uint64 + // attachments also have a reference to their base value. This field is set in three cases: // 1) when an attachment `A` is accessed off `v` using `v[A]`, this is set to `&v` // 2) When a resource `r`'s destructor is invoked, all of `r`'s attachments' destructors will also run, and @@ -262,6 +267,7 @@ func NewCompositeValueFromAtreeMap( return &CompositeValue{ dictionary: atreeOrderedMap, valueID: atreeOrderedMap.ValueID(), + mutationCount: atreeOrderedMap.MutationCount(), Location: typeInfo.Location, QualifiedIdentifier: typeInfo.QualifiedIdentifier, Kind: typeInfo.Kind, @@ -1644,6 +1650,7 @@ func (v *CompositeValue) Clone(context ValueCloneContext) Value { return &CompositeValue{ dictionary: dictionary, valueID: dictionary.ValueID(), + mutationCount: dictionary.MutationCount(), Location: v.Location, QualifiedIdentifier: v.QualifiedIdentifier, Kind: v.Kind, @@ -1823,14 +1830,22 @@ func (v *CompositeValue) ValueID() atree.ValueID { return v.valueID } -// isStaleAtreeView reports whether this wrapper has been displaced by a -// structural change (slab split/merge/promotion) that was performed through -// a sibling wrapper sharing the same underlying slab tree. See the -// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full -// context. Detected uses of a stale wrapper are rejected centrally in -// `CheckInvalidatedValueOrValueReference`. +// isStaleAtreeView reports whether this wrapper has been displaced by a structural change +// (slab split/promotion/PopIterate) that was performed through a sibling wrapper +// sharing the same underlying slab tree. +// See the `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full context. +// Detected uses of a stale wrapper are rejected centrally in `CheckInvalidatedValueOrValueReference`. +// +// Two independent signals are compared against the cached snapshot: +// - ValueID: detects splitRoot (atree mutates the demoted root's SlabID via SetSlabID; +// sibling wrappers reading via the orphaned-pointer see the new SlabID). +// - MutationCount: detects promoteChildAsNewRoot and PopIterate +// (atree does NOT perturb the orphaned root's SlabID in these cases; +// a slab-level counter bumped on the OLD root pre-swap is the sibling-visible signal). +// See atree.MapSlab.MutationCount for the contract. func (v *CompositeValue) isStaleAtreeView() bool { - return v.dictionary.ValueID() != v.valueID + return v.dictionary.ValueID() != v.valueID || + v.dictionary.MutationCount() != v.mutationCount } // LiveValueID returns the underlying atree map's current value ID. @@ -1844,6 +1859,13 @@ func (v *CompositeValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } +// LiveMutationCount returns the underlying atree map's current root-slab mutation counter. +// Counterpart to LiveValueID for the second signal that isStaleAtreeView checks. +// Intended for testing only; production code must use the cached mutationCount captured at wrapper construction. +func (v *CompositeValue) LiveMutationCount() uint64 { + return v.dictionary.MutationCount() +} + func (v *CompositeValue) RemoveField( context ValueRemoveContext, name string, diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index cd7f554f55..deb6df48ec 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -45,6 +45,11 @@ type DictionaryValue struct { // stable even if the dictionary's runtime root slab ID changes. // See the equivalent field on CompositeValue for the underlying scenario. valueID atree.ValueID + + // mutationCount is the atree root-slab mutation counter captured at construction. + // Together with valueID it detects sibling-wrapper staleness. + // See isStaleAtreeView. + mutationCount uint64 } func NewDictionaryValue( @@ -297,10 +302,11 @@ func newDictionaryValueFromAtreeMap( common.UseMemory(gauge, common.DictionaryValueBaseMemoryUsage) return &DictionaryValue{ - Type: staticType, - dictionary: atreeOrderedMap, - valueID: atreeOrderedMap.ValueID(), - elementSize: elementSize, + Type: staticType, + dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), + mutationCount: atreeOrderedMap.MutationCount(), + elementSize: elementSize, } } @@ -1835,14 +1841,29 @@ func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } -// isStaleAtreeView reports whether this wrapper has been displaced by a -// structural change (slab split/merge/promotion) that was performed through -// a sibling wrapper sharing the same underlying slab tree. See the -// `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full -// context. Detected uses of a stale wrapper are rejected centrally in -// `CheckInvalidatedValueOrValueReference`. +// LiveMutationCount returns the underlying atree map's current root-slab mutation counter. +// Counterpart to LiveValueID for the second signal that isStaleAtreeView checks. +// Intended for testing only; production code must use the cached mutationCount captured at wrapper construction. +func (v *DictionaryValue) LiveMutationCount() uint64 { + return v.dictionary.MutationCount() +} + +// isStaleAtreeView reports whether this wrapper has been displaced by a structural change +// (slab split/promotion/PopIterate) that was performed through a sibling wrapper +// sharing the same underlying slab tree. +// See the `AtreeBackedValue` interface and `InvalidatedContainerViewError` for the full context. +// Detected uses of a stale wrapper are rejected centrally in `CheckInvalidatedValueOrValueReference`. +// +// Two independent signals are compared against the cached snapshot: +// - ValueID: detects splitRoot (atree mutates the demoted root's SlabID via SetSlabID; +// sibling wrappers reading via the orphaned-pointer see the new SlabID). +// - MutationCount: detects promoteChildAsNewRoot and PopIterate +// (atree does NOT perturb the orphaned root's SlabID in these cases; +// a slab-level counter bumped on the OLD root pre-swap is the sibling-visible signal). +// See atree.MapSlab.MutationCount for the contract. func (v *DictionaryValue) isStaleAtreeView() bool { - return v.dictionary.ValueID() != v.valueID + return v.dictionary.ValueID() != v.valueID || + v.dictionary.MutationCount() != v.mutationCount } func (v *DictionaryValue) SemaType(typeConverter TypeConverter) *sema.DictionaryType { From a222caef4a02389fbeea132f34d5014901a42452 Mon Sep 17 00:00:00 2001 From: Supun Setunga Date: Fri, 29 May 2026 14:18:39 -0700 Subject: [PATCH 070/139] Add more tests --- interpreter/stale_atree_view_test.go | 193 ++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 18 deletions(-) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 9eaf6789f8..6270c24fbf 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -595,12 +595,7 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { // ref2 is evaluated as the index target) or inside the method body when // `base.X` is evaluated. // - // Both directions are exercised by the two sub-tests below. They currently - // PASS, confirming Gap 3 is fully covered by the centralized check; they - // serve as positive regression coverage so a future refactor that removes - // either the index-target check or the in-method `base` re-check would be - // caught. - + // Both directions are exercised by the two sub-tests below. typeDeclarations := ` access(all) resource R { access(all) var balance: UFix64 @@ -855,13 +850,6 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { // atree uninlines it — physically moving its data to a standalone // slab. // - // Important observation: outer-side growth alone (i.e., calling - // `outer.append(<-[<-Vault])` many times) does NOT uninline a - // specific child; atree just splits outer into more leaf slabs, - // each of which can independently hold inlined children. The way - // to actually trigger uninlining of `outer[0]` is to grow `outer[0]` - // itself past the inline-element budget. - // // Atree's ValueID is stable across the inline ↔ standalone-slab // transition, so the cached-vs-live ValueID comparison used by the // staleness check elsewhere in this file cannot detect uninlining. @@ -905,13 +893,10 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { i = i + 1 } - // Concretely confirm uninlining happened. Without this - // assertion the test would be vacuously true if atree - // happened to keep inner[0] inlined. + // Confirm uninlining happened. assert( !liveInlinedOf("ref1"), - message: "expected inner[0] to be uninlined after growth via ref1; if this fires, " - .concat("the iteration count is too small for atree's current inline budget") + message: "expected inner[0] to be uninlined after growth via ref1" ) // Append through the sibling. The slab tree restructuring @@ -988,4 +973,176 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { var staleViewErr *interpreter.InvalidatedContainerViewError assert.ErrorAs(t, err, &staleViewErr) }) + + t.Run("ArrayValue: inline to standalone round-trip; sibling wrapper probed", func(t *testing.T) { + t.Parallel() + + // Probes the scenario flagged in atree-slab-change-security-analysis.md: + // "a single small standalone slab gets re-inlined when its parent shrinks, + // or vice-versa — would not be caught by the current check". + // + // Setup: two sibling refs to outer[0]. Grow inner via `ref` enough to + // uninline it. Then shrink via `ref` back down so atree re-inlines. + // The shape returns toward the original (inner empty/tiny, inlined), + // but `ref2`'s wrapper has been carried across both transitions and + // many intermediate slab tree restructurings. + // + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[<-create Vault(balance: 1.0)]] + + let ref = &outer[0] as auth(Mutate, Remove) &[Vault] + let ref2 = &outer[0] as auth(Mutate, Remove) &[Vault] + + assert( + liveInlinedOf("ref"), + message: "precondition: inner[0] should start inlined" + ) + assert( + liveValueIDOf("ref") == liveValueIDOf("ref2"), + message: "precondition: siblings should share live ValueID" + ) + + // Phase 1: grow inner via ref until atree uninlines outer[0]. + var i: Int = 0 + while i < 200 { + ref.append(<-create Vault(balance: UFix64(i) + 10.0)) + i = i + 1 + } + assert( + !liveInlinedOf("ref"), + message: "phase 1: expected inner[0] to be uninlined after growth" + ) + + // Phase 2: shrink inner via ref so atree re-inlines outer[0]. + var j: Int = 0 + while j < 200 { + let v <- ref.removeLast() + destroy v + j = j + 1 + } + assert( + liveInlinedOf("ref"), + message: "phase 2: expected inner[0] to be re-inlined after shrink" + ) + + // Probe ref2 after the inline <-> standalone round-trip. + // The sibling has not participated in either transition, and + // its cached pointers may reference now-freed standalone slabs + // or otherwise stale tree state. + ref2.append(<-create Vault(balance: 999.0)) + + // If the previous line succeeded (no panic from the staleness check), + // verify canonical state is consistent. A length other + // than 2 or wrong balances would indicate silent corruption. + let canonical = &outer[0] as &[Vault] + assert( + canonical.length == 2, + message: "canonical inner[0] length mismatch after round-trip: " + .concat(canonical.length.toString()) + ) + assert(canonical[0].balance == 1.0, message: "canonical inner[0][0] mismatch") + assert(canonical[1].balance == 999.0, message: "canonical inner[0][1] mismatch") + + destroy outer + } + `) + + require.NoError(t, err) + }) + + t.Run("ArrayValue: minimal inline to standalone transition; sibling probed", func(t *testing.T) { + t.Parallel() + + // Probes the narrowest inline -> standalone transition (no round-trip back). + // The earlier "inner-growth uninlining" test drives 200 + // successive appends to push inner past atree's inline-element budget, + // that produces many intermediate slab restructurings beyond the + // uninlining itself, each of which independently changes the slab + // tree shape and so guarantees a cached-vs-live ValueID mismatch on + // the sibling. + // + // This test instead appends one element at a time and stops the + // moment `liveInlinedOf` flips from `true` to `false`. That isolates + // the uninline transition as cleanly as the language layer allows: + // no extra post-transition restructuring, no round-trip back, and + // the loop count `i` records exactly how many small elements were + // needed. + // + // Why this matters: atree's stated contract is that a value's + // `ValueID` is stable across the inline <-> standalone transition. + // If the minimal uninlining produces no other observable tree change, + // the centralized staleness check (which compares cached vs. live + // `ValueID`) is structurally blind to it. + // The sibling's subsequent mutation succeeds, and the canonical-state + // assertions hold — meaning atree's storage indirection transparently + // rebinds the sibling's `*atree.Array` across the transition; + + err := runInvoke(t, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[]] + let ref1 = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + assert( + liveInlinedOf("ref1"), + message: "precondition: inner[0] should start inlined" + ) + assert( + liveValueIDOf("ref1") == liveValueIDOf("ref2"), + message: "precondition: siblings should share live ValueID" + ) + + // Append one element at a time via ref1, stopping the moment + // atree uninlines inner[0]. This is the minimal mutation + // sequence that drives the inline → standalone transition. + var i: Int = 0 + while liveInlinedOf("ref1") && i < 1000 { + ref1.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + assert( + !liveInlinedOf("ref1"), + message: "expected inner[0] to be uninlined within 1000 small appends; " + .concat("loop count: ").concat(i.toString()) + ) + + // Sibling probe immediately at the boundary crossing. + ref2.append(<-create Vault(balance: 9999.0)) + + // Canonical-state assertions catch silent corruption. + let canonical = &outer[0] as &[Vault] + let expectedLen = i + 1 + assert( + canonical.length == expectedLen, + message: "canonical inner[0] length mismatch after minimal uninlining: got " + .concat(canonical.length.toString()) + .concat(", want ") + .concat(expectedLen.toString()) + ) + assert( + canonical[0].balance == 0.0, + message: "canonical inner[0][0] balance mismatch after minimal uninlining" + ) + assert( + canonical[expectedLen - 1].balance == 9999.0, + message: "canonical inner[0][last] balance mismatch after minimal uninlining" + ) + + destroy outer + } + `) + + require.NoError(t, err) + }) } From 02935dee78cb9c1eb6873d40c052b63df29245c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 29 May 2026 15:33:18 -0700 Subject: [PATCH 071/139] adjust swap in VM to match interpreter --- bbq/compiler/compiler.go | 6 +++++- bbq/compiler/compiler_test.go | 11 ++++++++--- bbq/opcode/instructions.go | 22 ++++++++++++++++++++-- bbq/opcode/instructions.yml | 12 ++++++++++++ bbq/opcode/pretty_instructions.go | 15 ++++++++++++++- bbq/opcode/print_test.go | 2 +- bbq/vm/vm.go | 28 +++++++++++++++++++++++++++- 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index b22e9b7e32..e8c0db92f7 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -2359,8 +2359,12 @@ func (c *Compiler[_, _]) compileSwapSet( } indexedType := c.getOrAddType(indexExpressionTypes.IndexedType) + // Suppress the staleness re-check on the value being set: + // the swap validated both operands once at the start + // (matching the interpreter's VisitSwapStatement timing). c.emit(opcode.InstructionSetIndex{ - IndexedType: indexedType, + IndexedType: indexedType, + SkipValueStalenessCheck: true, }) default: diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 28e317db2b..6d03058133 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -10835,6 +10835,7 @@ func TestCompileSwapIndexInStructs(t *testing.T) { IndexedType: &interpreter.VariableSizedStaticType{ Type: interpreter.PrimitiveStaticTypeString, }, + SkipValueStalenessCheck: true, }, // set left index with right value @@ -10845,6 +10846,7 @@ func TestCompileSwapIndexInStructs(t *testing.T) { IndexedType: &interpreter.VariableSizedStaticType{ Type: interpreter.PrimitiveStaticTypeString, }, + SkipValueStalenessCheck: true, }, // Return @@ -11037,7 +11039,8 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: leftIndexIndex}, opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionSetIndex{ - IndexedType: rArrayType, + IndexedType: rArrayType, + SkipValueStalenessCheck: true, }, // jump to the end @@ -11064,7 +11067,8 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: leftIndexIndex}, opcode.PrettyInstructionGetLocal{Local: rightValueIndex}, opcode.PrettyInstructionSetIndex{ - IndexedType: rArrayType, + IndexedType: rArrayType, + SkipValueStalenessCheck: true, }, // set right index with left value @@ -11072,7 +11076,8 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: rightIndexIndex}, opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionSetIndex{ - IndexedType: rArrayType, + IndexedType: rArrayType, + SkipValueStalenessCheck: true, }, // destroy rs diff --git a/bbq/opcode/instructions.go b/bbq/opcode/instructions.go index 9ee8d9fa02..f81c0226d6 100644 --- a/bbq/opcode/instructions.go +++ b/bbq/opcode/instructions.go @@ -658,8 +658,19 @@ func (i InstructionRemoveIndex) Pretty(program ProgramForInstructions) PrettyIns // // Pops three values off the stack, the array, the index, and the value, // and then sets the value at the given index of the array to the value. +// If skipValueStalenessCheck is true, the staleness re-check on the value +// being set is suppressed; the container and index are still validated. +// Set by the swap-statement compiler, which matches the interpreter's +// timing by validating both swap operands once before the transfers and +// then performing the two sets without further per-instruction staleness +// checks. Without this flag the second SetIndex would re-check the value +// just before serializing it into the container, and that re-check would +// fire on values whose source slab was drained as part of the first +// SetIndex's eviction-cleanup — a pre-existing reference-corruption +// hazard in the non-resource swap path that is not in scope here. type InstructionSetIndex struct { - IndexedType uint16 + IndexedType uint16 + SkipValueStalenessCheck bool } var _ Instruction = InstructionSetIndex{} @@ -678,6 +689,8 @@ func (i InstructionSetIndex) String() string { func (i InstructionSetIndex) OperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfArgument(sb, "indexedType", i.IndexedType, colorize) + sb.WriteByte(' ') + printfArgument(sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, colorize) } func (i InstructionSetIndex) ResolvedOperandsString(sb *strings.Builder, @@ -685,21 +698,26 @@ func (i InstructionSetIndex) ResolvedOperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfTypeArgument(sb, "indexedType", program.GetTypes()[i.IndexedType], colorize) + sb.WriteByte(' ') + printfArgument(sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, colorize) } func (i InstructionSetIndex) Encode(code *[]byte) { emitOpcode(code, i.Opcode()) emitUint16(code, i.IndexedType) + emitBool(code, i.SkipValueStalenessCheck) } func DecodeSetIndex(ip *uint16, code []byte) (i InstructionSetIndex) { i.IndexedType = decodeUint16(ip, code) + i.SkipValueStalenessCheck = decodeBool(ip, code) return i } func (i InstructionSetIndex) Pretty(program ProgramForInstructions) PrettyInstruction { return PrettyInstructionSetIndex{ - IndexedType: program.GetTypes()[i.IndexedType], + IndexedType: program.GetTypes()[i.IndexedType], + SkipValueStalenessCheck: i.SkipValueStalenessCheck, } } diff --git a/bbq/opcode/instructions.yml b/bbq/opcode/instructions.yml index 0b1913da03..83a1ca7c1d 100644 --- a/bbq/opcode/instructions.yml +++ b/bbq/opcode/instructions.yml @@ -181,9 +181,21 @@ description: | Pops three values off the stack, the array, the index, and the value, and then sets the value at the given index of the array to the value. + If skipValueStalenessCheck is true, the staleness re-check on the value + being set is suppressed; the container and index are still validated. + Set by the swap-statement compiler, which matches the interpreter's + timing by validating both swap operands once before the transfers and + then performing the two sets without further per-instruction staleness + checks. Without this flag the second SetIndex would re-check the value + just before serializing it into the container, and that re-check would + fire on values whose source slab was drained as part of the first + SetIndex's eviction-cleanup — a pre-existing reference-corruption + hazard in the non-resource swap path that is not in scope here. operands: - name: "indexedType" type: "typeIndex" + - name: "skipValueStalenessCheck" + type: "bool" valueEffects: pop: - name: "array" diff --git a/bbq/opcode/pretty_instructions.go b/bbq/opcode/pretty_instructions.go index addf378614..53d10d2535 100644 --- a/bbq/opcode/pretty_instructions.go +++ b/bbq/opcode/pretty_instructions.go @@ -314,8 +314,19 @@ func (i PrettyInstructionRemoveIndex) String() string { // Pretty form of InstructionSetIndex with resolved operands. // Pops three values off the stack, the array, the index, and the value, // and then sets the value at the given index of the array to the value. +// If skipValueStalenessCheck is true, the staleness re-check on the value +// being set is suppressed; the container and index are still validated. +// Set by the swap-statement compiler, which matches the interpreter's +// timing by validating both swap operands once before the transfers and +// then performing the two sets without further per-instruction staleness +// checks. Without this flag the second SetIndex would re-check the value +// just before serializing it into the container, and that re-check would +// fire on values whose source slab was drained as part of the first +// SetIndex's eviction-cleanup — a pre-existing reference-corruption +// hazard in the non-resource swap path that is not in scope here. type PrettyInstructionSetIndex struct { - IndexedType interpreter.StaticType + IndexedType interpreter.StaticType + SkipValueStalenessCheck bool } var _ PrettyInstruction = PrettyInstructionSetIndex{} @@ -329,6 +340,8 @@ func (i PrettyInstructionSetIndex) String() string { sb.WriteString(i.Opcode().String()) sb.WriteByte(' ') printfTypeArgument(&sb, "indexedType", i.IndexedType, false) + sb.WriteByte(' ') + printfArgument(&sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, false) return sb.String() } diff --git a/bbq/opcode/print_test.go b/bbq/opcode/print_test.go index 5a925b8aea..3964e0f98e 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -277,7 +277,7 @@ func TestPrintInstruction(t *testing.T) { "SetTypeIndex indexedType:258 indexingType:258": {byte(SetTypeIndex), 1, 2, 1, 2}, "RemoveTypeIndex indexedType:258 indexingType:258": {byte(RemoveTypeIndex), 1, 2, 1, 2}, "SetAttachmentBase": {byte(SetAttachmentBase), 1, 2, 3}, - "SetIndex indexedType:258": {byte(SetIndex), 1, 2}, + "SetIndex indexedType:258 skipValueStalenessCheck:true": {byte(SetIndex), 1, 2, 1}, "GetIndex indexedType:258": {byte(GetIndex), 1, 2}, "RemoveIndex indexedType:258 pushPlaceholder:true": {byte(RemoveIndex), 1, 2, 1}, "Drop": {byte(Drop)}, diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index 7903cebb06..ed8f007061 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -131,6 +131,22 @@ func (vm *VM) pop3() (Value, Value, Value) { return value1, value2, value3 } +// pop3SkipValueStalenessCheck is pop3 with the staleness check on the +// third (top-of-stack) value suppressed. The container and index are still +// validated. Used by SetIndex with SkipValueStalenessCheck=true (swap path). +func (vm *VM) pop3SkipValueStalenessCheck() (Value, Value, Value) { + lastIndex := len(vm.stack) - 1 + value1, value2, value3 := vm.stack[lastIndex-2], vm.stack[lastIndex-1], vm.stack[lastIndex] + vm.stack[lastIndex-2], vm.stack[lastIndex-1], vm.stack[lastIndex] = nil, nil, nil + vm.stack = vm.stack[:lastIndex-2] + + context := vm.context + interpreter.CheckInvalidatedValueOrValueReference(value1, context) + interpreter.CheckInvalidatedValueOrValueReference(value2, context) + + return value1, value2, value3 +} + func (vm *VM) peekN(count int) []Value { stackHeight := len(vm.stack) startIndex := stackHeight - count @@ -912,7 +928,17 @@ func opSetGlobal(vm *VM, ins opcode.InstructionSetGlobal) { } func opSetIndex(vm *VM, ins opcode.InstructionSetIndex) { - container, index, value := vm.pop3() + var container, index, value interpreter.Value + if ins.SkipValueStalenessCheck { + // Swap-statement emit: the value was already validated when it was + // originally read at the start of the swap. Re-checking here would + // fire on values whose source slab was drained by the FIRST set's + // eviction-cleanup — same timing as the interpreter's swap, which + // only checks the operands once before the transfers. + container, index, value = vm.pop3SkipValueStalenessCheck() + } else { + container, index, value = vm.pop3() + } indexableValue := container.(interpreter.ValueIndexableValue) checkIndexedType( From 7479f3b27a355694338e0c5be729cbed55a7f57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 29 May 2026 16:33:29 -0700 Subject: [PATCH 072/139] check invalidated value or value reference in swap like VM used to --- bbq/compiler/compiler.go | 6 +----- bbq/compiler/compiler_test.go | 5 ----- bbq/opcode/instructions.go | 22 ++-------------------- bbq/opcode/instructions.yml | 12 ------------ bbq/opcode/pretty_instructions.go | 15 +-------------- bbq/opcode/print_test.go | 2 +- bbq/vm/vm.go | 28 +--------------------------- interpreter/interpreter_statement.go | 10 ++++++++++ interpreter/member_test.go | 14 +++++++++++++- 9 files changed, 29 insertions(+), 85 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index e8c0db92f7..b22e9b7e32 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -2359,12 +2359,8 @@ func (c *Compiler[_, _]) compileSwapSet( } indexedType := c.getOrAddType(indexExpressionTypes.IndexedType) - // Suppress the staleness re-check on the value being set: - // the swap validated both operands once at the start - // (matching the interpreter's VisitSwapStatement timing). c.emit(opcode.InstructionSetIndex{ - IndexedType: indexedType, - SkipValueStalenessCheck: true, + IndexedType: indexedType, }) default: diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 6d03058133..bf57feba2c 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -10835,7 +10835,6 @@ func TestCompileSwapIndexInStructs(t *testing.T) { IndexedType: &interpreter.VariableSizedStaticType{ Type: interpreter.PrimitiveStaticTypeString, }, - SkipValueStalenessCheck: true, }, // set left index with right value @@ -10846,7 +10845,6 @@ func TestCompileSwapIndexInStructs(t *testing.T) { IndexedType: &interpreter.VariableSizedStaticType{ Type: interpreter.PrimitiveStaticTypeString, }, - SkipValueStalenessCheck: true, }, // Return @@ -11040,7 +11038,6 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionSetIndex{ IndexedType: rArrayType, - SkipValueStalenessCheck: true, }, // jump to the end @@ -11068,7 +11065,6 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: rightValueIndex}, opcode.PrettyInstructionSetIndex{ IndexedType: rArrayType, - SkipValueStalenessCheck: true, }, // set right index with left value @@ -11077,7 +11073,6 @@ func TestCompileSwapIndexInResources(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionSetIndex{ IndexedType: rArrayType, - SkipValueStalenessCheck: true, }, // destroy rs diff --git a/bbq/opcode/instructions.go b/bbq/opcode/instructions.go index f81c0226d6..9ee8d9fa02 100644 --- a/bbq/opcode/instructions.go +++ b/bbq/opcode/instructions.go @@ -658,19 +658,8 @@ func (i InstructionRemoveIndex) Pretty(program ProgramForInstructions) PrettyIns // // Pops three values off the stack, the array, the index, and the value, // and then sets the value at the given index of the array to the value. -// If skipValueStalenessCheck is true, the staleness re-check on the value -// being set is suppressed; the container and index are still validated. -// Set by the swap-statement compiler, which matches the interpreter's -// timing by validating both swap operands once before the transfers and -// then performing the two sets without further per-instruction staleness -// checks. Without this flag the second SetIndex would re-check the value -// just before serializing it into the container, and that re-check would -// fire on values whose source slab was drained as part of the first -// SetIndex's eviction-cleanup — a pre-existing reference-corruption -// hazard in the non-resource swap path that is not in scope here. type InstructionSetIndex struct { - IndexedType uint16 - SkipValueStalenessCheck bool + IndexedType uint16 } var _ Instruction = InstructionSetIndex{} @@ -689,8 +678,6 @@ func (i InstructionSetIndex) String() string { func (i InstructionSetIndex) OperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfArgument(sb, "indexedType", i.IndexedType, colorize) - sb.WriteByte(' ') - printfArgument(sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, colorize) } func (i InstructionSetIndex) ResolvedOperandsString(sb *strings.Builder, @@ -698,26 +685,21 @@ func (i InstructionSetIndex) ResolvedOperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfTypeArgument(sb, "indexedType", program.GetTypes()[i.IndexedType], colorize) - sb.WriteByte(' ') - printfArgument(sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, colorize) } func (i InstructionSetIndex) Encode(code *[]byte) { emitOpcode(code, i.Opcode()) emitUint16(code, i.IndexedType) - emitBool(code, i.SkipValueStalenessCheck) } func DecodeSetIndex(ip *uint16, code []byte) (i InstructionSetIndex) { i.IndexedType = decodeUint16(ip, code) - i.SkipValueStalenessCheck = decodeBool(ip, code) return i } func (i InstructionSetIndex) Pretty(program ProgramForInstructions) PrettyInstruction { return PrettyInstructionSetIndex{ - IndexedType: program.GetTypes()[i.IndexedType], - SkipValueStalenessCheck: i.SkipValueStalenessCheck, + IndexedType: program.GetTypes()[i.IndexedType], } } diff --git a/bbq/opcode/instructions.yml b/bbq/opcode/instructions.yml index 83a1ca7c1d..0b1913da03 100644 --- a/bbq/opcode/instructions.yml +++ b/bbq/opcode/instructions.yml @@ -181,21 +181,9 @@ description: | Pops three values off the stack, the array, the index, and the value, and then sets the value at the given index of the array to the value. - If skipValueStalenessCheck is true, the staleness re-check on the value - being set is suppressed; the container and index are still validated. - Set by the swap-statement compiler, which matches the interpreter's - timing by validating both swap operands once before the transfers and - then performing the two sets without further per-instruction staleness - checks. Without this flag the second SetIndex would re-check the value - just before serializing it into the container, and that re-check would - fire on values whose source slab was drained as part of the first - SetIndex's eviction-cleanup — a pre-existing reference-corruption - hazard in the non-resource swap path that is not in scope here. operands: - name: "indexedType" type: "typeIndex" - - name: "skipValueStalenessCheck" - type: "bool" valueEffects: pop: - name: "array" diff --git a/bbq/opcode/pretty_instructions.go b/bbq/opcode/pretty_instructions.go index 53d10d2535..addf378614 100644 --- a/bbq/opcode/pretty_instructions.go +++ b/bbq/opcode/pretty_instructions.go @@ -314,19 +314,8 @@ func (i PrettyInstructionRemoveIndex) String() string { // Pretty form of InstructionSetIndex with resolved operands. // Pops three values off the stack, the array, the index, and the value, // and then sets the value at the given index of the array to the value. -// If skipValueStalenessCheck is true, the staleness re-check on the value -// being set is suppressed; the container and index are still validated. -// Set by the swap-statement compiler, which matches the interpreter's -// timing by validating both swap operands once before the transfers and -// then performing the two sets without further per-instruction staleness -// checks. Without this flag the second SetIndex would re-check the value -// just before serializing it into the container, and that re-check would -// fire on values whose source slab was drained as part of the first -// SetIndex's eviction-cleanup — a pre-existing reference-corruption -// hazard in the non-resource swap path that is not in scope here. type PrettyInstructionSetIndex struct { - IndexedType interpreter.StaticType - SkipValueStalenessCheck bool + IndexedType interpreter.StaticType } var _ PrettyInstruction = PrettyInstructionSetIndex{} @@ -340,8 +329,6 @@ func (i PrettyInstructionSetIndex) String() string { sb.WriteString(i.Opcode().String()) sb.WriteByte(' ') printfTypeArgument(&sb, "indexedType", i.IndexedType, false) - sb.WriteByte(' ') - printfArgument(&sb, "skipValueStalenessCheck", i.SkipValueStalenessCheck, false) return sb.String() } diff --git a/bbq/opcode/print_test.go b/bbq/opcode/print_test.go index 3964e0f98e..5a925b8aea 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -277,7 +277,7 @@ func TestPrintInstruction(t *testing.T) { "SetTypeIndex indexedType:258 indexingType:258": {byte(SetTypeIndex), 1, 2, 1, 2}, "RemoveTypeIndex indexedType:258 indexingType:258": {byte(RemoveTypeIndex), 1, 2, 1, 2}, "SetAttachmentBase": {byte(SetAttachmentBase), 1, 2, 3}, - "SetIndex indexedType:258 skipValueStalenessCheck:true": {byte(SetIndex), 1, 2, 1}, + "SetIndex indexedType:258": {byte(SetIndex), 1, 2}, "GetIndex indexedType:258": {byte(GetIndex), 1, 2}, "RemoveIndex indexedType:258 pushPlaceholder:true": {byte(RemoveIndex), 1, 2, 1}, "Drop": {byte(Drop)}, diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index ed8f007061..7903cebb06 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -131,22 +131,6 @@ func (vm *VM) pop3() (Value, Value, Value) { return value1, value2, value3 } -// pop3SkipValueStalenessCheck is pop3 with the staleness check on the -// third (top-of-stack) value suppressed. The container and index are still -// validated. Used by SetIndex with SkipValueStalenessCheck=true (swap path). -func (vm *VM) pop3SkipValueStalenessCheck() (Value, Value, Value) { - lastIndex := len(vm.stack) - 1 - value1, value2, value3 := vm.stack[lastIndex-2], vm.stack[lastIndex-1], vm.stack[lastIndex] - vm.stack[lastIndex-2], vm.stack[lastIndex-1], vm.stack[lastIndex] = nil, nil, nil - vm.stack = vm.stack[:lastIndex-2] - - context := vm.context - interpreter.CheckInvalidatedValueOrValueReference(value1, context) - interpreter.CheckInvalidatedValueOrValueReference(value2, context) - - return value1, value2, value3 -} - func (vm *VM) peekN(count int) []Value { stackHeight := len(vm.stack) startIndex := stackHeight - count @@ -928,17 +912,7 @@ func opSetGlobal(vm *VM, ins opcode.InstructionSetGlobal) { } func opSetIndex(vm *VM, ins opcode.InstructionSetIndex) { - var container, index, value interpreter.Value - if ins.SkipValueStalenessCheck { - // Swap-statement emit: the value was already validated when it was - // originally read at the start of the swap. Re-checking here would - // fire on values whose source slab was drained by the FIRST set's - // eviction-cleanup — same timing as the interpreter's swap, which - // only checks the operands once before the transfers. - container, index, value = vm.pop3SkipValueStalenessCheck() - } else { - container, index, value = vm.pop3() - } + container, index, value := vm.pop3() indexableValue := container.(interpreter.ValueIndexableValue) checkIndexedType( diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index a6477edad6..233db8aa1c 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -717,7 +717,17 @@ func (interpreter *Interpreter) VisitSwapStatement(swap *ast.SwapStatement) Stat CheckInvalidatedValueOrValueReference(leftValue, interpreter) transferredLeftValue := TransferAndConvert(interpreter, leftValue, leftType, rightType) + // Re-check before each set, matching the VM's per-instruction pop3 + // staleness check. The first set's atree eviction-cleanup can drain + // the source slab of a reference-typed swap operand that is still + // referenced by the second set's value (a pre-existing reference- + // corruption hazard in the non-resource through-reference swap path). + // Failing here surfaces the issue in both runtimes; without it the + // interpreter would silently corrupt the second slot. + CheckInvalidatedValueOrValueReference(transferredRightValue, interpreter) leftGetterSetter.set(transferredRightValue) + + CheckInvalidatedValueOrValueReference(transferredLeftValue, interpreter) rightGetterSetter.set(transferredLeftValue) } diff --git a/interpreter/member_test.go b/interpreter/member_test.go index 615fdd0a39..6d4fbef494 100644 --- a/interpreter/member_test.go +++ b/interpreter/member_test.go @@ -1285,6 +1285,17 @@ func TestInterpretMemberAccess(t *testing.T) { t.Run("anystruct swap on reference", func(t *testing.T) { t.Parallel() + // Non-resource through-reference swap currently surfaces a latent + // data-corruption hazard: the first atree.Set evicts the slab held + // by the second swap operand (which is a reference to the original + // inlined value), so the second set would serialize from a drained + // slab. Both runtimes now consistently raise + // InvalidatedContainerViewError at the second set rather than + // silently corrupting the dictionary. Fixing this properly requires + // extract-then-write swap semantics for non-resource targets, which + // in turn needs sema changes (the swap-statement type for through- + // reference access is currently a reference type, incompatible with + // the extracted underlying value). Tracked as a follow-up. inter := parseCheckAndPrepare(t, ` struct Foo { var array: [Int] @@ -1302,7 +1313,8 @@ func TestInterpretMemberAccess(t *testing.T) { `) _, err := inter.Invoke("test") - require.NoError(t, err) + var staleViewErr *interpreter.InvalidatedContainerViewError + require.ErrorAs(t, err, &staleViewErr) }) t.Run("entitlement map access on field", func(t *testing.T) { From 63ec811a4a87573968729187646e97fdbc7bcc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 29 May 2026 17:22:18 -0700 Subject: [PATCH 073/139] improve swapping --- bbq/compiler/compiler_test.go | 101 +++++++++++++++++++--------------- interpreter/member_test.go | 22 +++----- sema/check_swap.go | 44 ++++++++++++--- 3 files changed, 101 insertions(+), 66 deletions(-) diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index bf57feba2c..5b276f6981 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -10697,12 +10697,13 @@ func TestCompileSwapIndexInStructs(t *testing.T) { const ( charsIndex = iota - tempIndex1 - tempIndex2 - tempIndex3 - tempIndex4 - tempIndex5 - tempIndex6 + leftTargetIndex + leftIndexIndex + rightTargetIndex + rightIndexIndex + leftInsertedPlaceholderIndex + leftValueIndex + rightValueIndex ) arrayType := &interpreter.VariableSizedStaticType{ @@ -10745,7 +10746,7 @@ func TestCompileSwapIndexInStructs(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: charsIndex}, opcode.PrettyInstructionSetLocal{ - Local: tempIndex1, + Local: leftTargetIndex, IsTempVar: true, }, @@ -10760,13 +10761,13 @@ func TestCompileSwapIndexInStructs(t *testing.T) { TargetType: interpreter.PrimitiveStaticTypeInteger, }, opcode.PrettyInstructionSetLocal{ - Local: tempIndex2, + Local: leftIndexIndex, IsTempVar: true, }, opcode.PrettyInstructionGetLocal{Local: charsIndex}, opcode.PrettyInstructionSetLocal{ - Local: tempIndex3, + Local: rightTargetIndex, IsTempVar: true, }, @@ -10781,70 +10782,82 @@ func TestCompileSwapIndexInStructs(t *testing.T) { TargetType: interpreter.PrimitiveStaticTypeInteger, }, opcode.PrettyInstructionSetLocal{ - Local: tempIndex4, + Local: rightIndexIndex, IsTempVar: true, }, - // get left value - opcode.PrettyInstructionGetLocal{Local: tempIndex1}, - opcode.PrettyInstructionGetLocal{Local: tempIndex2}, - opcode.PrettyInstructionGetIndex{ - IndexedType: &interpreter.VariableSizedStaticType{ - Type: interpreter.PrimitiveStaticTypeString, - }, + // get left value via extract-then-write: RemoveIndex with placeholder. + opcode.PrettyInstructionGetLocal{Local: leftTargetIndex}, + opcode.PrettyInstructionGetLocal{Local: leftIndexIndex}, + opcode.PrettyInstructionRemoveIndex{ + IndexedType: arrayType, + PushPlaceholder: true, }, + opcode.PrettyInstructionSetLocal{Local: leftInsertedPlaceholderIndex}, opcode.PrettyInstructionSetLocal{ - Local: tempIndex5, + Local: leftValueIndex, IsTempVar: true, }, - // get right value - opcode.PrettyInstructionGetLocal{Local: tempIndex3}, - opcode.PrettyInstructionGetLocal{Local: tempIndex4}, - opcode.PrettyInstructionGetIndex{ - IndexedType: &interpreter.VariableSizedStaticType{ - Type: interpreter.PrimitiveStaticTypeString, - }, + // get right value via extract: RemoveIndex (no placeholder needed). + opcode.PrettyInstructionGetLocal{Local: rightTargetIndex}, + opcode.PrettyInstructionGetLocal{Local: rightIndexIndex}, + opcode.PrettyInstructionRemoveIndex{ + IndexedType: arrayType, + PushPlaceholder: false, }, opcode.PrettyInstructionSetLocal{ - Local: tempIndex6, + Local: rightValueIndex, IsTempVar: true, }, + // compare right value and left inserted placeholder + opcode.PrettyInstructionGetLocal{Local: rightValueIndex}, + opcode.PrettyInstructionGetLocal{Local: leftInsertedPlaceholderIndex}, + opcode.PrettyInstructionSame{}, + opcode.PrettyInstructionJumpIfFalse{Target: 37}, + + // set left index back with left value (swap-with-itself short-circuit) + opcode.PrettyInstructionGetLocal{Local: leftTargetIndex}, + opcode.PrettyInstructionGetLocal{Local: leftIndexIndex}, + opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, + opcode.PrettyInstructionSetIndex{ + IndexedType: arrayType, + }, + + // jump to the end + opcode.PrettyInstructionJump{Target: 51}, + // convert right value to left type - opcode.PrettyInstructionGetLocal{Local: tempIndex6}, + opcode.PrettyInstructionGetLocal{Local: rightValueIndex}, opcode.PrettyInstructionTransferAndConvert{ ValueType: interpreter.PrimitiveStaticTypeString, TargetType: interpreter.PrimitiveStaticTypeString, }, - opcode.PrettyInstructionSetLocal{Local: tempIndex6}, + opcode.PrettyInstructionSetLocal{Local: rightValueIndex}, // convert left value to right type - opcode.PrettyInstructionGetLocal{Local: tempIndex5}, + opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionTransferAndConvert{ ValueType: interpreter.PrimitiveStaticTypeString, TargetType: interpreter.PrimitiveStaticTypeString, }, - opcode.PrettyInstructionSetLocal{Local: tempIndex5}, + opcode.PrettyInstructionSetLocal{Local: leftValueIndex}, - // set right index with left value - opcode.PrettyInstructionGetLocal{Local: tempIndex1}, - opcode.PrettyInstructionGetLocal{Local: tempIndex2}, - opcode.PrettyInstructionGetLocal{Local: tempIndex6}, + // set left index with right value + opcode.PrettyInstructionGetLocal{Local: leftTargetIndex}, + opcode.PrettyInstructionGetLocal{Local: leftIndexIndex}, + opcode.PrettyInstructionGetLocal{Local: rightValueIndex}, opcode.PrettyInstructionSetIndex{ - IndexedType: &interpreter.VariableSizedStaticType{ - Type: interpreter.PrimitiveStaticTypeString, - }, + IndexedType: arrayType, }, - // set left index with right value - opcode.PrettyInstructionGetLocal{Local: tempIndex3}, - opcode.PrettyInstructionGetLocal{Local: tempIndex4}, - opcode.PrettyInstructionGetLocal{Local: tempIndex5}, + // set right index with left value + opcode.PrettyInstructionGetLocal{Local: rightTargetIndex}, + opcode.PrettyInstructionGetLocal{Local: rightIndexIndex}, + opcode.PrettyInstructionGetLocal{Local: leftValueIndex}, opcode.PrettyInstructionSetIndex{ - IndexedType: &interpreter.VariableSizedStaticType{ - Type: interpreter.PrimitiveStaticTypeString, - }, + IndexedType: arrayType, }, // Return diff --git a/interpreter/member_test.go b/interpreter/member_test.go index 6d4fbef494..437d9519fe 100644 --- a/interpreter/member_test.go +++ b/interpreter/member_test.go @@ -1285,17 +1285,14 @@ func TestInterpretMemberAccess(t *testing.T) { t.Run("anystruct swap on reference", func(t *testing.T) { t.Parallel() - // Non-resource through-reference swap currently surfaces a latent - // data-corruption hazard: the first atree.Set evicts the slab held - // by the second swap operand (which is a reference to the original - // inlined value), so the second set would serialize from a drained - // slab. Both runtimes now consistently raise - // InvalidatedContainerViewError at the second set rather than - // silently corrupting the dictionary. Fixing this properly requires - // extract-then-write swap semantics for non-resource targets, which - // in turn needs sema changes (the swap-statement type for through- - // reference access is currently a reference type, incompatible with - // the extracted underlying value). Tracked as a follow-up. + // Non-resource through-reference swap uses extract-then-write + // semantics: both operands are removed from the dictionary via + // RemoveKey+InsertPlaceholder before either set, so the second + // set does not evict a slab still referenced by the first + // operand's view. The swap-statement types are recorded as the + // target (value) types, not the cascaded reference types, so + // TransferAndConvert's defensive type check accepts the + // extracted underlying values. See sema/check_swap.go. inter := parseCheckAndPrepare(t, ` struct Foo { var array: [Int] @@ -1313,8 +1310,7 @@ func TestInterpretMemberAccess(t *testing.T) { `) _, err := inter.Invoke("test") - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) + require.NoError(t, err) }) t.Run("entitlement map access on field", func(t *testing.T) { diff --git a/sema/check_swap.go b/sema/check_swap.go index 9930e4be2b..2f3282669d 100644 --- a/sema/check_swap.go +++ b/sema/check_swap.go @@ -31,28 +31,54 @@ func (checker *Checker) VisitSwapStatement(swap *ast.SwapStatement) (_ struct{}) // Then re-visit the same expressions, this time treat them as the value-expr of the assignment. // The 'expected type' of the two expression would be the types obtained from the previous visit, swapped. - leftValueType := checker.VisitExpression(swap.Left, swap, rightTargetType) - rightValueType := checker.VisitExpression(swap.Right, swap, leftTargetType) + checker.VisitExpression(swap.Left, swap, rightTargetType) + checker.VisitExpression(swap.Right, swap, leftTargetType) checker.enforceViewAssignment(swap, swap.Left) checker.enforceViewAssignment(swap, swap.Right) + // Record the target types (rather than the value-expression types) for + // runtime use. Swap is semantically a value-exchange operation: the + // runtime extracts each operand from its container and writes it to the + // other operand's slot. Recording the target types here lets the + // runtime apply extract-then-write uniformly — even when the operand + // expression's read-context type is a cascaded reference (e.g., + // `dictRef[k]` where dictRef is `&{String: AnyStruct}` reads as + // `&AnyStruct?`). The extracted underlying value matches the target + // type, so TransferAndConvert's defensive type check passes. checker.Elaboration.SetSwapStatementTypes( swap, SwapStatementTypes{ - LeftType: leftValueType, - RightType: rightValueType, + LeftType: leftTargetType, + RightType: rightTargetType, }, ) - // Record as a resource move (when applicable), - // because in destructors, nested resource moves are allowed. - - if leftValueType.IsResourceType() { + // Mark IndexExpression swap operands as nested resource moves + // unconditionally so the runtime uses extract-then-write semantics + // (RemoveIndex + placeholder) for them. This eliminates a corruption + // hazard in the non-resource through-reference IndexExpression swap + // path: read-via-reference returns a wrapper into the container's + // inlined slab, and the second set's atree eviction-cleanup would + // drain that slab while the first operand still holds a view of it. + // + // MemberExpression operands are not affected here: CompositeValue + // fields are stored at fixed slab offsets and a SetField does not + // trigger an eviction-cleanup that could drain a sibling field's + // view. RemoveField for non-resource fields would also be ill-defined + // (no nil sentinel for required non-optional fields). Resource-typed + // MemberExpression operands are still marked below by the existing + // resource-aware path. + + if _, ok := swap.Left.(*ast.IndexExpression); ok { + checker.elaborateNestedResourceMoveExpression(swap.Left) + } else if leftTargetType.IsResourceType() { checker.elaborateNestedResourceMoveExpression(swap.Left) } - if rightValueType.IsResourceType() { + if _, ok := swap.Right.(*ast.IndexExpression); ok { + checker.elaborateNestedResourceMoveExpression(swap.Right) + } else if rightTargetType.IsResourceType() { checker.elaborateNestedResourceMoveExpression(swap.Right) } From 719c3646da9e1ce71573ef8c60ff0dfe4cf144e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 11:00:38 -0700 Subject: [PATCH 074/139] add support for using internal atree --- .github/workflows/benchmark.yml | 6 ++++ .github/workflows/ci.yml | 36 +++++++++++++++++++ .github/workflows/codeql-analysis.yml | 6 ++++ .github/workflows/compat.yaml | 9 +++++ .../compatibility-check-template.yml | 6 ++++ .github/workflows/downstream.yml | 12 +++++++ actions/private-setup/action.yml | 27 ++++++++++++++ 7 files changed, 102 insertions(+) create mode 100644 actions/private-setup/action.yml diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 1d046f2257..7f98ff7f8f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -27,6 +27,12 @@ jobs: with: fetch-depth: 0 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Detect changed components uses: dorny/paths-filter@v3 id: changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faeb34f1ef..2ad15b2fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: - name: Clone uses: actions/checkout@v6 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -43,6 +49,12 @@ jobs: - name: Clone uses: actions/checkout@v6 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -62,6 +74,12 @@ jobs: - name: Clone uses: actions/checkout@v6 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -84,6 +102,12 @@ jobs: - name: Clone uses: actions/checkout@v6 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -104,6 +128,12 @@ jobs: # required to update the embedded version during code generation fetch-depth: 0 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -145,5 +175,11 @@ jobs: - name: Clone uses: actions/checkout@v6 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: semgrep ci run: semgrep ci --config semgrep.yaml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0ba2f42694..3d36c364fc 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,6 +35,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/compat.yaml b/.github/workflows/compat.yaml index 808e80ffe5..2cfc7ce36f 100644 --- a/.github/workflows/compat.yaml +++ b/.github/workflows/compat.yaml @@ -11,14 +11,23 @@ jobs: steps: - name: Clone uses: actions/checkout@v6 + + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' + - name: Install dependencies working-directory: compat run: pip3 install -r requirements.txt + - name: Run working-directory: compat run: "python3 main.py" diff --git a/.github/workflows/compatibility-check-template.yml b/.github/workflows/compatibility-check-template.yml index 50e6fd5984..676a305df9 100644 --- a/.github/workflows/compatibility-check-template.yml +++ b/.github/workflows/compatibility-check-template.yml @@ -33,6 +33,12 @@ jobs: with: fetch-depth: 0 + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - uses: actions/setup-go@v6 with: go-version-file: ./tools/compatibility-check/go.mod diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index b640aac142..e44f8ab0de 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -26,6 +26,12 @@ jobs: with: repository: 'onflow/flow-go' + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: @@ -50,6 +56,12 @@ jobs: with: repository: 'onflow/flow-emulator' + - name: Setup private build environment + if: ${{ vars.PRIVATE_BUILDS_SUPPORTED == 'true' }} + uses: ./actions/private-setup + with: + atree_deploy_key: ${{ secrets.ATREE_DEPLOY_KEY }} + - name: Setup Go uses: actions/setup-go@v6 with: diff --git a/actions/private-setup/action.yml b/actions/private-setup/action.yml new file mode 100644 index 0000000000..3edd5c94e7 --- /dev/null +++ b/actions/private-setup/action.yml @@ -0,0 +1,27 @@ +name: "Private Build Setup" +description: "Checks and configures the environment for building private dependencies" +inputs: + atree_deploy_key: + description: "Deploy Key for Private Atree Repo" + required: true + go_private_value: + description: "The value for GOPRIVATE" + required: false + default: "github.com/onflow/*-internal" +runs: + using: "composite" + steps: + - name: Load atree deploy key + uses: webfactory/ssh-agent@v0.10.0 + with: + ssh-private-key: "${{ inputs.atree_deploy_key }}" + + - name: Configure git for SSH + shell: bash + run: | + git config --global url."git@github.com:".insteadOf "https://github.com/" + + - name: Configure GOPRIVATE env + shell: bash + run: | + echo "GOPRIVATE=${{ inputs.go_private_value }}" >> $GITHUB_ENV From a4f8bf7681e8e041e5adbfaf03c5ae73b7a90dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 11:12:46 -0700 Subject: [PATCH 075/139] switch to internal atree --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 5edda1b5f2..e60e1eac11 100644 --- a/go.mod +++ b/go.mod @@ -62,3 +62,5 @@ require ( gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 diff --git a/go.sum b/go.sum index 28dbf96f6e..67ea95216d 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU= -github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 h1:mXufEy0+qiXbomVTXnvYz0LiPaO0SuHjDDlgc8KD25g= +github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 310afba48e45cdf25dc3c3e2692d4772d55098ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 11:23:45 -0700 Subject: [PATCH 076/139] fix backward compatibility check: pass atree deploy key --- .github/workflows/compatibility-check-template.yml | 1 + .github/workflows/compatibility-check.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/compatibility-check-template.yml b/.github/workflows/compatibility-check-template.yml index 676a305df9..1b0b16f77d 100644 --- a/.github/workflows/compatibility-check-template.yml +++ b/.github/workflows/compatibility-check-template.yml @@ -19,6 +19,7 @@ on: secrets: FIND_API_AUTH: required: true + ATREE_DEPLOY_KEY: concurrency: group: ${{ github.workflow }}-${{ inputs.base-branch || github.run_id }}-${{ inputs.chain }} diff --git a/.github/workflows/compatibility-check.yml b/.github/workflows/compatibility-check.yml index 0b6e3aa2d8..4ffe2407d5 100644 --- a/.github/workflows/compatibility-check.yml +++ b/.github/workflows/compatibility-check.yml @@ -66,6 +66,7 @@ jobs: chain: mainnet secrets: FIND_API_AUTH: ${{ secrets.FIND_API_AUTH }} + ATREE_DEPLOY_KEY: ${{ secrets.ATREE_DEPLOY_KEY }} testnet: needs: setup @@ -77,3 +78,4 @@ jobs: chain: testnet secrets: FIND_API_AUTH: ${{ secrets.FIND_API_AUTH }} + ATREE_DEPLOY_KEY: ${{ secrets.ATREE_DEPLOY_KEY }} From a3a17dd575edf1f17b3054e137e57521c036b8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 11:39:26 -0700 Subject: [PATCH 077/139] fix GOPRIVATE --- actions/private-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/private-setup/action.yml b/actions/private-setup/action.yml index 3edd5c94e7..821581a110 100644 --- a/actions/private-setup/action.yml +++ b/actions/private-setup/action.yml @@ -7,7 +7,7 @@ inputs: go_private_value: description: "The value for GOPRIVATE" required: false - default: "github.com/onflow/*-internal" + default: "github.com/onflow/atree-internal" runs: using: "composite" steps: From 49990727eed970ba4a94aa196eef4bf5ecd2ec18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 11:56:59 -0700 Subject: [PATCH 078/139] try to fix compatibility check --- .github/workflows/compatibility-check-template.yml | 4 ++-- actions/private-setup/action.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compatibility-check-template.yml b/.github/workflows/compatibility-check-template.yml index 1b0b16f77d..6a2833ebb1 100644 --- a/.github/workflows/compatibility-check-template.yml +++ b/.github/workflows/compatibility-check-template.yml @@ -82,8 +82,8 @@ jobs: - name: Configure permissions if: github.repository != 'onflow/cadence' run: | - echo "GOPRIVATE=github.com/${{ inputs.repo }}" >> "$GITHUB_ENV" - git config --global url."https://${{ github.actor }}:${{ github.token }}@github.com".insteadOf "https://github.com" + echo "GOPRIVATE=github.com/${{ inputs.repo }},github.com/onflow/*-internal" >> "$GITHUB_ENV" + git config --global url."https://${{ github.actor }}:${{ github.token }}@github.com/${{ inputs.repo }}".insteadOf "https://github.com/${{ inputs.repo }}" # Check contracts using current branch diff --git a/actions/private-setup/action.yml b/actions/private-setup/action.yml index 821581a110..3edd5c94e7 100644 --- a/actions/private-setup/action.yml +++ b/actions/private-setup/action.yml @@ -7,7 +7,7 @@ inputs: go_private_value: description: "The value for GOPRIVATE" required: false - default: "github.com/onflow/atree-internal" + default: "github.com/onflow/*-internal" runs: using: "composite" steps: From 853317a152a35c312994229ece7fe0e31f30eb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 13:13:53 -0700 Subject: [PATCH 079/139] also use internal atree in compatibility check tool --- tools/compatibility-check/go.mod | 2 ++ tools/compatibility-check/go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index c7c7af6dc0..cf7903c76b 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -89,3 +89,5 @@ require ( ) replace github.com/onflow/cadence => ../../ + +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index a9873b55a0..a138adc696 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU= -github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 h1:mXufEy0+qiXbomVTXnvYz0LiPaO0SuHjDDlgc8KD25g= +github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From d7d5ba8d9355d3a54c5b428e700e8f9b841a0b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 16:37:52 -0700 Subject: [PATCH 080/139] update to internal atree with shared state refactor --- cmd/decode-state-values/main.go | 14 ++++++++++++-- go.mod | 2 ++ go.sum | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/decode-state-values/main.go b/cmd/decode-state-values/main.go index 15a7dcf9db..a0fdb34724 100644 --- a/cmd/decode-state-values/main.go +++ b/cmd/decode-state-values/main.go @@ -128,10 +128,20 @@ func slabIDToStorageKey(id atree.SlabID) storageKey { // slabStorage -type slabStorage struct{} +type slabStorage struct { + // Provides the four state-registry methods atree requires. + // This binary is a one-shot decoder, so the in-memory registry + // lives for the duration of the process and doesn't need explicit + // cleanup. + *atree.BaseStateRegistry +} var _ atree.SlabStorage = &slabStorage{} +func newSlabStorage() *slabStorage { + return &slabStorage{BaseStateRegistry: atree.NewBaseStateRegistry()} +} + func (s *slabStorage) Retrieve(id atree.SlabID) (atree.Slab, bool, error) { data, ok := storage[slabIDToStorageKey(id)] if !ok { @@ -254,7 +264,7 @@ func load() { log.Println("Validating slabs ...") - slabStorage := &slabStorage{} + slabStorage := newSlabStorage() if *checkSlabsFlag { _, err := atree.CheckStorageHealth(slabStorage, -1) diff --git a/go.mod b/go.mod index 5edda1b5f2..9975172ffc 100644 --- a/go.mod +++ b/go.mod @@ -62,3 +62,5 @@ require ( gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774 diff --git a/go.sum b/go.sum index 28dbf96f6e..a9597d2e44 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree v0.16.0 h1:b+f/suzcnnr1Lx1KdJEjpn2CX+AKSAz1yIB30NQDutU= -github.com/onflow/atree v0.16.0/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774 h1:yEf0N7J8DCMC8wgZQcpFkQ/XAPawja/4A3yr3ANcz4g= +github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 851cd911ed6ec7a1e21957992a09def5b1e00516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 16:39:38 -0700 Subject: [PATCH 081/139] add more tests --- interpreter/array_test.go | 172 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 954f634cf1..342f56ab1a 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -961,3 +961,175 @@ func TestInterpretOptionalContainerAliasingViaDictionaryLookup(t *testing.T) { _, err = inter.Invoke("main") require.NoError(t, err) } + +// TestInterpretArrayPromoteRootAliasingConsistency exercises the dual +// of the split-induced aliasing scenario: enough removals through one +// aliased reference to drive atree's `promoteChildAsNewRoot` (which +// fires when a meta-slab root shrinks to a single child). The +// previously-identified gap was that a per-Cadence-wrapper slab-ID +// check (cached `valueID` vs live `array.ValueID()`) does NOT detect +// promote — promoteChildAsNewRoot keeps the root slab ID stable. With +// canonicalization, both refs share one Cadence wrapper whose `.array` +// is kept in sync, so the gap does not manifest at the user-visible +// level. This test pins that behavior. +func TestInterpretArrayPromoteRootAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + // Build an inner resource array large enough that the atree + // tree has multiple data slabs under a meta-slab root. + let outer: @[[Vault]] <- [<-[]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + // Fill: triggers splitRoot at some threshold. + var i: Int = 0 + while i < 400 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + assert(ref.length == 400, message: "after fill: length must be 400") + assert(ref2.length == 400, message: "after fill: ref2 must observe same length") + + // Drain via ref2 until only one element remains: this drives + // the meta-slab root to one-child state, triggering + // promoteChildAsNewRoot. + while ref2.length > 1 { + let v <- ref2.removeLast() + destroy v + } + assert(ref.length == 1, message: "after drain: ref must see promoted length") + assert(ref2.length == 1, message: "after drain: ref2 must see promoted length") + + // Cross-mutate again: append via ref, observe via ref2. + ref.append(<-create Vault(balance: 999.0)) + assert(ref2.length == 2, message: "post-promote append must be visible to alias") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + +// TestInterpretArraySplitAndPromoteAliasingConsistency drives both +// structural transitions through different aliased references in one +// run: grow via ref1 until splitRoot, shrink via ref2 until +// promoteChildAsNewRoot, then grow again. At every step both +// references must observe identical state through the canonical +// wrapper. +func TestInterpretArraySplitAndPromoteAliasingConsistency(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(logFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]] <- [<-[]] + + let ref = &outer[0] as auth(Mutate) &[Vault] + let ref2 = &outer[0] as auth(Mutate) &[Vault] + + // Phase 1: split via ref1. + var i: Int = 0 + while i < 300 { + ref.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + assert(ref2.length == 300, message: "phase 1: ref2 must see split-grown length") + + // Phase 2: promote via ref2. + while ref2.length > 1 { + let v <- ref2.removeLast() + destroy v + } + assert(ref.length == 1, message: "phase 2: ref must see promoted length") + + // Phase 3: grow again via ref1, observe via ref2. + i = 0 + while i < 50 { + ref.append(<-create Vault(balance: UFix64(i + 1000))) + i = i + 1 + } + assert(ref2.length == 51, message: "phase 3: ref2 must see post-promote growth") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} From e16792c610f79a09fd317a1ad867d5c1db70e9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 17:01:19 -0700 Subject: [PATCH 082/139] revert --- interpreter/storage.go | 16 ------ interpreter/value_array.go | 110 ++++++------------------------------- 2 files changed, 17 insertions(+), 109 deletions(-) diff --git a/interpreter/storage.go b/interpreter/storage.go index ecb44ef6f4..d9f47be470 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -119,22 +119,6 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value } cache.SetCanonicalAtreeContainer(v.valueID, v) return v - case *SomeValue: - // An optional wrapping a container must canonicalize its inner so - // that aliased references see a shared wrapper. SomeStorable. - // StoredValue produces a fresh SomeValue per load whose inner is - // built via the non-canonicalizing StoredValue path (it has no - // access to the current context's cache); re-canonicalize the - // inner here so the SomeValue we return contains the canonical - // wrapper. The recursion also handles nested optionals (T??, ...). - if v.value == nil { - return fresh - } - canonicalized := canonicalizeContainerElement(cache, v.value) - if canonicalized != v.value { - v.value = canonicalized - } - return v } return fresh } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index cad4c8888f..f4c81b6334 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -461,25 +461,14 @@ func (v *ArrayValue) Concat( resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) - // In the asReference branch each yielded inner is wrapped in an - // EphemeralReferenceValue stored in the result via NonStorable; its - // wrapper must be canonicalized, which requires the iterator to yield - // mutable inner instances (parentUpdater wired). - var firstIterator, secondIterator atree.ArrayIterator - var err error - if asReference { - firstIterator, err = v.array.Iterator() - } else { - firstIterator, err = v.array.ReadOnlyIterator() - } + // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. + firstIterator, err := v.array.ReadOnlyIterator() if err != nil { panic(errors.NewExternalError(err)) } - if asReference { - secondIterator, err = other.array.Iterator() - } else { - secondIterator, err = other.array.ReadOnlyIterator() - } + + // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. + secondIterator, err := other.array.ReadOnlyIterator() if err != nil { panic(errors.NewExternalError(err)) } @@ -513,8 +502,6 @@ func (v *ArrayValue) Concat( if atreeValue == nil { first = false - } else if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) } else { value = MustConvertStoredValue(context, atreeValue) } @@ -529,11 +516,7 @@ func (v *ArrayValue) Concat( } if atreeValue != nil { - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value = MustConvertStoredValue(context, atreeValue) checkContainerMutation(context, otherElementType, value) } @@ -543,15 +526,6 @@ func (v *ArrayValue) Concat( return nil } - // NOTE: the asReference branch below wraps the freshly-loaded - // wrapper in an EphemeralReferenceValue stored in the result - // array via NonStorable. The wrapper is transient and not - // canonicalized; an external alias to the same source element - // would observe diverging state after a split. See - // TestInterpretArraySliceDoesNotInvalidateAliases for the - // non-asReference safety property this code already maintains; - // the asReference aliasing fix requires switching to a non- - // read-only iterator and is tracked separately. if asReference { value = getReferenceValue(context, value, resultElementSemaType) } @@ -1804,19 +1778,8 @@ func (v *ArrayValue) Slice( elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) - // In the asReference branch, each yielded inner is wrapped in an - // EphemeralReferenceValue that is stored in the result via NonStorable - // and outlives this call. The reference's wrapper must therefore be - // canonicalized, which requires the iterator to yield mutable inner - // instances (parentUpdater wired) - ReadOnly iterator yields - // trap-callback instances that would downgrade the canonical wrapper. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.RangeIterator(uint64(fromIndex), uint64(toIndex)) - } else { - iterator, err = v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) - } + // Use ReadOnlyRangeIterator here because new ArrayValue is created from elements copied (not removed) from original ArrayValue. + iterator, err := v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) if err != nil { var sliceOutOfBoundsError *atree.SliceOutOfBoundsError @@ -1865,11 +1828,7 @@ func (v *ArrayValue) Slice( var value Value if atreeValue != nil { - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value = MustConvertStoredValue(context, atreeValue) } if value == nil { @@ -2001,11 +1960,7 @@ func (v *ArrayValue) Filter( return nil } - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value = MustConvertStoredValue(context, atreeValue) if value == nil { return nil } @@ -2120,12 +2075,7 @@ func (v *ArrayValue) Map( return nil } - var value Value - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value := MustConvertStoredValue(context, atreeValue) if asReference { value = getReferenceValue( context, @@ -2186,16 +2136,8 @@ func (v *ArrayValue) ToVariableSized( // Convert the array to a variable-sized array. - // asReference path keeps the wrapper alive in a reference stored in - // the result; canonicalize and use a mutable iterator. Otherwise the - // element is Transfer'd and the read-only iterator suffices. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.Iterator() - } else { - iterator, err = v.array.ReadOnlyIterator() - } + // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. + iterator, err := v.array.ReadOnlyIterator() if err != nil { panic(errors.NewExternalError(err)) } @@ -2226,12 +2168,7 @@ func (v *ArrayValue) ToVariableSized( return nil } - var value Value - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value := MustConvertStoredValue(context, atreeValue) if asReference { value = getReferenceValue(context, value, elementType) @@ -2282,16 +2219,8 @@ func (v *ArrayValue) ToConstantSized( // Convert the array to a constant-sized array. - // asReference path keeps the wrapper alive in a reference stored in - // the result; canonicalize and use a mutable iterator. Otherwise the - // element is Transfer'd and the read-only iterator suffices. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.Iterator() - } else { - iterator, err = v.array.ReadOnlyIterator() - } + // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. + iterator, err := v.array.ReadOnlyIterator() if err != nil { panic(errors.NewExternalError(err)) } @@ -2322,12 +2251,7 @@ func (v *ArrayValue) ToConstantSized( return nil } - var value Value - if asReference { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + value := MustConvertStoredValue(context, atreeValue) if asReference { value = getReferenceValue(context, value, elementType) From 06f1ba9c15c1451a900e6c3600fbc5456177e1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 19:32:19 -0700 Subject: [PATCH 083/139] update atree, fix incorrect tests creating multiple storages --- go.mod | 2 +- go.sum | 4 ++-- test_utils/interpreter_utils/values.go | 17 +++++++++-------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 9975172ffc..d6861699d5 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd diff --git a/go.sum b/go.sum index a9597d2e44..f3ae3aa796 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774 h1:yEf0N7J8DCMC8wgZQcpFkQ/XAPawja/4A3yr3ANcz4g= -github.com/onflow/atree-internal v0.15.1-0.20260601223522-8f68792aa774/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd h1:uI64AQ6/HTmOcBiLS6y5LKbTOoEVPCeChqRtig+xvck= +github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/test_utils/interpreter_utils/values.go b/test_utils/interpreter_utils/values.go index 16d8b95c2e..62c2d82d20 100644 --- a/test_utils/interpreter_utils/values.go +++ b/test_utils/interpreter_utils/values.go @@ -174,22 +174,23 @@ func DictionaryEntries[K, V any]( } type testValueCreationContext struct { - storage interpreter.Storage common_utils.Invokable } var _ interpreter.MemberAccessibleContext = &testValueCreationContext{} var _ interpreter.ValueCreationContext = &testValueCreationContext{} +// NewTestValueCreationContext returns a value-creation context that +// shares storage with the given invokable. +// Sharing storage matters when test-constructed values +// are later compared to values produced by the Cadence program: +// atree's shared-state design assumes a single storage, +// so SlabIDs are unique within it. +// Mixing values across multiple storages risks SlabID collisions +// (each storage has its own monotonic SlabID counter starting from zero) +// that surface as cross-value aliasing. func NewTestValueCreationContext(invokable common_utils.Invokable) *testValueCreationContext { return &testValueCreationContext{ - // Have a separate storage for creating values inside the test Go-code. - // Then it'll not interfere with cadence program's storage. - storage: NewUnmeteredInMemoryStorage(), Invokable: invokable, } } - -func (c *testValueCreationContext) Storage() interpreter.Storage { - return c.storage -} From 0a0e37c6eea0c2ac4a5dde0569ea26be102993cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 20:13:34 -0700 Subject: [PATCH 084/139] remove update of atree value pointers of containers, fix canonicalization in container iterators --- interpreter/storage.go | 22 +++++++++--------- interpreter/value_array.go | 35 ++++++++++++++++++++-------- interpreter/value_composite.go | 26 ++++++++++++++++++--- interpreter/value_dictionary.go | 41 ++++++++++++++++++++++++++------- 4 files changed, 93 insertions(+), 31 deletions(-) diff --git a/interpreter/storage.go b/interpreter/storage.go index d9f47be470..8820a35a73 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -70,14 +70,17 @@ type AtreeContainerCache interface { } // canonicalizeContainerElement returns the canonical cached wrapper for a -// container element, populating the cache on first sight or adopting the -// freshly-loaded `*atree.Array`/`*atree.OrderedMap` into the existing -// cached wrapper. atree's `Array.Get`/`OrderedMap.Get` sets up the -// parent updater (via setCallbackWithChild) on the -// `*atree.Array`/`*atree.OrderedMap` instance it just returned, not on -// the one we may have previously cached, so the freshly-returned -// instance is the one that will correctly notify the parent on -// mutation. +// container element, +// populating the cache on first sight and returning the cached wrapper otherwise. +// +// Callers must only invoke this with `fresh` produced by an iterator/Get path +// that wires a real parent-notification callback on the underlying +// `*atree.Array`/`*atree.OrderedMap`. +// Wrappers from read-only iterators +// (`IterateReadOnly`, `IterateReadOnlyLoadedValues`) +// would have no parentUpdater (or a trap callback), +// and caching them would shadow later proper-Get wrappers. +// See `MustConvertStoredContainerElement` for the path that ensures this invariant. func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value { switch v := fresh.(type) { case *ArrayValue: @@ -86,7 +89,6 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value } if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*ArrayValue); ok { if existing.array != nil && !existing.isDestroyed { - existing.array = v.array return existing } cache.ClearCanonicalAtreeContainer(v.valueID) @@ -99,7 +101,6 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value } if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*DictionaryValue); ok { if existing.dictionary != nil && !existing.isDestroyed { - existing.dictionary = v.dictionary return existing } cache.ClearCanonicalAtreeContainer(v.valueID) @@ -112,7 +113,6 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value } if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*CompositeValue); ok { if existing.dictionary != nil && !existing.isDestroyed { - existing.dictionary = v.dictionary return existing } cache.ClearCanonicalAtreeContainer(v.valueID) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index f4c81b6334..6a5cd2b2e9 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -260,11 +260,18 @@ func (v *ArrayValue) Iterate( f func(element Value) (resume bool), transferElements bool, ) { + // v.array.Iterate is the mutable iterator path: + // it wires setCallbackWithChild on each returned element, + // so canonicalizing the resulting wrapper is safe + // (its *atree.Array has a real parentUpdater). + const canonicalizeElements = true + v.iterate( context, v.array.Iterate, f, transferElements, + canonicalizeElements, ) } @@ -276,11 +283,20 @@ func (v *ArrayValue) IterateReadOnlyLoaded( ) { const transferElements = false + // v.array.IterateReadOnlyLoadedValues does NOT wire setCallbackWithChild, + // so the *atree.Array wrappers it produces have no parentUpdater. + // Caching such wrappers as canonical would shadow later proper-Get + // wrappers (which DO have a parentUpdater) until the cache entry is replaced. + // This iteration is internal-only (callbacks must not expose elements + // to user code), so skip canonicalization entirely. + const canonicalizeElements = false + v.iterate( context, v.array.IterateReadOnlyLoadedValues, f, transferElements, + canonicalizeElements, ) } @@ -289,6 +305,7 @@ func (v *ArrayValue) iterate( atreeIterate func(fn atree.ArrayIterationFunc) error, f func(element Value) (resume bool), transferElements bool, + canonicalizeElements bool, ) { iterate := func() { err := atreeIterate(func(element atree.Value) (resume bool, err error) { @@ -304,17 +321,17 @@ func (v *ArrayValue) iterate( // atree.Array iteration provides low-level atree.Value, // convert to high-level interpreter.Value. // - // When the element will be passed to `f` without transfer, - // canonicalize it: `f` may stash it or hand it back to user - // code, where it could be aliased by an `&...` reference. When - // the element will be Transfer'd below, leave it as a - // transient fresh wrapper - Transfer's `array`/`dictionary` - // nil-out would poison the cache otherwise. + // Canonicalize the element wrapper when the iterator path wires parent callbacks + // AND the element will not be Transfer'd below: + // `f` may stash it or hand it back to user code, + // where it could be aliased by an `&...` reference. + // When Transfer'd below, Transfer's `array`/`dictionary` nil-out + // would poison the cache, so leave the wrapper transient. var elementValue Value - if transferElements { - elementValue = MustConvertStoredValue(context, element) - } else { + if !transferElements && canonicalizeElements { elementValue = MustConvertStoredContainerElement(context, element) + } else { + elementValue = MustConvertStoredValue(context, element) } CheckInvalidatedResourceOrResourceReference(elementValue, context) diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 343e641cdb..96ff3fb550 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1775,10 +1775,15 @@ func (v *CompositeValue) ForEachField( fn, ) } + // v.dictionary.Iterate is the mutable iterator path: + // it wires setCallbackWithChild on each returned value, + // so canonicalizing is safe (the wrapper's *atree.Array has a real parentUpdater). + const canonicalizeFieldValues = true v.forEachField( context, iterate, f, + canonicalizeFieldValues, ) } @@ -1789,10 +1794,17 @@ func (v *CompositeValue) ForEachReadOnlyLoadedField( context ContainerMutationContext, f func(fieldName string, fieldValue Value) (resume bool), ) { + // v.dictionary.IterateReadOnlyLoadedValues does NOT wire any parentUpdater + // on the returned field values. + // Canonicalizing such wrappers would shadow later proper-Get wrappers. + // This iteration is internal-only (callbacks must not expose field values to user code), + // so skip canonicalization entirely. + const canonicalizeFieldValues = false v.forEachField( context, v.dictionary.IterateReadOnlyLoadedValues, f, + canonicalizeFieldValues, ) } @@ -1800,11 +1812,19 @@ func (v *CompositeValue) forEachField( context ContainerMutationContext, atreeIterate func(fn atree.MapEntryIterationFunc) error, f func(fieldName string, fieldValue Value) (resume bool), + canonicalizeFieldValues bool, ) { err := atreeIterate(func(key atree.Value, atreeValue atree.Value) (resume bool, err error) { - // The field value is handed to `f` without transfer, so - // canonicalize so aliased references see a shared wrapper. - value := MustConvertStoredContainerElement(context, atreeValue) + // The field value is handed to `f` without transfer. + // Canonicalize only when the iterator wires real parent callbacks; + // otherwise the cache would be polluted with wrappers + // that have no parentUpdater. + var value Value + if canonicalizeFieldValues { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } CheckInvalidatedResourceOrResourceReference(value, context) resume = f( diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 2f5b7097c2..07fed56b77 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -413,7 +413,13 @@ func (v *DictionaryValue) IterateReadOnly( iterate := func(fn atree.MapEntryIterationFunc) error { return v.dictionary.IterateReadOnly(fn) } - v.iterate(interpreter, iterate, f) + // v.dictionary.IterateReadOnly wires a read-only "trap" parentUpdater + // on each returned element rather than the real parent-notification callback. + // Canonicalizing such wrappers would shadow later proper-Get wrappers + // (which DO have a real parentUpdater), + // causing mutations through the canonical wrapper to trip the trap. + const canonicalizeElements = false + v.iterate(interpreter, iterate, f, canonicalizeElements) } func (v *DictionaryValue) Iterate( @@ -429,7 +435,11 @@ func (v *DictionaryValue) Iterate( fn, ) } - v.iterate(context, iterate, f) + // v.dictionary.Iterate is the mutable iterator path: + // it wires setCallbackWithChild on each returned element, + // so canonicalizing is safe (the wrapper's *atree.Array has a real parentUpdater). + const canonicalizeElements = true + v.iterate(context, iterate, f, canonicalizeElements) } // IterateReadOnlyLoaded iterates over all LOADED key-value pairs of the array. @@ -438,10 +448,17 @@ func (v *DictionaryValue) IterateReadOnlyLoaded( context ContainerMutationContext, f func(key, value Value) (resume bool), ) { + // v.dictionary.IterateReadOnlyLoadedValues does NOT wire any parentUpdater + // on the returned elements. + // Canonicalizing such wrappers would shadow later proper-Get wrappers. + // This iteration is internal-only (callbacks must not expose elements to user code), + // so skip canonicalization entirely. + const canonicalizeElements = false v.iterate( context, v.dictionary.IterateReadOnlyLoadedValues, f, + canonicalizeElements, ) } @@ -449,6 +466,7 @@ func (v *DictionaryValue) iterate( context ContainerMutationContext, atreeIterate func(fn atree.MapEntryIterationFunc) error, f func(key Value, value Value) (resume bool), + canonicalizeElements bool, ) { iterate := func() { err := atreeIterate(func(key, value atree.Value) (resume bool, err error) { @@ -461,12 +479,19 @@ func (v *DictionaryValue) iterate( ) // atree.OrderedMap iteration provides low-level atree.Value, - // convert to high-level interpreter.Value. The pair is - // passed to `f` without transfer, so canonicalize both so - // aliased references see a shared wrapper. - - keyValue := MustConvertStoredContainerElement(context, key) - valueValue := MustConvertStoredContainerElement(context, value) + // convert to high-level interpreter.Value. + // Canonicalize the wrappers only when the iterator wires real parent callbacks; + // otherwise the cache would be polluted with wrappers + // that have no parentUpdater (or worse, a read-only trap). + + var keyValue, valueValue Value + if canonicalizeElements { + keyValue = MustConvertStoredContainerElement(context, key) + valueValue = MustConvertStoredContainerElement(context, value) + } else { + keyValue = MustConvertStoredValue(context, key) + valueValue = MustConvertStoredValue(context, value) + } CheckInvalidatedResourceOrResourceReference(keyValue, context) CheckInvalidatedResourceOrResourceReference(valueValue, context) From 5c984f868961bb1f6ee95d9eb36530899179ffb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 21:04:27 -0700 Subject: [PATCH 085/139] fix 06f1ba9c1 --- test_utils/interpreter_utils/values.go | 46 +++++++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/test_utils/interpreter_utils/values.go b/test_utils/interpreter_utils/values.go index 62c2d82d20..5762dbd802 100644 --- a/test_utils/interpreter_utils/values.go +++ b/test_utils/interpreter_utils/values.go @@ -21,6 +21,7 @@ package interpreter_utils import ( "fmt" "strings" + "sync" "testing" "github.com/kr/pretty" @@ -174,23 +175,50 @@ func DictionaryEntries[K, V any]( } type testValueCreationContext struct { + storage interpreter.Storage common_utils.Invokable } var _ interpreter.MemberAccessibleContext = &testValueCreationContext{} var _ interpreter.ValueCreationContext = &testValueCreationContext{} -// NewTestValueCreationContext returns a value-creation context that -// shares storage with the given invokable. -// Sharing storage matters when test-constructed values -// are later compared to values produced by the Cadence program: -// atree's shared-state design assumes a single storage, -// so SlabIDs are unique within it. -// Mixing values across multiple storages risks SlabID collisions -// (each storage has its own monotonic SlabID counter starting from zero) -// that surface as cross-value aliasing. +// testValueCreationStorages tracks one shared test-only storage per invokable, +// so all NewTestValueCreationContext calls within a single test build their values +// into the SAME storage rather than each into a fresh one. +// +// Why a shared storage: +// - atree's shared-state design assumes SlabIDs are unique within a storage. +// - Each storage's GenerateSlabID counter starts at zero, +// so values built across multiple storages can carry colliding SlabIDs. +// - When such values are later combined (e.g. NewArrayValue([v1, v2, v3])), +// the destination storage's registry can't disambiguate them. +// +// Why a SEPARATE storage from the invokable's: +// - In VM-vs-interpreter comparison tests, +// the invokable's storage is the VM's storage and is compared to the interpreter's storage. +// If test-built expected values were placed in the VM's storage, +// they would pollute the comparison. +var testValueCreationStorages sync.Map // common_utils.Invokable -> interpreter.Storage + +// NewTestValueCreationContext returns a value-creation context backed by a test-only storage +// that is shared across all NewTestValueCreationContext calls for the given invokable +// but is independent of the invokable's own storage. +// See testValueCreationStorages for why both invariants matter. func NewTestValueCreationContext(invokable common_utils.Invokable) *testValueCreationContext { + if cached, ok := testValueCreationStorages.Load(invokable); ok { + return &testValueCreationContext{ + storage: cached.(interpreter.Storage), + Invokable: invokable, + } + } + storage := NewUnmeteredInMemoryStorage() + actual, _ := testValueCreationStorages.LoadOrStore(invokable, storage) return &testValueCreationContext{ + storage: actual.(interpreter.Storage), Invokable: invokable, } } + +func (c *testValueCreationContext) Storage() interpreter.Storage { + return c.storage +} From 85b6998bb8e26bb69fbfe510cf01253972f33e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 1 Jun 2026 21:18:21 -0700 Subject: [PATCH 086/139] remove cached value ID from containers --- interpreter/array_test.go | 2 +- interpreter/composite_value_test.go | 2 +- interpreter/dictionary_test.go | 2 +- interpreter/storage.go | 21 ++++++++++++--------- interpreter/value_array.go | 22 ++-------------------- interpreter/value_composite.go | 28 ++-------------------------- interpreter/value_dictionary.go | 22 ++-------------------- 7 files changed, 21 insertions(+), 78 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 342f56ab1a..c7ca5c3da6 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -212,7 +212,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) arrayValue := ref.Value.(*interpreter.ArrayValue) - return interpreter.NewUnmeteredStringValue(arrayValue.LiveValueID().String()) + return interpreter.NewUnmeteredStringValue(arrayValue.ValueID().String()) }, ) diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index b8eb732f4a..a3498b1bbd 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -452,7 +452,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) composite := ref.Value.(*interpreter.CompositeValue) - return interpreter.NewUnmeteredStringValue(composite.LiveValueID().String()) + return interpreter.NewUnmeteredStringValue(composite.ValueID().String()) }, ) diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index 850c007e60..b8469737f7 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -172,7 +172,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) dictValue := ref.Value.(*interpreter.DictionaryValue) - return interpreter.NewUnmeteredStringValue(dictValue.LiveValueID().String()) + return interpreter.NewUnmeteredStringValue(dictValue.ValueID().String()) }, ) diff --git a/interpreter/storage.go b/interpreter/storage.go index 8820a35a73..e256a4d228 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -87,37 +87,40 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value if v.array == nil || v.isDestroyed { return fresh } - if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*ArrayValue); ok { + valueID := v.array.ValueID() + if existing, ok := cache.CanonicalAtreeContainer(valueID).(*ArrayValue); ok { if existing.array != nil && !existing.isDestroyed { return existing } - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } - cache.SetCanonicalAtreeContainer(v.valueID, v) + cache.SetCanonicalAtreeContainer(valueID, v) return v case *DictionaryValue: if v.dictionary == nil || v.isDestroyed { return fresh } - if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*DictionaryValue); ok { + valueID := v.dictionary.ValueID() + if existing, ok := cache.CanonicalAtreeContainer(valueID).(*DictionaryValue); ok { if existing.dictionary != nil && !existing.isDestroyed { return existing } - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } - cache.SetCanonicalAtreeContainer(v.valueID, v) + cache.SetCanonicalAtreeContainer(valueID, v) return v case *CompositeValue: if v.dictionary == nil || v.isDestroyed { return fresh } - if existing, ok := cache.CanonicalAtreeContainer(v.valueID).(*CompositeValue); ok { + valueID := v.dictionary.ValueID() + if existing, ok := cache.CanonicalAtreeContainer(valueID).(*CompositeValue); ok { if existing.dictionary != nil && !existing.isDestroyed { return existing } - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } - cache.SetCanonicalAtreeContainer(v.valueID, v) + cache.SetCanonicalAtreeContainer(valueID, v) return v } return fresh diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 6a5cd2b2e9..e1e26430ba 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -39,12 +39,6 @@ type ArrayValue struct { isResourceKinded *bool elementSize uint isDestroyed bool - - // valueID is the atree value ID captured at construction time. - // It is used for reference tracking and invalidation, and must remain - // stable even if the array's runtime root slab ID changes. - // See the equivalent field on CompositeValue for the underlying scenario. - valueID atree.ValueID } func NewArrayValue( @@ -222,7 +216,6 @@ func newArrayValueFromAtreeArray( return &ArrayValue{ Type: staticType, array: atreeArray, - valueID: atreeArray.ValueID(), elementSize: elementSize, } } @@ -451,7 +444,7 @@ func (v *ArrayValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } v.array = nil } @@ -1612,7 +1605,7 @@ func (v *ArrayValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(v.array.ValueID()) } v.array = nil } @@ -1728,17 +1721,6 @@ func (v *ArrayValue) StorageAddress() atree.Address { } func (v *ArrayValue) ValueID() atree.ValueID { - return v.valueID -} - -// LiveValueID returns the underlying atree array's current value ID. -// In contrast to ValueID, which returns a stable value ID cached at -// construction, LiveValueID reflects mutations to the atree array's root, -// including slab ID reassignments caused by splits triggered through other -// ArrayValue instances wrapping the same underlying atree array. -// Intended for testing only; production code must use ValueID for resource -// tracking and invalidation. -func (v *ArrayValue) LiveValueID() atree.ValueID { return v.array.ValueID() } diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 96ff3fb550..60ffe96bb0 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -53,17 +53,6 @@ type CompositeValue struct { dictionary *atree.OrderedMap typeID TypeID - // valueID is the atree value ID captured at construction time. - // It is used for reference tracking and invalidation, and must remain - // stable even if the dictionary's runtime root slab ID changes. - // Specifically, when multiple CompositeValue instances share the same - // underlying atree map (e.g. created by repeated element access via - // `arr[i]`), an atree slab split through one instance reassigns the - // other instance's root pointer's slab ID. Using the dictionary's live - // ValueID in that scenario would let a stale view register a reference - // under a different ID, bypassing invalidation. - valueID atree.ValueID - // attachments also have a reference to their base value. This field is set in three cases: // 1) when an attachment `A` is accessed off `v` using `v[A]`, this is set to `&v` // 2) When a resource `r`'s destructor is invoked, all of `r`'s attachments' destructors will also run, and @@ -261,7 +250,6 @@ func NewCompositeValueFromAtreeMap( return &CompositeValue{ dictionary: atreeOrderedMap, - valueID: atreeOrderedMap.ValueID(), Location: typeInfo.Location, QualifiedIdentifier: typeInfo.QualifiedIdentifier, Kind: typeInfo.Kind, @@ -463,7 +451,7 @@ func (v *CompositeValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } v.dictionary = nil } @@ -1559,7 +1547,7 @@ func (v *CompositeValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(v.dictionary.ValueID()) } v.dictionary = nil } @@ -1649,7 +1637,6 @@ func (v *CompositeValue) Clone(context ValueCloneContext) Value { return &CompositeValue{ dictionary: dictionary, - valueID: dictionary.ValueID(), Location: v.Location, QualifiedIdentifier: v.QualifiedIdentifier, Kind: v.Kind, @@ -1848,17 +1835,6 @@ func (v *CompositeValue) StorageAddress() atree.Address { } func (v *CompositeValue) ValueID() atree.ValueID { - return v.valueID -} - -// LiveValueID returns the underlying atree map's current value ID. -// In contrast to ValueID, which returns a stable value ID cached at -// construction, LiveValueID reflects mutations to the atree map's root, -// including slab ID reassignments caused by splits triggered through other -// CompositeValue instances wrapping the same underlying atree map. -// Intended for testing only; production code must use ValueID for resource -// tracking and invalidation. -func (v *CompositeValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 07fed56b77..f6809eb35d 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -39,12 +39,6 @@ type DictionaryValue struct { dictionary *atree.OrderedMap isDestroyed bool elementSize uint - - // valueID is the atree value ID captured at construction time. - // It is used for reference tracking and invalidation, and must remain - // stable even if the dictionary's runtime root slab ID changes. - // See the equivalent field on CompositeValue for the underlying scenario. - valueID atree.ValueID } func NewDictionaryValue( @@ -299,7 +293,6 @@ func newDictionaryValueFromAtreeMap( return &DictionaryValue{ Type: staticType, dictionary: atreeOrderedMap, - valueID: atreeOrderedMap.ValueID(), elementSize: elementSize, } } @@ -620,7 +613,7 @@ func (v *DictionaryValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(valueID) } v.dictionary = nil } @@ -1725,7 +1718,7 @@ func (v *DictionaryValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) + cache.ClearCanonicalAtreeContainer(v.dictionary.ValueID()) } v.dictionary = nil } @@ -1860,17 +1853,6 @@ func (v *DictionaryValue) StorageAddress() atree.Address { } func (v *DictionaryValue) ValueID() atree.ValueID { - return v.valueID -} - -// LiveValueID returns the underlying atree map's current value ID. -// In contrast to ValueID, which returns a stable value ID cached at -// construction, LiveValueID reflects mutations to the atree map's root, -// including slab ID reassignments caused by splits triggered through other -// DictionaryValue instances wrapping the same underlying atree map. -// Intended for testing only; production code must use ValueID for resource -// tracking and invalidation. -func (v *DictionaryValue) LiveValueID() atree.ValueID { return v.dictionary.ValueID() } From ba53008b6672dba18c328352e16ee937f0fc69f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 2 Jun 2026 11:38:56 -0700 Subject: [PATCH 087/139] fix iteration: references resulting from container methods may get mutated --- go.mod | 2 +- go.sum | 4 +- interpreter/account_test.go | 84 +++++++++++++++ interpreter/array_test.go | 110 +++++++++++++++++-- interpreter/composite_value_test.go | 16 +-- interpreter/dictionary_test.go | 14 ++- interpreter/domain_storagemap.go | 7 +- interpreter/sharedstate.go | 40 ++++--- interpreter/storage.go | 48 ++++++--- interpreter/value_array.go | 160 ++++++++++++++++++++-------- interpreter/value_composite.go | 26 +---- interpreter/value_dictionary.go | 48 +++------ 12 files changed, 401 insertions(+), 158 deletions(-) diff --git a/go.mod b/go.mod index d6861699d5..5dd5b32fb7 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116 diff --git a/go.sum b/go.sum index f3ae3aa796..8e4da8d0c8 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd h1:uI64AQ6/HTmOcBiLS6y5LKbTOoEVPCeChqRtig+xvck= -github.com/onflow/atree-internal v0.15.1-0.20260602023016-52144d6abebd/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116 h1:CNHl0ZXcB1r1oYz+dDpqASstCquAMYFl9+JwARGkQ08= +github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/interpreter/account_test.go b/interpreter/account_test.go index d3a4b7a1f1..743a9a7361 100644 --- a/interpreter/account_test.go +++ b/interpreter/account_test.go @@ -1701,3 +1701,87 @@ func TestInterpretAccountStorageReadFunctionTypes(t *testing.T) { require.NoError(t, err) require.Equal(t, interpreter.FalseValue, areEqual) } + +// TestInterpretAccountStorageBorrowAliasingConsistency exercises +// DomainStorageMap.ReadValue's canonicalization: +// two `account.storage.borrow<&T>(from: /storage/path)` calls +// to the same path must alias the same canonical wrapper, +// so structural mutations through one reference +// are observable through the other. +// +// Without canonicalization at the storage read path, +// each borrow would build a fresh `*atree.Array` over the same slab. +// Atree's shared state still propagates structural changes, +// but Cadence-level wrapper state (`isDestroyed` etc.) would diverge, +// and references would have different Go-level target pointers. +// +// The test grows a stored array past atree's inline limit +// to force an actual slab split, then verifies that a second borrow +// observes the post-split state. +func TestInterpretAccountStorageBorrowAliasingConsistency(t *testing.T) { + t.Parallel() + + address := interpreter.NewUnmeteredAddressValueFromBytes([]byte{42}) + + inter, _, _ := testAccount(t, address, true, nil, ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun save() { + let vaults: @[Vault] <- [<-create Vault(balance: 0.0)] + account.storage.save(<-vaults, to: /storage/vaults) + } + + access(all) fun growThroughBorrow1(): Int { + // Borrow once, append enough vaults through this reference + // to trigger an atree slab split. + let ref1 = account.storage.borrow(from: /storage/vaults)! + var i: Int = 0 + while i < 200 { + ref1.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + return ref1.length + } + + access(all) fun observeThroughBorrow2(): Int { + // A separate borrow must observe the post-split state. + let ref2 = account.storage.borrow<&[Vault]>(from: /storage/vaults)! + return ref2.length + } + + access(all) fun crossMutate(): Int { + // Append through one borrow, observe through another. + let ref1 = account.storage.borrow(from: /storage/vaults)! + let ref2 = account.storage.borrow<&[Vault]>(from: /storage/vaults)! + ref1.append(<-create Vault(balance: 999.0)) + return ref2.length + } + `, sema.Config{}) + + _, err := inter.Invoke("save") + require.NoError(t, err) + + grew, err := inter.Invoke("growThroughBorrow1") + require.NoError(t, err) + require.Equal(t, + interpreter.NewUnmeteredIntValueFromInt64(201), + grew, + "borrow1 must report initial element plus 200 appended vaults") + + observed, err := inter.Invoke("observeThroughBorrow2") + require.NoError(t, err) + require.Equal(t, + interpreter.NewUnmeteredIntValueFromInt64(201), + observed, + "borrow2 must observe the post-split state set up via borrow1") + + postCross, err := inter.Invoke("crossMutate") + require.NoError(t, err) + require.Equal(t, + interpreter.NewUnmeteredIntValueFromInt64(202), + postCross, + "borrow2 must observe borrow1's append within the same call") +} diff --git a/interpreter/array_test.go b/interpreter/array_test.go index c7ca5c3da6..2c00748247 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -167,13 +167,12 @@ func TestCheckArrayReferenceTypeInferenceWithDowncasting(t *testing.T) { } // TestInterpretArrayValueIDTracking is the ArrayValue counterpart to -// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split -// stale-view scenario, where two ArrayValue instances wrap the same underlying -// atree array (created by accessing the same outer array element twice) and a -// split through one instance leaves the other with a different live value ID. -// Without the cached valueID on ArrayValue, an EphemeralReferenceValue created -// from the stale view would register under that different ID, bypass -// invalidation when the inner array is moved, and survive as a dangling ref. +// TestInterpretCompositeValueIDTracking. +// Two references to the same logical inner array (created by accessing the same +// outer array element twice) must share a canonical wrapper, so when the inner +// array is moved/invalidated, all references see it as invalidated. +// Without canonicalization, an EphemeralReferenceValue created from a separate +// wrapper would survive invalidation through the canonical one and dangle. func TestInterpretArrayValueIDTracking(t *testing.T) { t.Parallel() @@ -723,6 +722,103 @@ func TestInterpretArrayAsReferenceContainerMethodAliasingConsistency(t *testing. } } +// TestInterpretArrayAsReferenceContainerMethodMutationPropagation verifies +// that mutations performed through a reference returned by the asReference branch +// of Array.slice / .concat / .filter / .map / .toVariableSized / .toConstantSized +// propagate to the original container. +// +// `access(all)` mutating functions on a struct (like `S.inc()`) are reachable +// even on unauthorized references — Cadence's checker does not require an +// entitlement to call them. The implementations must therefore use the +// mutable atree iterator on the asReference branch, so that mutations through +// the result-stored reference fire the real parent-notification callback +// rather than tripping the trap callback installed by a read-only iterator. +func TestInterpretArrayAsReferenceContainerMethodMutationPropagation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + }{ + { + name: "slice", + body: `let part = ref.slice(from: 0, upTo: 2); part[0].inc()`, + }, + { + name: "concat", + body: `let part = ref.concat([]); part[0].inc()`, + }, + { + name: "filter", + body: `let part = ref.filter(view fun (_: &S): Bool { return true }); part[0].inc()`, + }, + { + name: "map", + body: `let _ = ref.map(fun (s: &S) { s.inc() })`, + }, + { + name: "toConstantSized", + body: `let part = ref.toConstantSized<[&S; 3]>()!; part[0].inc()`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + code := fmt.Sprintf(` + access(all) struct S { + access(all) var x: Int + init() { self.x = 1 } + access(all) fun inc() { self.x = self.x + 1 } + } + + access(all) fun main(): Int { + let xs = [S(), S(), S()] + let ref = &xs as &[S] + %s + return xs[0].x + } + `, tc.body) + + inter := parseCheckAndPrepare(t, code) + result, err := inter.Invoke("main") + require.NoError(t, err) + require.Equal(t, + interpreter.NewUnmeteredIntValueFromInt64(2), + result, + "mutation through result reference must propagate to original") + }) + } + + // toVariableSized takes a fixed-sized array, so test it separately. + t.Run("toVariableSized", func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + access(all) struct S { + access(all) var x: Int + init() { self.x = 1 } + access(all) fun inc() { self.x = self.x + 1 } + } + + access(all) fun main(): Int { + let xs: [S; 3] = [S(), S(), S()] + let ref = &xs as &[S; 3] + let part = ref.toVariableSized() + part[0].inc() + return xs[0].x + } + `) + result, err := inter.Invoke("main") + require.NoError(t, err) + require.Equal(t, + interpreter.NewUnmeteredIntValueFromInt64(2), + result, + "mutation through toVariableSized result reference must propagate to original") + }) +} + // TestInterpretOptionalContainerAliasingConsistency verifies the // SomeStorable.StoredValue path: when a stored optional wraps a // container (e.g. `{String: [Vault]?}`), accessing the optional should diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index a3498b1bbd..4d33c9b250 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -626,13 +626,15 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { ) require.NoError(t, err) - // After an atree slab split, a stale view of the resource's dictionary can - // produce a different live ValueID than the original. Without the cached - // valueID on CompositeValue, an EphemeralReferenceValue created from such - // a stale view would register under a different ID, bypassing invalidation - // when the resource is moved, and allow the balance to be withdrawn twice. - // The cached valueID ensures the stale reference is invalidated alongside - // the others, so the second withdraw must fail. + // Multiple CompositeValue references to the same resource (e.g. via + // repeated `&` accesses) must share a canonical wrapper, so when the + // resource is moved/destroyed, all references see it as invalidated. + // Without canonicalization, an EphemeralReferenceValue created from a + // separate wrapper would survive invalidation through the canonical one, + // allowing the balance to be withdrawn twice. + // atree's shared per-container state keeps siblings structurally consistent; + // the canonical wrapper cache ensures they also share Cadence-level state + // like `isDestroyed`. _, err = inter.Invoke("main") RequireError(t, err) var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index b8469737f7..100f237805 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -126,14 +126,12 @@ func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { } // TestInterpretDictionaryValueIDTracking is the DictionaryValue counterpart to -// TestInterpretCompositeValueIDTracking. It exercises the same atree slab-split -// stale-view scenario, where two DictionaryValue instances wrap the same -// underlying atree map (created by accessing the same outer dictionary key -// twice) and a split through one instance leaves the other with a different -// live value ID. Without the cached valueID on DictionaryValue, an -// EphemeralReferenceValue created from the stale view would register under -// that different ID, bypass invalidation when the inner dictionary is moved, -// and survive as a dangling ref. +// TestInterpretCompositeValueIDTracking. +// Two references to the same logical inner dictionary (created by accessing +// the same outer dictionary key twice) must share a canonical wrapper, so when +// the inner dictionary is moved/invalidated, all references see it as invalidated. +// Without canonicalization, an EphemeralReferenceValue created from a separate +// wrapper would survive invalidation through the canonical one and dangle. func TestInterpretDictionaryValueIDTracking(t *testing.T) { t.Parallel() diff --git a/interpreter/domain_storagemap.go b/interpreter/domain_storagemap.go index 7fe66815f6..eda2522f47 100644 --- a/interpreter/domain_storagemap.go +++ b/interpreter/domain_storagemap.go @@ -187,7 +187,12 @@ func (s *DomainStorageMap) ReadValue(gauge common.Gauge, key StorageMapKey) Valu panic(errors.NewExternalError(err)) } - return MustConvertStoredValue(gauge, storedValue) + // s.orderedMap.Get wires a real parent updater on the returned value. + // The wrapper is exposed to user code + // (e.g. as the target of `account.storage.borrow<&T>`), + // so two reads of the same path must yield the same canonical wrapper + // for reference identity and per-wrapper state alignment. + return MustConvertStoredContainerElement(gauge, storedValue) } // WriteValue sets or removes a value in the storage map. diff --git a/interpreter/sharedstate.go b/interpreter/sharedstate.go index 57e0df7160..512c6b40ca 100644 --- a/interpreter/sharedstate.go +++ b/interpreter/sharedstate.go @@ -47,25 +47,31 @@ type SharedState struct { destroyedResources map[atree.ValueID]struct{} // canonicalAtreeContainers deduplicates the Cadence-level wrappers // (ArrayValue, DictionaryValue, CompositeValue) created for atree - // containers, keyed by their atree value ID. The first wrapper created - // for a given value ID via `ConvertStoredValue` becomes canonical; - // subsequent retrievals reuse the same wrapper instance. The wrapper's - // `array`/`dictionary` field is updated on each retrieval to the - // `*atree.Array`/`*atree.OrderedMap` instance that atree just handed - // out (and on which it set up the parent updater), so operations - // through the canonical wrapper notify the parent correctly. + // containers, keyed by their atree value ID. + // The first wrapper created for a given value ID + // via a canonicalizing path (see `MustConvertStoredContainerElement`) + // becomes canonical; + // subsequent retrievals reuse the same wrapper instance. // - // Without this, atree.Array.Get / OrderedMap.Get returns a fresh - // `*atree.Array`/`*atree.OrderedMap` per call - two `&outer[0]` - // evaluations would hold separate wrappers around separate atree - // instances over the same slab. An atree slab split through one - // wrapper reassigns root pointers on that instance only; the others go - // stale and mutations through them silently corrupt the container. + // Cadence relies on this in two ways: // - // Entries are removed when a wrapper is invalidated by Destroy or - // Transfer (its `array`/`dictionary` is nilled). Subsequent retrievals - // for the same value ID create a fresh wrapper, leaving the held - // reference to the invalidated wrapper unaffected. + // - Reference identity for `EphemeralReferenceValue`: + // two `&outer[0]` accesses must yield references to the same Go-level wrapper + // so that mutations through one are visible to the other. + // + // - Per-wrapper Cadence-level state alignment: + // fields like `isDestroyed` live on the wrapper, not on atree's shared state. + // If multiple wrappers existed for one logical container, + // marking one destroyed would leave the others usable. + // + // Structural consistency between sibling `*atree.Array`/`*atree.OrderedMap` instances + // is handled by atree's per-container shared state (the registry on `SlabStorage`), + // not by this cache. + // + // Entries are removed when a wrapper is invalidated by Destroy or Transfer + // (its `array`/`dictionary` is nilled). + // Subsequent retrievals for the same value ID create a fresh wrapper, + // leaving the held reference to the invalidated wrapper unaffected. canonicalAtreeContainers map[atree.ValueID]Value } diff --git a/interpreter/storage.go b/interpreter/storage.go index e256a4d228..7c60029360 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -73,20 +73,27 @@ type AtreeContainerCache interface { // container element, // populating the cache on first sight and returning the cached wrapper otherwise. // -// Callers must only invoke this with `fresh` produced by an iterator/Get path -// that wires a real parent-notification callback on the underlying -// `*atree.Array`/`*atree.OrderedMap`. +// The function is self-defensive: +// it will only cache wrappers whose underlying `*atree.Array`/`*atree.OrderedMap` +// has a real parent-notification callback installed. // Wrappers from read-only iterators -// (`IterateReadOnly`, `IterateReadOnlyLoadedValues`) -// would have no parentUpdater (or a trap callback), -// and caching them would shadow later proper-Get wrappers. -// See `MustConvertStoredContainerElement` for the path that ensures this invariant. +// (whose `*atree.Array`/`*atree.OrderedMap` either has no parentUpdater, +// or has a read-only mutation trap callback) +// are returned as-is without being cached, +// because caching them would either silently lose parent notifications +// or trip the trap on subsequent mutations through the canonical wrapper. +// +// This means `MustConvertStoredContainerElement` is safe to call on any path — +// it acts as canonicalize-or-passthrough based on the wrapper's actual state. func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value { switch v := fresh.(type) { case *ArrayValue: if v.array == nil || v.isDestroyed { return fresh } + if !v.array.HasParentUpdater() || v.array.HasReadOnlyMutationCallback() { + return fresh + } valueID := v.array.ValueID() if existing, ok := cache.CanonicalAtreeContainer(valueID).(*ArrayValue); ok { if existing.array != nil && !existing.isDestroyed { @@ -100,6 +107,9 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value if v.dictionary == nil || v.isDestroyed { return fresh } + if !v.dictionary.HasParentUpdater() || v.dictionary.HasReadOnlyMutationCallback() { + return fresh + } valueID := v.dictionary.ValueID() if existing, ok := cache.CanonicalAtreeContainer(valueID).(*DictionaryValue); ok { if existing.dictionary != nil && !existing.isDestroyed { @@ -113,6 +123,9 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value if v.dictionary == nil || v.isDestroyed { return fresh } + if !v.dictionary.HasParentUpdater() || v.dictionary.HasReadOnlyMutationCallback() { + return fresh + } valueID := v.dictionary.ValueID() if existing, ok := cache.CanonicalAtreeContainer(valueID).(*CompositeValue); ok { if existing.dictionary != nil && !existing.isDestroyed { @@ -127,15 +140,18 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value } // MustConvertStoredContainerElement wraps an atree value retrieved as a -// container element (e.g. via `*atree.Array.Get` or -// `*atree.OrderedMap.Get`) as a Cadence-level `Value`, deduplicating the -// resulting wrapper via the canonical wrapper cache when supported. Use -// this instead of `MustConvertStoredValue` whenever a container's -// element is being returned to user code (e.g. for `&outer[0]`), so that -// aliased references share state. Internal callers that immediately -// Transfer (and thereby invalidate) the wrapper must continue to use -// `MustConvertStoredValue` so their transient wrapper does not poison -// the cache. +// container element (e.g. via `*atree.Array.Get` or `*atree.OrderedMap.Get`) +// as a Cadence-level `Value`, +// deduplicating the resulting wrapper via the canonical wrapper cache when both: +// - the gauge is an `AtreeContainerCache`, and +// - the wrapper has a real parent-notification callback +// (i.e. it came from a `Get` or mutable-iterator path, +// not from a read-only iterator). +// +// The second condition is enforced inside `canonicalizeContainerElement`, +// so callers do not need to know which atree path produced `value`: +// passing in a read-only-iterator wrapper is safe; +// it just won't be cached. func MustConvertStoredContainerElement(gauge common.MemoryGauge, value atree.Value) Value { result := MustConvertStoredValue(gauge, value) if cache, ok := gauge.(AtreeContainerCache); ok { diff --git a/interpreter/value_array.go b/interpreter/value_array.go index e1e26430ba..b9eda42677 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -253,18 +253,11 @@ func (v *ArrayValue) Iterate( f func(element Value) (resume bool), transferElements bool, ) { - // v.array.Iterate is the mutable iterator path: - // it wires setCallbackWithChild on each returned element, - // so canonicalizing the resulting wrapper is safe - // (its *atree.Array has a real parentUpdater). - const canonicalizeElements = true - v.iterate( context, v.array.Iterate, f, transferElements, - canonicalizeElements, ) } @@ -276,20 +269,11 @@ func (v *ArrayValue) IterateReadOnlyLoaded( ) { const transferElements = false - // v.array.IterateReadOnlyLoadedValues does NOT wire setCallbackWithChild, - // so the *atree.Array wrappers it produces have no parentUpdater. - // Caching such wrappers as canonical would shadow later proper-Get - // wrappers (which DO have a parentUpdater) until the cache entry is replaced. - // This iteration is internal-only (callbacks must not expose elements - // to user code), so skip canonicalization entirely. - const canonicalizeElements = false - v.iterate( context, v.array.IterateReadOnlyLoadedValues, f, transferElements, - canonicalizeElements, ) } @@ -298,7 +282,6 @@ func (v *ArrayValue) iterate( atreeIterate func(fn atree.ArrayIterationFunc) error, f func(element Value) (resume bool), transferElements bool, - canonicalizeElements bool, ) { iterate := func() { err := atreeIterate(func(element atree.Value) (resume bool, err error) { @@ -314,17 +297,23 @@ func (v *ArrayValue) iterate( // atree.Array iteration provides low-level atree.Value, // convert to high-level interpreter.Value. // - // Canonicalize the element wrapper when the iterator path wires parent callbacks - // AND the element will not be Transfer'd below: - // `f` may stash it or hand it back to user code, - // where it could be aliased by an `&...` reference. - // When Transfer'd below, Transfer's `array`/`dictionary` nil-out - // would poison the cache, so leave the wrapper transient. + // When the element will not be Transfer'd, + // route through MustConvertStoredContainerElement: + // `f` may hand the element to user code, where it could be aliased by an `&...` reference, + // so we want to canonicalize when safe. + // canonicalizeContainerElement self-filters by inspecting the wrapper's parent-callback state, + // so it is safe to call regardless of which atree iterator (`Iterate` vs + // `IterateReadOnlyLoadedValues`) produced the element. + // + // For Transfer'd elements, the wrapper is transient + // (Transfer's `array`/`dictionary` nil-out would otherwise leave a stale cache entry + // that gets cleaned up lazily on next access), + // so skip the cache lookup entirely. var elementValue Value - if !transferElements && canonicalizeElements { - elementValue = MustConvertStoredContainerElement(context, element) - } else { + if transferElements { elementValue = MustConvertStoredValue(context, element) + } else { + elementValue = MustConvertStoredContainerElement(context, element) } CheckInvalidatedResourceOrResourceReference(elementValue, context) @@ -471,14 +460,31 @@ func (v *ArrayValue) Concat( resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) - // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. - firstIterator, err := v.array.ReadOnlyIterator() + // In the asReference branch each yielded element is wrapped in an + // EphemeralReferenceValue that lands in the result array. + // Subsequent mutations through such a reference + // (e.g. calling an `access(all)` mutating function on a struct element) + // must propagate to the original container, + // so the iterator must wire a real parent-notification callback — + // a read-only iterator's trap callback would panic on mutation. + // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, + // so a read-only iterator suffices. + var firstIterator, secondIterator atree.ArrayIterator + var err error + if asReference { + firstIterator, err = v.array.Iterator() + } else { + firstIterator, err = v.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } - // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. - secondIterator, err := other.array.ReadOnlyIterator() + if asReference { + secondIterator, err = other.array.Iterator() + } else { + secondIterator, err = other.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } @@ -512,6 +518,8 @@ func (v *ArrayValue) Concat( if atreeValue == nil { first = false + } else if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) } else { value = MustConvertStoredValue(context, atreeValue) } @@ -526,7 +534,11 @@ func (v *ArrayValue) Concat( } if atreeValue != nil { - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } checkContainerMutation(context, otherElementType, value) } @@ -1777,8 +1789,19 @@ func (v *ArrayValue) Slice( elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) - // Use ReadOnlyRangeIterator here because new ArrayValue is created from elements copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) + // In the asReference branch each yielded element is wrapped in an + // EphemeralReferenceValue that lands in the result array. + // Subsequent mutations through such a reference + // must propagate to the original container, + // so the iterator must wire a real parent-notification callback. + // In the non-asReference branch the element is `Transfer`'d, decoupled from the original. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.RangeIterator(uint64(fromIndex), uint64(toIndex)) + } else { + iterator, err = v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) + } if err != nil { var sliceOutOfBoundsError *atree.SliceOutOfBoundsError @@ -1827,7 +1850,11 @@ func (v *ArrayValue) Slice( var value Value if atreeValue != nil { - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } } if value == nil { @@ -1959,7 +1986,15 @@ func (v *ArrayValue) Filter( return nil } - value = MustConvertStoredValue(context, atreeValue) + // When asReference is true, the result array stores references + // to source elements; the reference wrappers must be canonical + // so that mutations (e.g. an access(all) mutating function) + // propagate consistently with sibling references taken elsewhere. + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if value == nil { return nil } @@ -2074,7 +2109,16 @@ func (v *ArrayValue) Map( return nil } - value := MustConvertStoredValue(context, atreeValue) + // When asReference is true, the closure receives a reference to + // the source element; the reference target must be canonical so + // that mutations through it (e.g. an access(all) mutating function) + // propagate consistently with sibling references taken elsewhere. + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue( context, @@ -2135,8 +2179,19 @@ func (v *ArrayValue) ToVariableSized( // Convert the array to a variable-sized array. - // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyIterator() + // In the asReference branch each yielded element is wrapped in an + // EphemeralReferenceValue that lands in the result array. + // Subsequent mutations through such a reference must propagate to the original container, + // so the iterator must wire a real parent-notification callback. + // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, + // so a read-only iterator suffices. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.Iterator() + } else { + iterator, err = v.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } @@ -2167,7 +2222,12 @@ func (v *ArrayValue) ToVariableSized( return nil } - value := MustConvertStoredValue(context, atreeValue) + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue(context, value, elementType) @@ -2218,8 +2278,19 @@ func (v *ArrayValue) ToConstantSized( // Convert the array to a constant-sized array. - // Use ReadOnlyIterator here because ArrayValue elements are copied (not removed) from original ArrayValue. - iterator, err := v.array.ReadOnlyIterator() + // In the asReference branch each yielded element is wrapped in an + // EphemeralReferenceValue that lands in the result array. + // Subsequent mutations through such a reference must propagate to the original container, + // so the iterator must wire a real parent-notification callback. + // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, + // so a read-only iterator suffices. + var iterator atree.ArrayIterator + var err error + if asReference { + iterator, err = v.array.Iterator() + } else { + iterator, err = v.array.ReadOnlyIterator() + } if err != nil { panic(errors.NewExternalError(err)) } @@ -2250,7 +2321,12 @@ func (v *ArrayValue) ToConstantSized( return nil } - value := MustConvertStoredValue(context, atreeValue) + var value Value + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } if asReference { value = getReferenceValue(context, value, elementType) diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 60ffe96bb0..f982d2263c 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1762,15 +1762,10 @@ func (v *CompositeValue) ForEachField( fn, ) } - // v.dictionary.Iterate is the mutable iterator path: - // it wires setCallbackWithChild on each returned value, - // so canonicalizing is safe (the wrapper's *atree.Array has a real parentUpdater). - const canonicalizeFieldValues = true v.forEachField( context, iterate, f, - canonicalizeFieldValues, ) } @@ -1781,17 +1776,10 @@ func (v *CompositeValue) ForEachReadOnlyLoadedField( context ContainerMutationContext, f func(fieldName string, fieldValue Value) (resume bool), ) { - // v.dictionary.IterateReadOnlyLoadedValues does NOT wire any parentUpdater - // on the returned field values. - // Canonicalizing such wrappers would shadow later proper-Get wrappers. - // This iteration is internal-only (callbacks must not expose field values to user code), - // so skip canonicalization entirely. - const canonicalizeFieldValues = false v.forEachField( context, v.dictionary.IterateReadOnlyLoadedValues, f, - canonicalizeFieldValues, ) } @@ -1799,19 +1787,13 @@ func (v *CompositeValue) forEachField( context ContainerMutationContext, atreeIterate func(fn atree.MapEntryIterationFunc) error, f func(fieldName string, fieldValue Value) (resume bool), - canonicalizeFieldValues bool, ) { err := atreeIterate(func(key atree.Value, atreeValue atree.Value) (resume bool, err error) { // The field value is handed to `f` without transfer. - // Canonicalize only when the iterator wires real parent callbacks; - // otherwise the cache would be polluted with wrappers - // that have no parentUpdater. - var value Value - if canonicalizeFieldValues { - value = MustConvertStoredContainerElement(context, atreeValue) - } else { - value = MustConvertStoredValue(context, atreeValue) - } + // canonicalizeContainerElement self-filters based on the wrapper's parent-callback state, + // so it is safe to call on any path — wrappers from read-only iterators + // (`IterateReadOnlyLoadedValues`) are returned as-is rather than cached. + value := MustConvertStoredContainerElement(context, atreeValue) CheckInvalidatedResourceOrResourceReference(value, context) resume = f( diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index f6809eb35d..d5aa9ee0c1 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -406,13 +406,7 @@ func (v *DictionaryValue) IterateReadOnly( iterate := func(fn atree.MapEntryIterationFunc) error { return v.dictionary.IterateReadOnly(fn) } - // v.dictionary.IterateReadOnly wires a read-only "trap" parentUpdater - // on each returned element rather than the real parent-notification callback. - // Canonicalizing such wrappers would shadow later proper-Get wrappers - // (which DO have a real parentUpdater), - // causing mutations through the canonical wrapper to trip the trap. - const canonicalizeElements = false - v.iterate(interpreter, iterate, f, canonicalizeElements) + v.iterate(interpreter, iterate, f) } func (v *DictionaryValue) Iterate( @@ -428,11 +422,7 @@ func (v *DictionaryValue) Iterate( fn, ) } - // v.dictionary.Iterate is the mutable iterator path: - // it wires setCallbackWithChild on each returned element, - // so canonicalizing is safe (the wrapper's *atree.Array has a real parentUpdater). - const canonicalizeElements = true - v.iterate(context, iterate, f, canonicalizeElements) + v.iterate(context, iterate, f) } // IterateReadOnlyLoaded iterates over all LOADED key-value pairs of the array. @@ -441,17 +431,10 @@ func (v *DictionaryValue) IterateReadOnlyLoaded( context ContainerMutationContext, f func(key, value Value) (resume bool), ) { - // v.dictionary.IterateReadOnlyLoadedValues does NOT wire any parentUpdater - // on the returned elements. - // Canonicalizing such wrappers would shadow later proper-Get wrappers. - // This iteration is internal-only (callbacks must not expose elements to user code), - // so skip canonicalization entirely. - const canonicalizeElements = false v.iterate( context, v.dictionary.IterateReadOnlyLoadedValues, f, - canonicalizeElements, ) } @@ -459,7 +442,6 @@ func (v *DictionaryValue) iterate( context ContainerMutationContext, atreeIterate func(fn atree.MapEntryIterationFunc) error, f func(key Value, value Value) (resume bool), - canonicalizeElements bool, ) { iterate := func() { err := atreeIterate(func(key, value atree.Value) (resume bool, err error) { @@ -473,18 +455,12 @@ func (v *DictionaryValue) iterate( // atree.OrderedMap iteration provides low-level atree.Value, // convert to high-level interpreter.Value. - // Canonicalize the wrappers only when the iterator wires real parent callbacks; - // otherwise the cache would be polluted with wrappers - // that have no parentUpdater (or worse, a read-only trap). - - var keyValue, valueValue Value - if canonicalizeElements { - keyValue = MustConvertStoredContainerElement(context, key) - valueValue = MustConvertStoredContainerElement(context, value) - } else { - keyValue = MustConvertStoredValue(context, key) - valueValue = MustConvertStoredValue(context, value) - } + // canonicalizeContainerElement self-filters based on the wrapper's + // parent-callback state, so passing wrappers from read-only iterators + // (`IterateReadOnly`, `IterateReadOnlyLoadedValues`) through is safe — + // they will be returned as-is rather than cached. + keyValue := MustConvertStoredContainerElement(context, key) + valueValue := MustConvertStoredContainerElement(context, value) CheckInvalidatedResourceOrResourceReference(keyValue, context) CheckInvalidatedResourceOrResourceReference(valueValue, context) @@ -2060,9 +2036,11 @@ func (i *DictionaryKeyIterator) Next(context ValueIteratorContext) Value { } // atree.Map iterator returns low-level atree.Value, - // convert to high-level interpreter.Value. The key is handed back to - // user code (e.g. loop variable of `for k in dict.keys`), so - // canonicalize to keep aliased references consistent. + // convert to high-level interpreter.Value. + // Although DictionaryKeyIterator is backed by atree's read-only iterator + // (which wires a trap callback on returned elements), + // canonicalizeContainerElement self-filters trap-bearing wrappers + // and returns them as-is, so this is safe. return MustConvertStoredContainerElement(context, atreeKeyValue) } From 2eda5a56ab347279a8ec7048976f16845ac0fcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 2 Jun 2026 14:30:10 -0700 Subject: [PATCH 088/139] adjust staleness tests, they are working now; remove InvalidatedContainerViewError --- interpreter/composite_value_test.go | 7 +- interpreter/errors.go | 28 ----- interpreter/interpreter_expression.go | 8 +- interpreter/stale_atree_view_test.go | 148 ++++++++++++++++---------- 4 files changed, 94 insertions(+), 97 deletions(-) diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 4a425468a5..cfcfb46b1e 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -418,10 +418,9 @@ func TestInterpretSimpleCompositeTypeFunctionMember(t *testing.T) { // "immortal reference" exploit against a resource whose underlying atree map // has been split, and the exploit goes further by attempting to double-spend // the resource by withdrawing through both the original ref and the -// "immortal" one. The runtime must reject the exploit; with the staleness -// check in place, the rejection surfaces at the cast site as -// InvalidatedContainerViewError. See TestInterpretArrayValueIDTracking for -// the full rationale around the two defense layers. +// "immortal" one. The runtime must reject the exploit; +// reference invalidation tracking surfaces it as `InvalidatedResourceReferenceError` +// at the second use of `immortalRef`. func TestInterpretCompositeValueIDTracking(t *testing.T) { t.Parallel() diff --git a/interpreter/errors.go b/interpreter/errors.go index 61bf26fe48..1277128b51 100644 --- a/interpreter/errors.go +++ b/interpreter/errors.go @@ -400,34 +400,6 @@ func (e *InvalidatedResourceError) SetLocationRange(locationRange LocationRange) e.LocationRange = locationRange } -// InvalidatedContainerViewError is reported when a container value wrapper -// (ArrayValue, DictionaryValue, or CompositeValue) is used to mutate the -// underlying atree container, but the wrapper has been "displaced" by a -// structural change (e.g., a slab split/merge or root promotion) triggered -// through a sibling wrapper that shares the same underlying slab tree. -type InvalidatedContainerViewError struct { - LocationRange - ValueID string -} - -var _ errors.InternalError = &InvalidatedContainerViewError{} -var _ HasLocationRange = &InvalidatedContainerViewError{} - -func (*InvalidatedContainerViewError) IsInternalError() {} - -func (e *InvalidatedContainerViewError) Error() string { - return fmt.Sprintf( - "%s container view %s is stale: the underlying slab tree was restructured "+ - "by a mutation through a sibling wrapper", - errors.InternalErrorMessagePrefix, - e.ValueID, - ) -} - -func (e *InvalidatedContainerViewError) SetLocationRange(locationRange LocationRange) { - e.LocationRange = locationRange -} - // DestroyedResourceError is the error which is reported // when a user uses a destroyed resource through a reference type DestroyedResourceError struct { diff --git a/interpreter/interpreter_expression.go b/interpreter/interpreter_expression.go index 4d710d6025..55bfe97534 100644 --- a/interpreter/interpreter_expression.go +++ b/interpreter/interpreter_expression.go @@ -497,10 +497,8 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value return result } -// CheckInvalidatedValueOrValueReference checks whether a value is either: -// - an invalidated resource -// - a value pointing to a stale atree slab -// - or a reference to any of the above +// CheckInvalidatedValueOrValueReference checks whether a value is either an +// invalidated resource or a reference to one. func CheckInvalidatedValueOrValueReference( value Value, context ValueStaticTypeContext, @@ -525,8 +523,6 @@ func CheckInvalidatedValueOrValueReference( // This step is not really needed, since reference tracking is supposed to clear the // `value.Value` if the referenced-value was moved/deleted. // However, have this as a second layer of defensive. - // The staleness check below is also transitively triggered for the - // referenced value through this recursion. CheckInvalidatedValueOrValueReference( value.Value, context, diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 0a3d9e0a93..8e01df54c5 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -27,16 +27,19 @@ import ( . "github.com/onflow/cadence/test_utils/sema_utils" ) -// TestInterpretStaleWrapperMutationRejected covers the case where two Go-level -// wrappers (ArrayValue/DictionaryValue/CompositeValue) for the same atree -// container are created (via repeated `&outer[i]` style access), one wrapper -// triggers a slab split, and the sibling wrapper subsequently attempts a -// mutation. Without the staleness check, the mutation writes into the demoted -// (now-leaf) slab and leaves the canonical view of the container out of sync -// with the live data, manifesting as an element that is invisible to -// iteration but visible to consecutive removals — a clear violation of -// resource semantics. -func TestInterpretStaleWrapperMutationRejected(t *testing.T) { +// TestInterpretAliasedWrapperMutationPropagation covers the case where two +// references (`&outer[i]` taken twice) project two `auth(Mutate) &T` handles +// onto the same atree container. +// Because of the canonical-wrapper cache (`SharedState.canonicalAtreeContainers`), +// both handles resolve to the same Go-level wrapper — +// even after a structural change like a slab split triggered through one of them. +// A subsequent mutation through the second handle must therefore land on the +// live tree and be observable through the first. +// +// The mid-iteration subtests (Map/Filter) cover a separate concern: +// the canonical wrapper is shared, but mutating it while another method has +// an active atree iterator must still raise `ContainerMutatedDuringIterationError`. +func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { t.Parallel() makeEnv := func(t *testing.T) ( @@ -142,15 +145,15 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { return runInvokeWithHandleCheckerError(t, code, nil) } - t.Run("ArrayValue: append via stale wrapper after split", func(t *testing.T) { + t.Run("ArrayValue: append via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() - // Two ArrayValue wrappers point to the same inner inlined-then-grown array. + // Two `auth(Mutate) &[Vault]` references project onto the same inner array. // `ref` appends enough elements to trigger an atree slab split. - // `ref2`'s `*atree.Array` still points to the now-demoted old root data slab. - // The second mutation through `ref2` must be rejected with InvalidatedContainerViewError; - // otherwise an element ends up parked on the demoted slab, - // hidden from iteration but exposed via removeLast. + // Without the canonical wrapper cache, `ref2`'s wrapper would point at the + // now-demoted leaf slab and mutate the wrong slab. + // With the cache, both refs resolve to the same wrapper, + // so `ref2.append(...)` lands on the live tree and is observable through `ref`. err := runInvoke(t, ` access(all) resource Vault { access(all) var balance: UFix64 @@ -179,18 +182,27 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { message: "after split: refs must still observe the same live atree value ID" ) - // This mutation goes through the stale wrapper and must be rejected. + // Append through ref2: both refs share the canonical wrapper, so + // the append lands on the live tree. ref2.append(<- create Vault(balance: 123.456)) + // Both refs observe the same length (1 initial + 200 + 1 = 202). + assert(ref.length == 202, message: "ref must observe ref2's append") + assert(ref2.length == 202, message: "ref2 must observe its own append") + + // The last element appended via ref2 is visible through ref. + assert( + ref[201].balance == 123.456, + message: "ref must read back the value appended via ref2" + ) + destroy outer } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 30, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) - t.Run("ArrayValue: insert via stale wrapper after split", func(t *testing.T) { + t.Run("ArrayValue: insert via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() err := runInvoke(t, ` @@ -223,15 +235,19 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ref2.insert(at: 0, <- create Vault(balance: 123.456)) + assert(ref.length == 202, message: "ref must observe ref2's insert") + assert( + ref[0].balance == 123.456, + message: "ref must read back the value inserted via ref2" + ) + destroy outer } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 29, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) - t.Run("ArrayValue: remove via stale wrapper after split", func(t *testing.T) { + t.Run("ArrayValue: remove via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() err := runInvoke(t, ` @@ -262,18 +278,22 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { message: "after split: refs must still observe the same live atree value ID" ) + // The element at index 0 has balance 0.0 (the original initial element). + // After removal, ref must observe length 200 and the new index 0 must + // be the first appended element (balance 0.0 from UFix64(0)). let extra <- ref2.remove(at: 0) + assert(extra.balance == 0.0, message: "expected to remove the original initial element") destroy extra + assert(ref.length == 200, message: "ref must observe ref2's remove") + destroy outer } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 29, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) - t.Run("DictionaryValue: insert via stale wrapper after split", func(t *testing.T) { + t.Run("DictionaryValue: insert via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() err := runInvoke(t, ` @@ -307,23 +327,30 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ) let old2 <- ref2.insert(key: 9999, <- create Vault(balance: 123.456)) + assert(old2 == nil, message: "key 9999 should not have collided") destroy old2 + // ref observes ref2's insert: 1 (key 0) + 299 (keys 1..299) + 1 (key 9999) = 301. + assert(ref.length == 301, message: "ref must observe ref2's insert") + assert( + ref.containsKey(9999), + message: "ref must see the new key inserted via ref2" + ) + destroy outer } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 31, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) - t.Run("CompositeValue: field assignment via stale wrapper after split", func(t *testing.T) { + t.Run("CompositeValue: field assignment via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() - // Two CompositeValue wrappers point to the same Vault resource (via two + // Two CompositeValue wrappers project onto the same R resource (via two // `&arr[0]` references). `ref` inflates attachments enough to split the - // resource's underlying atree map; `ref2` is now a stale view. The - // subsequent field assignment through `ref2` must be rejected. + // resource's underlying atree map. With the canonical wrapper cache, + // `ref2` resolves to the same wrapper as `ref`, so a field assignment + // through `ref2` must succeed and be observable through `ref`. err := runInvoke(t, ` access(all) entitlement Mod access(all) resource R { @@ -417,18 +444,21 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { message: "after split: refs must still observe the same live atree value ID" ) - // Field assignment through stale wrapper must be rejected. + // Field assignment through ref2 must land on the live wrapper + // and be observable through ref. ref2.setBalance(123.456) + assert( + ref.balance == 123.456, + message: "ref must observe the balance set via ref2" + ) destroy arr } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 95, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) - t.Run("DictionaryValue: remove via stale wrapper after split", func(t *testing.T) { + t.Run("DictionaryValue: remove via sibling wrapper after split propagates", func(t *testing.T) { t.Parallel() err := runInvoke(t, ` @@ -461,37 +491,37 @@ func TestInterpretStaleWrapperMutationRejected(t *testing.T) { ) let removed <- ref2.remove(key: 0) + assert(removed != nil, message: "key 0 should have been present") destroy removed + // ref observes ref2's remove: 1 (key 0) + 299 (keys 1..299) - 1 = 299. + assert(ref.length == 299, message: "ref must observe ref2's remove") + assert( + !ref.containsKey(0), + message: "ref must no longer see the removed key" + ) + destroy outer } `) - var staleViewErr *interpreter.InvalidatedContainerViewError - require.ErrorAs(t, err, &staleViewErr) - assert.Equal(t, 30, staleViewErr.StartPosition().Line) + require.NoError(t, err) }) t.Run("ArrayValue: map procedure mutates sibling wrapper into split mid-iteration", func(t *testing.T) { t.Parallel() - // ArrayValue.Map (and similarly Filter / Reverse / Slice / Concat / ToVariableSized) - // creates an atree iterator once via v.array.Iterator() and walks it across many - // user-callback invocations. - // Between callback invocations there is: - // - no WithContainerMutationPrevention(v.ValueID()) (unlike Iterate); - // - no CheckInvalidatedValueOrValueReference(v, ...) between iterator.Next() calls. - // - // If the user procedure mutates a sibling wrapper of v, the sibling - // mutation triggers a slab split. The active atree iterator continues - // walking the now-demoted slab, potentially yielding duplicate elements, - // skipping elements, or yielding stale state — all silently. + // ArrayValue.Map creates an atree iterator once via v.array.Iterator() + // and walks it across many user-callback invocations. If the user + // procedure mutates the same container through a sibling reference + // (which, with the canonical wrapper cache, is the same wrapper as v), + // the iterator may yield duplicate or stale elements. // - // Safe contract: as soon as v becomes stale, the next iterator.Next() - // (or a per-iteration staleness check) must raise InvalidatedContainerViewError. + // Map wraps the iteration in WithContainerMutationPrevention(v.ValueID()), + // so the mutation through the sibling reference must raise + // ContainerMutatedDuringIterationError on the very first callback. // // Non-resource Int elements are used because Cadence forbids - // `map` on resource arrays even when accessed via reference. The - // iterator-corruption gap is independent of resource-ness. + // `map` on resource arrays even when accessed via reference. err := runInvoke(t, ` access(all) fun main() { // Two ArrayValue wrappers for the same inner Int array. From bee5b40e022189b0ced289185bda94874391282d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 2 Jun 2026 15:03:12 -0700 Subject: [PATCH 089/139] fix value smoke tests: clear canonoical atree containers --- interpreter/interpreter.go | 9 +++++++++ interpreter/values_test.go | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index ebff535f3f..c22fc9cb1d 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -6519,6 +6519,15 @@ func (interpreter *Interpreter) ClearCanonicalAtreeContainer(valueID atree.Value delete(interpreter.SharedState.canonicalAtreeContainers, valueID) } +// ClearAllCanonicalAtreeContainers drops every entry in the canonical wrapper cache. +// Call this when the underlying `atree.Storage` is being swapped out +// (e.g. test harnesses that commit to a ledger and reopen storage): +// cached wrappers hold `*atree.Array`/`*atree.OrderedMap` pointers into the *old* storage, +// and would silently mutate the old storage if returned from a post-swap canonicalization. +func (interpreter *Interpreter) ClearAllCanonicalAtreeContainers() { + clear(interpreter.SharedState.canonicalAtreeContainers) +} + // startResourceTracking starts tracking the life-span of a resource. // A resource can only be associated with one variable at most, at a given time. func (interpreter *Interpreter) startResourceTracking( diff --git a/interpreter/values_test.go b/interpreter/values_test.go index ed50ffb152..e7e0f1e704 100644 --- a/interpreter/values_test.go +++ b/interpreter/values_test.go @@ -91,6 +91,11 @@ func newRandomValueTestInterpreter(t *testing.T) (inter *interpreter.Interpreter nil, runtime.StorageConfig{}, ) + // Swapping the storage invalidates any canonical wrapper still cached + // against the previous one — their `*atree.Array`/`*atree.OrderedMap` + // pointers reference slabs in the now-discarded atree state. + // Drop the cache so post-reset loads canonicalize against fresh wrappers. + inter.ClearAllCanonicalAtreeContainers() } resetStorage() From c4e54d382094a6cd4729bffbb269071dcbac351a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 2 Jun 2026 16:18:33 -0700 Subject: [PATCH 090/139] update atree --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5dd5b32fb7..3a1f48f891 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534 diff --git a/go.sum b/go.sum index 8e4da8d0c8..600cdd526c 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116 h1:CNHl0ZXcB1r1oYz+dDpqASstCquAMYFl9+JwARGkQ08= -github.com/onflow/atree-internal v0.15.1-0.20260602183543-3f65b5776116/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534 h1:V5cTRUIJhaRZze1uvx/x0Li7IFZcEyerWmrnASjg8g4= +github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 59ceb63014d3e50b4801f7c7ae16c5cdd0de965e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 08:26:01 -0700 Subject: [PATCH 091/139] reduce code duplication in canonicalizeContainerElement --- interpreter/storage.go | 89 +++++++++++++++------------------ interpreter/value_array.go | 11 ++++ interpreter/value_composite.go | 11 ++++ interpreter/value_dictionary.go | 11 ++++ 4 files changed, 74 insertions(+), 48 deletions(-) diff --git a/interpreter/storage.go b/interpreter/storage.go index 7c60029360..3a30afc140 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -69,6 +69,28 @@ type AtreeContainerCache interface { ClearCanonicalAtreeContainer(valueID atree.ValueID) } +// canonicalizableContainer is implemented by the Cadence wrappers that back +// onto an atree container (ArrayValue, DictionaryValue, CompositeValue). +// It exposes just enough state for the canonical wrapper cache: +// the underlying atree container (or nil if the wrapper is invalidated), +// so callers can read its `ValueID`, `HasParentUpdater`, and +// `HasReadOnlyMutationCallback` predicates. +type canonicalizableContainer interface { + Value + canonicalAtreeContainer() atreeContainer +} + +// atreeContainer captures the subset of `*atree.Array` / `*atree.OrderedMap` +// methods that the canonical wrapper cache needs. +type atreeContainer interface { + ValueID() atree.ValueID + HasParentUpdater() bool + HasReadOnlyMutationCallback() bool +} + +var _ atreeContainer = &atree.Array{} +var _ atreeContainer = &atree.OrderedMap{} + // canonicalizeContainerElement returns the canonical cached wrapper for a // container element, // populating the cache on first sight and returning the cached wrapper otherwise. @@ -86,56 +108,27 @@ type AtreeContainerCache interface { // This means `MustConvertStoredContainerElement` is safe to call on any path — // it acts as canonicalize-or-passthrough based on the wrapper's actual state. func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value { - switch v := fresh.(type) { - case *ArrayValue: - if v.array == nil || v.isDestroyed { - return fresh - } - if !v.array.HasParentUpdater() || v.array.HasReadOnlyMutationCallback() { - return fresh - } - valueID := v.array.ValueID() - if existing, ok := cache.CanonicalAtreeContainer(valueID).(*ArrayValue); ok { - if existing.array != nil && !existing.isDestroyed { - return existing - } - cache.ClearCanonicalAtreeContainer(valueID) - } - cache.SetCanonicalAtreeContainer(valueID, v) - return v - case *DictionaryValue: - if v.dictionary == nil || v.isDestroyed { - return fresh - } - if !v.dictionary.HasParentUpdater() || v.dictionary.HasReadOnlyMutationCallback() { - return fresh - } - valueID := v.dictionary.ValueID() - if existing, ok := cache.CanonicalAtreeContainer(valueID).(*DictionaryValue); ok { - if existing.dictionary != nil && !existing.isDestroyed { - return existing - } - cache.ClearCanonicalAtreeContainer(valueID) - } - cache.SetCanonicalAtreeContainer(valueID, v) - return v - case *CompositeValue: - if v.dictionary == nil || v.isDestroyed { - return fresh - } - if !v.dictionary.HasParentUpdater() || v.dictionary.HasReadOnlyMutationCallback() { - return fresh - } - valueID := v.dictionary.ValueID() - if existing, ok := cache.CanonicalAtreeContainer(valueID).(*CompositeValue); ok { - if existing.dictionary != nil && !existing.isDestroyed { - return existing - } - cache.ClearCanonicalAtreeContainer(valueID) + freshCacheable, ok := fresh.(canonicalizableContainer) + if !ok { + return fresh + } + container := freshCacheable.canonicalAtreeContainer() + if container == nil { + return fresh + } + if !container.HasParentUpdater() || container.HasReadOnlyMutationCallback() { + return fresh + } + valueID := container.ValueID() + if existing, ok := cache.CanonicalAtreeContainer(valueID).(canonicalizableContainer); ok { + // Same concrete type guarded by same ValueID across the cache; + // a still-valid existing wrapper wins to preserve reference identity. + if existing.canonicalAtreeContainer() != nil { + return existing } - cache.SetCanonicalAtreeContainer(valueID, v) - return v + cache.ClearCanonicalAtreeContainer(valueID) } + cache.SetCanonicalAtreeContainer(valueID, fresh) return fresh } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index d534c31af7..19b16f954f 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -229,6 +229,7 @@ var _ MemberAccessibleValue = &ArrayValue{} var _ ReferenceTrackedResourceKindedValue = &ArrayValue{} var _ IterableValue = &ArrayValue{} var _ atreeContainerBackedValue = &ArrayValue{} +var _ canonicalizableContainer = &ArrayValue{} func (*ArrayValue) IsValue() {} @@ -442,6 +443,16 @@ func (v *ArrayValue) IsDestroyed() bool { return v.isDestroyed } +// canonicalAtreeContainer reports the underlying atree array, +// or nil if the wrapper is invalidated (destroyed or its backing nilled out). +// See `canonicalizeContainerElement` for use. +func (v *ArrayValue) canonicalAtreeContainer() atreeContainer { + if v.array == nil || v.isDestroyed { + return nil + } + return v.array +} + func (v *ArrayValue) Concat( context ValueTransferContext, other *ArrayValue, diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 98c76b7daf..c74765abd5 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -265,6 +265,7 @@ var _ ContractValue = &CompositeValue{} var _ atree.Value = &CompositeValue{} var _ atree.WrapperValue = &CompositeValue{} var _ atreeContainerBackedValue = &CompositeValue{} +var _ canonicalizableContainer = &CompositeValue{} func (*CompositeValue) IsValue() {} @@ -352,6 +353,16 @@ func (v *CompositeValue) IsDestroyed() bool { return v.isDestroyed } +// canonicalAtreeContainer reports the underlying atree map, +// or nil if the wrapper is invalidated (destroyed or its backing nilled out). +// See `canonicalizeContainerElement` for use. +func (v *CompositeValue) canonicalAtreeContainer() atreeContainer { + if v.dictionary == nil || v.isDestroyed { + return nil + } + return v.dictionary +} + func resourceDefaultDestroyEventName(t sema.ContainerType) string { return resourceDefaultDestroyEventPrefix + string(t.ID()) } diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index f55b0ab398..02e82fb322 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -306,6 +306,7 @@ var _ MemberAccessibleValue = &DictionaryValue{} var _ ReferenceTrackedResourceKindedValue = &DictionaryValue{} var _ atreeContainerBackedValue = &DictionaryValue{} var _ IterableValue = &DictionaryValue{} +var _ canonicalizableContainer = &DictionaryValue{} func (*DictionaryValue) IsValue() {} @@ -533,6 +534,16 @@ func (v *DictionaryValue) IsDestroyed() bool { return v.isDestroyed } +// canonicalAtreeContainer reports the underlying atree map, +// or nil if the wrapper is invalidated (destroyed or its backing nilled out). +// See `canonicalizeContainerElement` for use. +func (v *DictionaryValue) canonicalAtreeContainer() atreeContainer { + if v.dictionary == nil || v.isDestroyed { + return nil + } + return v.dictionary +} + func (v *DictionaryValue) isInvalidatedResource(context ValueStaticTypeContext) bool { return v.isDestroyed || (v.dictionary == nil && v.IsResourceKinded(context)) } From d600722c9314bea2eed9ffb00f94e74e77623e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 10:49:20 -0700 Subject: [PATCH 092/139] introduce ContainerElementContext, add atree container cache to VM --- bbq/vm/context.go | 20 +++++++++++++++++++ interpreter/domain_storagemap.go | 8 +++++--- interpreter/interface.go | 7 +++++++ interpreter/interpreter_statement.go | 4 ++-- interpreter/storage.go | 29 +++++++++++++++++++++------- interpreter/value_composite.go | 26 +++++++++++++++++++++---- interpreter/value_range.go | 4 ++-- runtime/contract.go | 1 + 8 files changed, 81 insertions(+), 18 deletions(-) diff --git a/bbq/vm/context.go b/bbq/vm/context.go index ea3e5955e6..f2c4c16f65 100644 --- a/bbq/vm/context.go +++ b/bbq/vm/context.go @@ -50,6 +50,11 @@ type Context struct { containerValueIteration map[atree.ValueID]int destroyedResources map[atree.ValueID]struct{} + // canonicalAtreeContainers deduplicates Cadence-level wrappers + // (ArrayValue, DictionaryValue, CompositeValue) keyed by atree value ID. + // See interpreter.SharedState.canonicalAtreeContainers for the rationale. + canonicalAtreeContainers map[atree.ValueID]interpreter.Value + // semaTypeCache is a cache-alike for temporary storing sema-types by their ID, // to avoid repeated conversions from static-types to sema-types. // This cache-alike is maintained per execution. @@ -89,6 +94,21 @@ func (c *Context) newReusing() *Context { return newContext } +func (c *Context) CanonicalAtreeContainer(valueID atree.ValueID) interpreter.Value { + return c.canonicalAtreeContainers[valueID] +} + +func (c *Context) SetCanonicalAtreeContainer(valueID atree.ValueID, v interpreter.Value) { + if c.canonicalAtreeContainers == nil { + c.canonicalAtreeContainers = map[atree.ValueID]interpreter.Value{} + } + c.canonicalAtreeContainers[valueID] = v +} + +func (c *Context) ClearCanonicalAtreeContainer(valueID atree.ValueID) { + delete(c.canonicalAtreeContainers, valueID) +} + func (c *Context) RecordStorageMutation() { if c.inStorageIteration { c.storageMutatedDuringIteration = true diff --git a/interpreter/domain_storagemap.go b/interpreter/domain_storagemap.go index eda2522f47..cf403a9276 100644 --- a/interpreter/domain_storagemap.go +++ b/interpreter/domain_storagemap.go @@ -164,10 +164,12 @@ func (s *DomainStorageMap) ValueExists(gauge common.ComputationGauge, key Storag // ReadValue returns the value for the given key. // Returns nil if the key does not exist. -func (s *DomainStorageMap) ReadValue(gauge common.Gauge, key StorageMapKey) Value { +// `context` is allowed to be nil for low-level inspection paths +// (in which case canonicalization is skipped). +func (s *DomainStorageMap) ReadValue(context ContainerElementContext, key StorageMapKey) Value { common.UseComputation( - gauge, + context, common.ComputationUsage{ Kind: common.ComputationKindAtreeMapGet, Intensity: 1, @@ -192,7 +194,7 @@ func (s *DomainStorageMap) ReadValue(gauge common.Gauge, key StorageMapKey) Valu // (e.g. as the target of `account.storage.borrow<&T>`), // so two reads of the same path must yield the same canonical wrapper // for reference identity and per-wrapper state alignment. - return MustConvertStoredContainerElement(gauge, storedValue) + return MustConvertStoredContainerElement(context, storedValue) } // WriteValue sets or removes a value in the storage map. diff --git a/interpreter/interface.go b/interpreter/interface.go index 9140148fd5..04bf4ab293 100644 --- a/interpreter/interface.go +++ b/interpreter/interface.go @@ -84,6 +84,7 @@ var _ ValueStaticTypeContext = &Interpreter{} type valueStaticTypeContext interface { common.Gauge + AtreeContainerCache StorageReader TypeConverter IsTypeInfoRecovered(location common.Location) bool @@ -565,6 +566,12 @@ func (NoOpStringContext) MeterComputation(_ common.ComputationUsage) error { return nil } +// Canonical wrapper cache is a no-op for stringification — wrappers +// constructed during MeteredString are discarded after the string is built. +func (NoOpStringContext) CanonicalAtreeContainer(_ atree.ValueID) Value { return nil } +func (NoOpStringContext) SetCanonicalAtreeContainer(_ atree.ValueID, _ Value) {} +func (NoOpStringContext) ClearCanonicalAtreeContainer(_ atree.ValueID) {} + func (NoOpStringContext) WithContainerMutationPrevention(_ atree.ValueID, f func()) { f() } diff --git a/interpreter/interpreter_statement.go b/interpreter/interpreter_statement.go index a6477edad6..25d3f4630b 100644 --- a/interpreter/interpreter_statement.go +++ b/interpreter/interpreter_statement.go @@ -510,7 +510,7 @@ func (interpreter *Interpreter) VisitEmitStatement(statement *ast.EmitStatement) } func extractEventFields( - gauge common.Gauge, + context ContainerElementContext, event *CompositeValue, eventType *sema.CompositeType) []Value { count := len(eventType.ConstructorParameters) @@ -521,7 +521,7 @@ func extractEventFields( eventFields := make([]Value, count) for i, parameter := range eventType.ConstructorParameters { - value := event.GetField(gauge, parameter.Identifier) + value := event.GetField(context, parameter.Identifier) eventFields[i] = value } diff --git a/interpreter/storage.go b/interpreter/storage.go index 3a30afc140..fc33573b36 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -69,6 +69,18 @@ type AtreeContainerCache interface { ClearCanonicalAtreeContainer(valueID atree.ValueID) } +// ContainerElementContext is the typed parameter for reading and +// canonicalizing an atree container element: +// memory and computation metering, plus access to the canonical wrapper cache. +// A nil interface value is accepted by `MustConvertStoredContainerElement` +// and disables canonicalization +// (matches the previous untyped-gauge behavior, +// used by low-level inspection paths and tests). +type ContainerElementContext interface { + common.Gauge + AtreeContainerCache +} + // canonicalizableContainer is implemented by the Cadence wrappers that back // onto an atree container (ArrayValue, DictionaryValue, CompositeValue). // It exposes just enough state for the canonical wrapper cache: @@ -135,8 +147,8 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value // MustConvertStoredContainerElement wraps an atree value retrieved as a // container element (e.g. via `*atree.Array.Get` or `*atree.OrderedMap.Get`) // as a Cadence-level `Value`, -// deduplicating the resulting wrapper via the canonical wrapper cache when both: -// - the gauge is an `AtreeContainerCache`, and +// deduplicating the resulting wrapper via the canonical wrapper cache when: +// - the context is non-nil, and // - the wrapper has a real parent-notification callback // (i.e. it came from a `Get` or mutable-iterator path, // not from a read-only iterator). @@ -145,12 +157,15 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value // so callers do not need to know which atree path produced `value`: // passing in a read-only-iterator wrapper is safe; // it just won't be cached. -func MustConvertStoredContainerElement(gauge common.MemoryGauge, value atree.Value) Value { - result := MustConvertStoredValue(gauge, value) - if cache, ok := gauge.(AtreeContainerCache); ok { - return canonicalizeContainerElement(cache, result) +// +// A nil `context` skips canonicalization entirely +// (used by tests and the low-level decode path). +func MustConvertStoredContainerElement(context ContainerElementContext, value atree.Value) Value { + result := MustConvertStoredValue(context, value) + if context == nil { + return result } - return result + return canonicalizeContainerElement(context, result) } // ConvertStoredValue wraps the given atree value as a Cadence-level diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index c74765abd5..9cddfeea37 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -985,10 +985,10 @@ func formatComposite( return format.Composite(typeId, preparedFields) } -func (v *CompositeValue) GetField(gauge common.Gauge, name string) Value { +func (v *CompositeValue) GetField(context ContainerElementContext, name string) Value { common.UseComputation( - gauge, + context, common.ComputationUsage{ Kind: common.ComputationKindAtreeMapGet, Intensity: 1, @@ -1008,7 +1008,7 @@ func (v *CompositeValue) GetField(gauge common.Gauge, name string) Value { panic(errors.NewExternalError(err)) } - return MustConvertStoredContainerElement(gauge, storedValue) + return MustConvertStoredContainerElement(context, storedValue) } func (v *CompositeValue) Equal(context ValueComparisonContext, other Value) bool { @@ -1067,7 +1067,25 @@ func (v *CompositeValue) HashInput(gauge common.Gauge, scratch []byte) []byte { if v.Kind == common.CompositeKindEnum { typeID := v.TypeID() - rawValue := v.GetField(gauge, sema.EnumRawValueFieldName) + // Read the enum's raw value directly via atree: + // `GetField` requires a `ContainerReadContext` (so it can canonicalize + // container-element wrappers via `MustConvertStoredContainerElement`), + // but enum raw values are primitives, + // so canonicalization is a no-op and the wider context is unnecessary. + // The `HashableValue.HashInput` contract only requires `common.Gauge`. + common.UseComputation(gauge, common.ComputationUsage{ + Kind: common.ComputationKindAtreeMapGet, + Intensity: 1, + }) + storedRawValue, err := v.dictionary.Get( + StringAtreeValueComparator, + StringAtreeValueHashInput, + StringAtreeValue(sema.EnumRawValueFieldName), + ) + if err != nil { + panic(errors.NewExternalError(err)) + } + rawValue := MustConvertStoredValue(gauge, storedRawValue) rawValueHashInput := rawValue.(HashableValue). HashInput(gauge, scratch) diff --git a/interpreter/value_range.go b/interpreter/value_range.go index 4361fbe4e5..eb0952ff68 100644 --- a/interpreter/value_range.go +++ b/interpreter/value_range.go @@ -232,9 +232,9 @@ func InclusiveRangeContains( return BoolValue(result) } -func getFieldAsIntegerValue(gauge common.Gauge, rangeValue *CompositeValue, name string) IntegerValue { +func getFieldAsIntegerValue(context ContainerElementContext, rangeValue *CompositeValue, name string) IntegerValue { return convertAndAssertIntegerValue( - rangeValue.GetField(gauge, name), + rangeValue.GetField(context, name), ) } diff --git a/runtime/contract.go b/runtime/contract.go index d6b71a8bc1..250a6155fd 100644 --- a/runtime/contract.go +++ b/runtime/contract.go @@ -25,6 +25,7 @@ import ( type contractLoader interface { interpreter.StorageMutationTracker + interpreter.AtreeContainerCache common.Gauge } From 7348c62d7898bc65075cd7a11c140c2f6c9b2025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 11:10:51 -0700 Subject: [PATCH 093/139] run tests also with VM --- interpreter/array_test.go | 49 +++++++------ interpreter/composite_value_test.go | 37 +++++----- interpreter/dictionary_test.go | 34 ++++----- interpreter/stale_atree_view_test.go | 103 +++++++++++++++------------ 4 files changed, 120 insertions(+), 103 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index cbdd9ee9a9..3e2ee9e875 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -31,7 +31,6 @@ import ( "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" "github.com/onflow/cadence/stdlib" - "github.com/onflow/cadence/test_utils" . "github.com/onflow/cadence/test_utils/common_utils" . "github.com/onflow/cadence/test_utils/sema_utils" ) @@ -183,35 +182,35 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // liveValueIDOf exposes the underlying atree array's current value ID so // the Cadence code can confirm the slab split actually occurred. - // - // It takes the *name* of the reference variable rather than the reference - // itself: passing the stale ref directly would trip the staleness check on - // the call-site expression and shadow the real exploit-site error. - // Resolving the variable internally via GetValueOfVariable bypasses the - // per-expression check. + // It takes a reference, generalized through `&AnyResource` so both + // `auth(Mutate) &[Vault]` and the post-cast `&AnyResource` form are accepted. liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "name", - TypeAnnotation: sema.StringTypeAnnotation, + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), }, }, sema.StringTypeAnnotation, ), "", func( - context interpreter.NativeFunctionContext, + _ interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - name := args[0].(*interpreter.StringValue).Str - ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) + ref := args[0].(*interpreter.EphemeralReferenceValue) arrayValue := ref.Value.(*interpreter.ArrayValue) return interpreter.NewUnmeteredStringValue(arrayValue.ValueID().String()) }, @@ -227,7 +226,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -249,7 +248,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // Both refs see the same live atree value ID. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -263,7 +262,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { // Both refs share the canonical wrapper, so the slab split through ref // is visible to ref2; their live value IDs continue to agree. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -340,7 +339,7 @@ func TestInterpretArrayAliasedMutationConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -440,7 +439,7 @@ func TestInterpretArrayForLoopAliasingConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -540,7 +539,7 @@ func TestInterpretArraySliceDoesNotInvalidateAliases(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) fun main() { @@ -697,7 +696,7 @@ func TestInterpretArrayAsReferenceContainerMethodAliasingConsistency(t *testing. } `, tc.setup, tc.expr, tc.alias, tc.mutVia) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, code, ParseCheckAndInterpretOptions{ @@ -847,7 +846,7 @@ func TestInterpretOptionalContainerAliasingConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -933,7 +932,7 @@ func TestInterpretOptionalContainerAliasingViaArrayIndex(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -1006,7 +1005,7 @@ func TestInterpretOptionalContainerAliasingViaDictionaryLookup(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -1087,7 +1086,7 @@ func TestInterpretArrayPromoteRootAliasingConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -1172,7 +1171,7 @@ func TestInterpretArraySplitAndPromoteAliasingConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index cfcfb46b1e..d1fdb667a7 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -31,7 +31,6 @@ import ( "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" "github.com/onflow/cadence/stdlib" - "github.com/onflow/cadence/test_utils" . "github.com/onflow/cadence/test_utils/common_utils" . "github.com/onflow/cadence/test_utils/interpreter_utils" . "github.com/onflow/cadence/test_utils/sema_utils" @@ -431,33 +430,34 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // liveValueIDOf exposes the underlying atree map's current value ID for a // composite resource so the Cadence code can confirm the slab split - // actually occurred. It takes the *name* of the reference variable so - // resolving the (potentially stale) reference happens inside Go via - // GetValueOfVariable, bypassing the per-expression staleness check that - // would otherwise fire at this call. + // actually occurred. It takes a reference, generalized through `&AnyResource`. liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "name", - TypeAnnotation: sema.StringTypeAnnotation, + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), }, }, sema.StringTypeAnnotation, ), "", func( - context interpreter.NativeFunctionContext, + _ interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - name := args[0].(*interpreter.StringValue).Str - ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) + ref := args[0].(*interpreter.EphemeralReferenceValue) composite := ref.Value.(*interpreter.CompositeValue) return interpreter.NewUnmeteredStringValue(composite.ValueID().String()) }, @@ -473,7 +473,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) entitlement Withdraw @@ -572,7 +572,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // wrappers, so both refs hold the same CompositeValue and observe the // same underlying atree map. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -588,7 +588,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { // Both refs share the canonical wrapper, so the slab split through ref // is visible to ref2; their live value IDs continue to agree. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -646,7 +646,8 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { _, err = inter.Invoke("main") RequireError(t, err) var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) + require.ErrorAs(t, err, &invalidatedResourceReferenceError) + assert.Equal(t, 130, invalidatedResourceReferenceError.StartPosition().Line) } // TestInterpretCompositeAliasedMutationConsistency is the CompositeValue @@ -673,7 +674,7 @@ func TestInterpretCompositeAliasedMutationConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) entitlement Withdraw @@ -836,7 +837,7 @@ func TestInterpretCompositeFieldAliasedMutationConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -928,7 +929,7 @@ func TestInterpretCompositeForEachAttachmentAliasingConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index 524f1cb7c2..93210ec5d5 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -31,7 +31,6 @@ import ( "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" "github.com/onflow/cadence/stdlib" - "github.com/onflow/cadence/test_utils" . "github.com/onflow/cadence/test_utils/common_utils" . "github.com/onflow/cadence/test_utils/sema_utils" ) @@ -141,33 +140,35 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { })) // liveValueIDOf exposes the underlying atree map's current value ID so the - // Cadence code can confirm the slab split actually occurred. It takes the - // *name* of the reference variable so that resolving the (potentially - // stale) reference happens inside Go via GetValueOfVariable, bypassing the - // per-expression staleness check that would otherwise fire at this call. + // Cadence code can confirm the slab split actually occurred. + // It takes a reference, generalized through `&AnyResource`. liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( "liveValueIDOf", sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { - Label: sema.ArgumentLabelNotRequired, - Identifier: "name", - TypeAnnotation: sema.StringTypeAnnotation, + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), }, }, sema.StringTypeAnnotation, ), "", func( - context interpreter.NativeFunctionContext, + _ interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - name := args[0].(*interpreter.StringValue).Str - ref := context.GetValueOfVariable(name).(*interpreter.EphemeralReferenceValue) + ref := args[0].(*interpreter.EphemeralReferenceValue) dictValue := ref.Value.(*interpreter.DictionaryValue) return interpreter.NewUnmeteredStringValue(dictValue.ValueID().String()) }, @@ -183,7 +184,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { interpreter.Declare(baseActivation, liveValueIDOfFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { @@ -205,7 +206,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { // Both refs see the same live atree value ID. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -220,7 +221,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { // Both refs share the canonical wrapper, so the slab split through ref // is visible to ref2; their live value IDs continue to agree. assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOf(ref) == liveValueIDOf(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -262,7 +263,8 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { _, err = inter.Invoke("main") RequireError(t, err) var invalidatedResourceReferenceError *interpreter.InvalidatedResourceReferenceError - assert.ErrorAs(t, err, &invalidatedResourceReferenceError) + require.ErrorAs(t, err, &invalidatedResourceReferenceError) + assert.Equal(t, 53, invalidatedResourceReferenceError.StartPosition().Line) } // TestInterpretDictionaryAliasedMutationConsistency is the DictionaryValue @@ -288,7 +290,7 @@ func TestInterpretDictionaryAliasedMutationConsistency(t *testing.T) { interpreter.Declare(baseActivation, logFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, ` access(all) resource Vault { diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 8e01df54c5..56f86206b1 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -23,7 +23,6 @@ import ( "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" "github.com/onflow/cadence/stdlib" - "github.com/onflow/cadence/test_utils" . "github.com/onflow/cadence/test_utils/sema_utils" ) @@ -42,47 +41,38 @@ import ( func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { t.Parallel() - makeEnv := func(t *testing.T) ( - *sema.VariableActivation, - *activations.Activation[interpreter.Variable], - ) { - - // liveValueIDOf exposes the underlying atree container's current value ID - // so the Cadence code can confirm the slab split actually occurred - // before attempting the stale-wrapper mutation. - // - // It takes the *name* of the reference variable rather than the - // reference itself: passing a stale reference as a function argument - // would trip the staleness check during expression evaluation (every - // expression result goes through CheckInvalidatedValueOrValueReference, - // which recursively descends into reference values), so the check would - // fire at the call-site rather than the mutation. Resolving the - // variable internally via GetValueOfVariable bypasses the per- - // expression check. - liveValueIDOfFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( - "liveValueIDOf", + // liveValueIDOf{Resource,Struct} expose the underlying atree container's + // value ID for any wrapped ArrayValue / DictionaryValue / CompositeValue. + // Two functions are needed because Cadence reference subtyping puts resource + // and struct references in disjoint hierarchies: + // `&AnyResource` won't accept struct refs and vice versa. + makeLiveValueIDOfFunction := func( + t *testing.T, + name string, + refType sema.Type, + ) stdlib.StandardLibraryValue { + return stdlib.NewInterpreterStandardLibraryStaticFunction( + name, sema.NewSimpleFunctionType( sema.FunctionPurityImpure, []sema.Parameter{ { Label: sema.ArgumentLabelNotRequired, - Identifier: "name", - TypeAnnotation: sema.StringTypeAnnotation, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation(refType), }, }, sema.StringTypeAnnotation, ), "", func( - context interpreter.NativeFunctionContext, + _ interpreter.NativeFunctionContext, _ interpreter.TypeArgumentsIterator, _ interpreter.ArgumentTypesIterator, _ interpreter.Value, args []interpreter.Value, ) interpreter.Value { - name := args[0].(*interpreter.StringValue).Str - value := context.GetValueOfVariable(name) - ref := value.(*interpreter.EphemeralReferenceValue) + ref := args[0].(*interpreter.EphemeralReferenceValue) var id string switch v := ref.Value.(type) { case *interpreter.ArrayValue: @@ -97,13 +87,38 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { return interpreter.NewUnmeteredStringValue(id) }, ) + } + + makeEnv := func(t *testing.T) ( + *sema.VariableActivation, + *activations.Activation[interpreter.Variable], + ) { + + liveValueIDOfResourceFunction := makeLiveValueIDOfFunction( + t, + "liveValueIDOfResource", + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ) + liveValueIDOfStructFunction := makeLiveValueIDOfFunction( + t, + "liveValueIDOfStruct", + &sema.ReferenceType{ + Type: sema.AnyStructType, + Authorization: sema.UnauthorizedAccess, + }, + ) baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) - baseValueActivation.DeclareValue(liveValueIDOfFunction) + baseValueActivation.DeclareValue(liveValueIDOfResourceFunction) + baseValueActivation.DeclareValue(liveValueIDOfStructFunction) baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) - interpreter.Declare(baseActivation, liveValueIDOfFunction) + interpreter.Declare(baseActivation, liveValueIDOfResourceFunction) + interpreter.Declare(baseActivation, liveValueIDOfStructFunction) interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) return baseValueActivation, baseActivation @@ -118,7 +133,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { handleCheckerError func(error), ) error { baseValueActivation, baseActivation := makeEnv(t) - inter, err := test_utils.ParseCheckAndInterpretWithAtreeValidationsDisabled( + invokable, err := parseCheckAndPrepareWithAtreeValidationsDisabled( t, code, ParseCheckAndInterpretOptions{ @@ -138,7 +153,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { }, ) require.NoError(t, err) - _, err = inter.Invoke("main") + _, err = invokable.Invoke("main") return err } runInvoke := func(t *testing.T, code string) error { @@ -167,7 +182,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -178,7 +193,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { } assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -218,7 +233,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -229,7 +244,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { } assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -263,7 +278,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Vault] assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -274,7 +289,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { } assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -309,7 +324,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -322,7 +337,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { } assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -428,7 +443,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &arr[0] as auth(Mod) &R assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -440,7 +455,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { ref[A6]!.inflate() assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -474,7 +489,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &{Int: Vault} assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -486,7 +501,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { } assert( - liveValueIDOf("ref") == liveValueIDOf("ref2"), + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), message: "after split: refs must still observe the same live atree value ID" ) @@ -531,7 +546,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { let ref2 = &outer[0] as auth(Mutate) &[Int] assert( - liveValueIDOf("ref1") == liveValueIDOf("ref2"), + liveValueIDOfStruct(ref1) == liveValueIDOfStruct(ref2), message: "before split: both refs should observe the same live atree value ID" ) @@ -558,7 +573,7 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { // If the gap is unpatched, ref's wrapper is now stale and mapped // contains corrupt data (wrong length, duplicates, or stale reads). assert( - liveValueIDOf("ref1") == liveValueIDOf("ref2"), + liveValueIDOfStruct(ref1) == liveValueIDOfStruct(ref2), message: "after callback-induced split: refs must still observe the same live atree value ID" ) From 30e8aaa645ee7d8e583f05fa37ce1dbd9985b81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 11:29:47 -0700 Subject: [PATCH 094/139] update atree --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f5125a4835..e854c0172d 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 diff --git a/go.sum b/go.sum index d2ded5eb90..69a6e8684d 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e h1:N9a/eHZBf8mQ8+GgiB9428oKLPJhuAhHbWdBjLqeqQ4= -github.com/onflow/atree-internal v0.15.1-0.20260529185539-5b37352a5f5e/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 h1:WdpfAE+AWfS8/t5vJejMi7A6I3Bq7/PaPEZDN7tEsFw= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From c5692206ecc1e3160af05384072bfd12e660da93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 11:33:10 -0700 Subject: [PATCH 095/139] fix lint --- bbq/opcode/print_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bbq/opcode/print_test.go b/bbq/opcode/print_test.go index 3964e0f98e..aaa9694cb9 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -270,16 +270,16 @@ func TestPrintInstruction(t *testing.T) { "False": {byte(False)}, "Nil": {byte(Nil)}, "Void": {byte(Void)}, - "GetField fieldName:258 accessedType:258": {byte(GetField), 1, 2, 1, 2}, - "SetField fieldName:258 accessedType:258": {byte(SetField), 1, 2, 1, 2}, - "RemoveField fieldName:258": {byte(RemoveField), 1, 2}, - "GetTypeIndex indexedType:258 indexingType:258": {byte(GetTypeIndex), 1, 2, 1, 2}, - "SetTypeIndex indexedType:258 indexingType:258": {byte(SetTypeIndex), 1, 2, 1, 2}, - "RemoveTypeIndex indexedType:258 indexingType:258": {byte(RemoveTypeIndex), 1, 2, 1, 2}, - "SetAttachmentBase": {byte(SetAttachmentBase), 1, 2, 3}, + "GetField fieldName:258 accessedType:258": {byte(GetField), 1, 2, 1, 2}, + "SetField fieldName:258 accessedType:258": {byte(SetField), 1, 2, 1, 2}, + "RemoveField fieldName:258": {byte(RemoveField), 1, 2}, + "GetTypeIndex indexedType:258 indexingType:258": {byte(GetTypeIndex), 1, 2, 1, 2}, + "SetTypeIndex indexedType:258 indexingType:258": {byte(SetTypeIndex), 1, 2, 1, 2}, + "RemoveTypeIndex indexedType:258 indexingType:258": {byte(RemoveTypeIndex), 1, 2, 1, 2}, + "SetAttachmentBase": {byte(SetAttachmentBase), 1, 2, 3}, "SetIndex indexedType:258 skipValueStalenessCheck:true": {byte(SetIndex), 1, 2, 1}, - "GetIndex indexedType:258": {byte(GetIndex), 1, 2}, - "RemoveIndex indexedType:258 pushPlaceholder:true": {byte(RemoveIndex), 1, 2, 1}, + "GetIndex indexedType:258": {byte(GetIndex), 1, 2}, + "RemoveIndex indexedType:258 pushPlaceholder:true": {byte(RemoveIndex), 1, 2, 1}, "Drop": {byte(Drop)}, "Dup": {byte(Dup)}, "Not": {byte(Not)}, From 9d1e1a19ae4273203dd1d24f49e7339f93c1dff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 12:45:28 -0700 Subject: [PATCH 096/139] update atree --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3a1f48f891..23d10ace76 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.1 diff --git a/go.sum b/go.sum index 600cdd526c..d4935d91d0 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534 h1:V5cTRUIJhaRZze1uvx/x0Li7IFZcEyerWmrnASjg8g4= -github.com/onflow/atree-internal v0.15.1-0.20260602230926-6f8e5cfd3534/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.16.1-rc.1 h1:ytBR6w4I1iW26vYwRxHIFHPgsIirZ66LprBYvos033A= +github.com/onflow/atree-internal v0.16.1-rc.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 3fe1e3f9e63c66ca9f6e818c9fc6e3be6ff2bb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 12:46:55 -0700 Subject: [PATCH 097/139] bring back value ID defensive check --- interpreter/errors.go | 34 +++++++++++++++++++++++++++ interpreter/interpreter_expression.go | 21 +++++++++++++++-- interpreter/storage.go | 15 ++++++++++++ interpreter/value.go | 7 ------ interpreter/value_array.go | 32 +++++++++++++++++++++---- interpreter/value_composite.go | 21 +++++++++++++---- interpreter/value_dictionary.go | 21 +++++++++++++---- interpreter/value_some.go | 2 +- 8 files changed, 128 insertions(+), 25 deletions(-) diff --git a/interpreter/errors.go b/interpreter/errors.go index 1277128b51..f29299e656 100644 --- a/interpreter/errors.go +++ b/interpreter/errors.go @@ -400,6 +400,40 @@ func (e *InvalidatedResourceError) SetLocationRange(locationRange LocationRange) e.LocationRange = locationRange } +// InvalidatedContainerViewError is a defensive runtime invariant violation, +// reported when a container value wrapper (ArrayValue, DictionaryValue, or +// CompositeValue) is used after its underlying atree container has been +// invalidated, or when the wrapper's cached value ID has diverged from the +// underlying atree container's live value ID. +// +// With atree's shared-state design and Cadence's canonical wrapper cache, +// neither divergence should be observable in practice: +// all wrappers backing the same logical container share a single Go object, +// and atree keeps the root slab ID stable across structural changes. +// This check fires only if those invariants are violated by a bug. +type InvalidatedContainerViewError struct { + LocationRange + ValueID string +} + +var _ errors.InternalError = &InvalidatedContainerViewError{} +var _ HasLocationRange = &InvalidatedContainerViewError{} + +func (*InvalidatedContainerViewError) IsInternalError() {} + +func (e *InvalidatedContainerViewError) Error() string { + return fmt.Sprintf( + "%s container view %s is invalidated: "+ + "the wrapper's underlying atree container is nil or its cached value ID has diverged from the live one", + errors.InternalErrorMessagePrefix, + e.ValueID, + ) +} + +func (e *InvalidatedContainerViewError) SetLocationRange(locationRange LocationRange) { + e.LocationRange = locationRange +} + // DestroyedResourceError is the error which is reported // when a user uses a destroyed resource through a reference type DestroyedResourceError struct { diff --git a/interpreter/interpreter_expression.go b/interpreter/interpreter_expression.go index 55bfe97534..cf18765121 100644 --- a/interpreter/interpreter_expression.go +++ b/interpreter/interpreter_expression.go @@ -497,8 +497,12 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value return result } -// CheckInvalidatedValueOrValueReference checks whether a value is either an -// invalidated resource or a reference to one. +// CheckInvalidatedValueOrValueReference checks whether a value is either: +// - an invalidated resource, or a reference to one +// - an atree-backed container wrapper whose underlying atree container has +// been invalidated, or whose cached value ID has diverged from the live one +// (defensive invariant check; +// see `AtreeBackedValue` and `InvalidatedContainerViewError`) func CheckInvalidatedValueOrValueReference( value Value, context ValueStaticTypeContext, @@ -523,12 +527,25 @@ func CheckInvalidatedValueOrValueReference( // This step is not really needed, since reference tracking is supposed to clear the // `value.Value` if the referenced-value was moved/deleted. // However, have this as a second layer of defensive. + // The atree-view staleness check below is also transitively triggered for the + // referenced value through this recursion. CheckInvalidatedValueOrValueReference( value.Value, context, ) } } + + // Defensive invariant: every code path that already calls this helper + // transparently gains the atree-view staleness check. + // With atree's shared-state design and Cadence's canonical wrapper cache + // this should never fire; if it does, an internal invariant has been violated. + if atreeBackedValue, ok := value.(AtreeBackedValue); ok && + atreeBackedValue.isStaleAtreeView() { + panic(&InvalidatedContainerViewError{ + ValueID: atreeBackedValue.ValueID().String(), + }) + } } func (interpreter *Interpreter) VisitBinaryExpression(expression *ast.BinaryExpression) Value { diff --git a/interpreter/storage.go b/interpreter/storage.go index fc33573b36..513ef010ed 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -92,6 +92,21 @@ type canonicalizableContainer interface { canonicalAtreeContainer() atreeContainer } +// AtreeBackedValue is implemented by Cadence container wrappers +// (ArrayValue, DictionaryValue, CompositeValue) that back onto an atree container. +// `isStaleAtreeView` is a defensive invariant check +// used by `CheckInvalidatedValueOrValueReference` +// to detect a wrapper whose underlying atree container has been invalidated +// (nilled out by Destroy/Transfer) or whose cached value ID has diverged +// from the live one. +// With atree's shared-state design and Cadence's canonical wrapper cache +// neither condition should be observable in practice; the check is a safety net. +type AtreeBackedValue interface { + Value + ValueID() atree.ValueID + isStaleAtreeView() bool +} + // atreeContainer captures the subset of `*atree.Array` / `*atree.OrderedMap` // methods that the canonical wrapper cache needs. type atreeContainer interface { diff --git a/interpreter/value.go b/interpreter/value.go index 5f5fb71324..ad47a66a07 100644 --- a/interpreter/value.go +++ b/interpreter/value.go @@ -281,13 +281,6 @@ type ValueIterator interface { ValueID() (atree.ValueID, bool) } -// atreeContainerBackedValue is an interface for values using atree containers -// (atree.Array or atree.OrderedMap) under the hood. -type atreeContainerBackedValue interface { - Value - isAtreeContainerBackedValue() -} - func safeAdd(a, b int) int { // INT32-C if (b > 0) && (a > (goMaxInt - b)) { diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 19b16f954f..5ff941e034 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -39,6 +39,15 @@ type ArrayValue struct { isResourceKinded *bool elementSize uint isDestroyed bool + + // valueID is the atree value ID captured at construction time. + // With atree's shared-state design + Cadence's canonical wrapper cache, + // the value ID returned by `v.array.ValueID()` is stable for the lifetime + // of the wrapper, so the cached value matches the live one. + // The cache is used by `isStaleAtreeView` as a defensive invariant check + // to catch unexpected divergence (and a stable identifier when + // `v.array` has been nilled out by Destroy/Transfer). + valueID atree.ValueID } func NewArrayValue( @@ -216,6 +225,7 @@ func newArrayValueFromAtreeArray( return &ArrayValue{ Type: staticType, array: atreeArray, + valueID: atreeArray.ValueID(), elementSize: elementSize, } } @@ -228,13 +238,11 @@ var _ ValueIndexableValue = &ArrayValue{} var _ MemberAccessibleValue = &ArrayValue{} var _ ReferenceTrackedResourceKindedValue = &ArrayValue{} var _ IterableValue = &ArrayValue{} -var _ atreeContainerBackedValue = &ArrayValue{} +var _ AtreeBackedValue = &ArrayValue{} var _ canonicalizableContainer = &ArrayValue{} func (*ArrayValue) IsValue() {} -func (*ArrayValue) isAtreeContainerBackedValue() {} - func (v *ArrayValue) Accept(context ValueVisitContext, visitor Visitor) { descend := visitor.VisitArrayValue(context, v) if !descend { @@ -1628,7 +1636,7 @@ func (v *ArrayValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.array.ValueID()) + cache.ClearCanonicalAtreeContainer(v.valueID) } v.array = nil } @@ -1744,7 +1752,21 @@ func (v *ArrayValue) StorageAddress() atree.Address { } func (v *ArrayValue) ValueID() atree.ValueID { - return v.array.ValueID() + return v.valueID +} + +// isStaleAtreeView reports whether this wrapper's underlying atree array has +// been invalidated (nilled out by Destroy/Transfer) or its live value ID has +// diverged from the cached one captured at construction. +// With atree's shared-state design and Cadence's canonical wrapper cache, +// neither condition should be observable in practice; +// this is a defensive invariant check used by +// `CheckInvalidatedValueOrValueReference`. +func (v *ArrayValue) isStaleAtreeView() bool { + if v.array == nil { + return true + } + return v.array.ValueID() != v.valueID } func (v *ArrayValue) GetOwner() common.Address { diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 9cddfeea37..50bd5d5652 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -62,6 +62,10 @@ type CompositeValue struct { QualifiedIdentifier string Kind common.CompositeKind isDestroyed bool + + // valueID — defensive cache of the atree value ID captured at construction. + // See `ArrayValue.valueID` for the full rationale. + valueID atree.ValueID } type ComputedField func(ValueTransferContext, *CompositeValue) Value @@ -250,6 +254,7 @@ func NewCompositeValueFromAtreeMap( return &CompositeValue{ dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), Location: typeInfo.Location, QualifiedIdentifier: typeInfo.QualifiedIdentifier, Kind: typeInfo.Kind, @@ -264,13 +269,11 @@ var _ ReferenceTrackedResourceKindedValue = &CompositeValue{} var _ ContractValue = &CompositeValue{} var _ atree.Value = &CompositeValue{} var _ atree.WrapperValue = &CompositeValue{} -var _ atreeContainerBackedValue = &CompositeValue{} +var _ AtreeBackedValue = &CompositeValue{} var _ canonicalizableContainer = &CompositeValue{} func (*CompositeValue) IsValue() {} -func (*CompositeValue) isAtreeContainerBackedValue() {} - func (v *CompositeValue) Accept(context ValueVisitContext, visitor Visitor) { descend := visitor.VisitCompositeValue(context, v) if !descend { @@ -1576,7 +1579,7 @@ func (v *CompositeValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.dictionary.ValueID()) + cache.ClearCanonicalAtreeContainer(v.valueID) } v.dictionary = nil } @@ -1846,7 +1849,15 @@ func (v *CompositeValue) StorageAddress() atree.Address { } func (v *CompositeValue) ValueID() atree.ValueID { - return v.dictionary.ValueID() + return v.valueID +} + +// isStaleAtreeView — defensive invariant check, see `ArrayValue.isStaleAtreeView`. +func (v *CompositeValue) isStaleAtreeView() bool { + if v.dictionary == nil { + return true + } + return v.dictionary.ValueID() != v.valueID } func (v *CompositeValue) RemoveField( diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 02e82fb322..3aed530675 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -39,6 +39,10 @@ type DictionaryValue struct { dictionary *atree.OrderedMap isDestroyed bool elementSize uint + + // valueID — defensive cache of the atree value ID captured at construction. + // See `ArrayValue.valueID` for the full rationale. + valueID atree.ValueID } func NewDictionaryValue( @@ -293,6 +297,7 @@ func newDictionaryValueFromAtreeMap( return &DictionaryValue{ Type: staticType, dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), elementSize: elementSize, } } @@ -304,14 +309,12 @@ var _ EquatableValue = &DictionaryValue{} var _ ValueIndexableValue = &DictionaryValue{} var _ MemberAccessibleValue = &DictionaryValue{} var _ ReferenceTrackedResourceKindedValue = &DictionaryValue{} -var _ atreeContainerBackedValue = &DictionaryValue{} +var _ AtreeBackedValue = &DictionaryValue{} var _ IterableValue = &DictionaryValue{} var _ canonicalizableContainer = &DictionaryValue{} func (*DictionaryValue) IsValue() {} -func (*DictionaryValue) isAtreeContainerBackedValue() {} - func (v *DictionaryValue) Accept(context ValueVisitContext, visitor Visitor) { descend := visitor.VisitDictionaryValue(context, v) if !descend { @@ -1705,7 +1708,7 @@ func (v *DictionaryValue) Transfer( InvalidateReferencedResources(context, v) if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.dictionary.ValueID()) + cache.ClearCanonicalAtreeContainer(v.valueID) } v.dictionary = nil } @@ -1840,7 +1843,15 @@ func (v *DictionaryValue) StorageAddress() atree.Address { } func (v *DictionaryValue) ValueID() atree.ValueID { - return v.dictionary.ValueID() + return v.valueID +} + +// isStaleAtreeView — defensive invariant check, see `ArrayValue.isStaleAtreeView`. +func (v *DictionaryValue) isStaleAtreeView() bool { + if v.dictionary == nil { + return true + } + return v.dictionary.ValueID() != v.valueID } func (v *DictionaryValue) SemaType(typeConverter TypeConverter) *sema.DictionaryType { diff --git a/interpreter/value_some.go b/interpreter/value_some.go index bb527e8551..8bfde7ccd4 100644 --- a/interpreter/value_some.go +++ b/interpreter/value_some.go @@ -281,7 +281,7 @@ func (v *SomeValue) Storable( nonSomeValue, nestedLevels := v.nonSomeValue() - _, isContainerValue := nonSomeValue.(atreeContainerBackedValue) + _, isContainerValue := nonSomeValue.(AtreeBackedValue) if v.valueStorable == nil || isContainerValue { From 1488c3d6ff2091dbe84292f764129481e140fc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 14:07:21 -0700 Subject: [PATCH 098/139] use LiveValueID(), ValueID() is now returneing cached/initial value ID again --- interpreter/array_test.go | 2 +- interpreter/composite_value_test.go | 2 +- interpreter/dictionary_test.go | 2 +- interpreter/stale_atree_view_test.go | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index 3e2ee9e875..f46ebcd336 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -212,7 +212,7 @@ func TestInterpretArrayValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) arrayValue := ref.Value.(*interpreter.ArrayValue) - return interpreter.NewUnmeteredStringValue(arrayValue.ValueID().String()) + return interpreter.NewUnmeteredStringValue(arrayValue.LiveValueID().String()) }, ) diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index d1fdb667a7..9b1dd6b773 100644 --- a/interpreter/composite_value_test.go +++ b/interpreter/composite_value_test.go @@ -459,7 +459,7 @@ func TestInterpretCompositeValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) composite := ref.Value.(*interpreter.CompositeValue) - return interpreter.NewUnmeteredStringValue(composite.ValueID().String()) + return interpreter.NewUnmeteredStringValue(composite.LiveValueID().String()) }, ) diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index 93210ec5d5..970ab94748 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -170,7 +170,7 @@ func TestInterpretDictionaryValueIDTracking(t *testing.T) { ) interpreter.Value { ref := args[0].(*interpreter.EphemeralReferenceValue) dictValue := ref.Value.(*interpreter.DictionaryValue) - return interpreter.NewUnmeteredStringValue(dictValue.ValueID().String()) + return interpreter.NewUnmeteredStringValue(dictValue.LiveValueID().String()) }, ) diff --git a/interpreter/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go index 812591a16e..f5f9717f13 100644 --- a/interpreter/stale_atree_view_test.go +++ b/interpreter/stale_atree_view_test.go @@ -76,11 +76,11 @@ func TestInterpretAliasedWrapperMutationPropagation(t *testing.T) { var id string switch v := ref.Value.(type) { case *interpreter.ArrayValue: - id = v.ValueID().String() + id = v.LiveValueID().String() case *interpreter.DictionaryValue: - id = v.ValueID().String() + id = v.LiveValueID().String() case *interpreter.CompositeValue: - id = v.ValueID().String() + id = v.LiveValueID().String() default: t.Fatalf("unexpected value type %T", ref.Value) } From 7640724f7f675292c6d7b03371fb9843bee39995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 14:12:15 -0700 Subject: [PATCH 099/139] always clear canonical atree container --- interpreter/interface.go | 6 +++--- interpreter/value_array.go | 10 ++++------ interpreter/value_composite.go | 10 ++++------ 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/interpreter/interface.go b/interpreter/interface.go index 04bf4ab293..7d8a61ae71 100644 --- a/interpreter/interface.go +++ b/interpreter/interface.go @@ -568,9 +568,9 @@ func (NoOpStringContext) MeterComputation(_ common.ComputationUsage) error { // Canonical wrapper cache is a no-op for stringification — wrappers // constructed during MeteredString are discarded after the string is built. -func (NoOpStringContext) CanonicalAtreeContainer(_ atree.ValueID) Value { return nil } -func (NoOpStringContext) SetCanonicalAtreeContainer(_ atree.ValueID, _ Value) {} -func (NoOpStringContext) ClearCanonicalAtreeContainer(_ atree.ValueID) {} +func (NoOpStringContext) CanonicalAtreeContainer(_ atree.ValueID) Value { return nil } +func (NoOpStringContext) SetCanonicalAtreeContainer(_ atree.ValueID, _ Value) {} +func (NoOpStringContext) ClearCanonicalAtreeContainer(_ atree.ValueID) {} func (NoOpStringContext) WithContainerMutationPrevention(_ atree.ValueID, f func()) { f() diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 980b7941c4..b07121118a 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -441,9 +441,8 @@ func (v *ArrayValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(valueID) - } + context.ClearCanonicalAtreeContainer(valueID) + v.array = nil } @@ -1635,9 +1634,8 @@ func (v *ArrayValue) Transfer( InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) - } + context.ClearCanonicalAtreeContainer(v.valueID) + v.array = nil } diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 1274cdd95c..fbf4deefdb 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -464,9 +464,8 @@ func (v *CompositeValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(valueID) - } + context.ClearCanonicalAtreeContainer(valueID) + v.dictionary = nil } @@ -1578,9 +1577,8 @@ func (v *CompositeValue) Transfer( InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) - } + context.ClearCanonicalAtreeContainer(v.valueID) + v.dictionary = nil } From 10af7963b50f814389088f66ddc909fcb6c55e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 14:29:38 -0700 Subject: [PATCH 100/139] also always clear canonical atree container for dictionaries --- interpreter/value_dictionary.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index c2b5ee05bd..4d76134fb6 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -602,9 +602,8 @@ func (v *DictionaryValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(valueID) - } + context.ClearCanonicalAtreeContainer(valueID) + v.dictionary = nil } @@ -1707,9 +1706,8 @@ func (v *DictionaryValue) Transfer( InvalidateReferencedResources(context, v) - if cache, ok := context.(AtreeContainerCache); ok { - cache.ClearCanonicalAtreeContainer(v.valueID) - } + context.ClearCanonicalAtreeContainer(v.valueID) + v.dictionary = nil } From 6d3b0d58d20af1a9c8527d78a149005e14ce2165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 14:29:56 -0700 Subject: [PATCH 101/139] add convenience methods for getting correct iterator depending on asReference --- interpreter/value_array.go | 91 +++++++++++++------------------------- 1 file changed, 31 insertions(+), 60 deletions(-) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index b07121118a..e4d8507e55 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -460,6 +460,32 @@ func (v *ArrayValue) canonicalAtreeContainer() atreeContainer { return v.array } +// iterator returns a mutable atree iterator when `asReference` is true — +// elements yielded will be exposed through `EphemeralReferenceValue`s, +// so mutations through those references must propagate via the wrapper's +// real parent-updater. +// A read-only iterator (whose wrapper has a trap callback, no real parent-updater) +// suffices when elements are `Transfer`'d out and decoupled from the source. +func (v *ArrayValue) iterator(asReference bool) (atree.ArrayIterator, error) { + if asReference { + return v.array.Iterator() + } + return v.array.ReadOnlyIterator() +} + +// rangeIterator returns a mutable atree iterator when `asReference` is true — +// elements yielded will be exposed through `EphemeralReferenceValue`s, +// so mutations through those references must propagate via the wrapper's +// real parent-updater. +// A read-only iterator (whose wrapper has a trap callback, no real parent-updater) +// suffices when elements are `Transfer`'d out and decoupled from the source. +func (v *ArrayValue) rangeIterator(fromIndex, toIndex uint64, asReference bool) (atree.ArrayIterator, error) { + if asReference { + return v.array.RangeIterator(fromIndex, toIndex) + } + return v.array.ReadOnlyRangeIterator(fromIndex, toIndex) +} + func (v *ArrayValue) Concat( context ValueTransferContext, other *ArrayValue, @@ -478,31 +504,12 @@ func (v *ArrayValue) Concat( resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) - // In the asReference branch each yielded element is wrapped in an - // EphemeralReferenceValue that lands in the result array. - // Subsequent mutations through such a reference - // (e.g. calling an `access(all)` mutating function on a struct element) - // must propagate to the original container, - // so the iterator must wire a real parent-notification callback — - // a read-only iterator's trap callback would panic on mutation. - // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, - // so a read-only iterator suffices. - var firstIterator, secondIterator atree.ArrayIterator - var err error - if asReference { - firstIterator, err = v.array.Iterator() - } else { - firstIterator, err = v.array.ReadOnlyIterator() - } + firstIterator, err := v.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } - if asReference { - secondIterator, err = other.array.Iterator() - } else { - secondIterator, err = other.array.ReadOnlyIterator() - } + secondIterator, err := other.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } @@ -1842,19 +1849,7 @@ func (v *ArrayValue) Slice( elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) resultElementStaticType := ConvertSemaToStaticType(context, elementType) - // In the asReference branch each yielded element is wrapped in an - // EphemeralReferenceValue that lands in the result array. - // Subsequent mutations through such a reference - // must propagate to the original container, - // so the iterator must wire a real parent-notification callback. - // In the non-asReference branch the element is `Transfer`'d, decoupled from the original. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.RangeIterator(uint64(fromIndex), uint64(toIndex)) - } else { - iterator, err = v.array.ReadOnlyRangeIterator(uint64(fromIndex), uint64(toIndex)) - } + iterator, err := v.rangeIterator(uint64(fromIndex), uint64(toIndex), asReference) if err != nil { var sliceOutOfBoundsError *atree.SliceOutOfBoundsError @@ -2246,19 +2241,7 @@ func (v *ArrayValue) ToVariableSized( // Convert the array to a variable-sized array. - // In the asReference branch each yielded element is wrapped in an - // EphemeralReferenceValue that lands in the result array. - // Subsequent mutations through such a reference must propagate to the original container, - // so the iterator must wire a real parent-notification callback. - // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, - // so a read-only iterator suffices. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.Iterator() - } else { - iterator, err = v.array.ReadOnlyIterator() - } + iterator, err := v.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } @@ -2345,19 +2328,7 @@ func (v *ArrayValue) ToConstantSized( // Convert the array to a constant-sized array. - // In the asReference branch each yielded element is wrapped in an - // EphemeralReferenceValue that lands in the result array. - // Subsequent mutations through such a reference must propagate to the original container, - // so the iterator must wire a real parent-notification callback. - // In the non-asReference branch the element is `Transfer`'d, decoupled from the original, - // so a read-only iterator suffices. - var iterator atree.ArrayIterator - var err error - if asReference { - iterator, err = v.array.Iterator() - } else { - iterator, err = v.array.ReadOnlyIterator() - } + iterator, err := v.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } From a918eaeea23940f3b0afe3c3ed2bea76fd2de263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 15:30:00 -0700 Subject: [PATCH 102/139] also clear canonical atree container from cache on non-resource remove --- interpreter/value_array.go | 7 ++ interpreter/value_composite.go | 7 ++ interpreter/value_dictionary.go | 7 ++ interpreter/value_test.go | 164 ++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index e4d8507e55..930d2cbb5a 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1644,6 +1644,13 @@ func (v *ArrayValue) Transfer( context.ClearCanonicalAtreeContainer(v.valueID) v.array = nil + } else if remove { + // Non-resource Transfer with remove: the source's slabs were just + // deleted by the PopIterate above, so any cached canonical wrapper + // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, + // so this can't cause a wrong-wrapper read, but the entry would + // otherwise leak for the lifetime of the cache. + context.ClearCanonicalAtreeContainer(v.valueID) } res := newArrayValueFromAtreeArray( diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index fbf4deefdb..0a21b0f28e 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1580,6 +1580,13 @@ func (v *CompositeValue) Transfer( context.ClearCanonicalAtreeContainer(v.valueID) v.dictionary = nil + } else if remove { + // Non-resource Transfer with remove: the source's slabs were just + // deleted by the PopIterate above, so any cached canonical wrapper + // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, + // so this can't cause a wrong-wrapper read, but the entry would + // otherwise leak for the lifetime of the cache. + context.ClearCanonicalAtreeContainer(v.valueID) } info := NewCompositeTypeInfo( diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 4d76134fb6..4ab7f4ebac 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -1709,6 +1709,13 @@ func (v *DictionaryValue) Transfer( context.ClearCanonicalAtreeContainer(v.valueID) v.dictionary = nil + } else if remove { + // Non-resource Transfer with remove: the source's slabs were just + // deleted by the PopIterate above, so any cached canonical wrapper + // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, + // so this can't cause a wrong-wrapper read, but the entry would + // otherwise leak for the lifetime of the cache. + context.ClearCanonicalAtreeContainer(v.valueID) } res := newDictionaryValueFromAtreeMap( diff --git a/interpreter/value_test.go b/interpreter/value_test.go index a84ec55d6d..13f9e32e2d 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -657,6 +657,170 @@ func TestOwnerCompositeTransfer(t *testing.T) { assert.Equal(t, common.ZeroAddress, value.GetOwner()) } +// TestArrayValueTransferRemoveEvictsCanonicalCache pins down the cache +// eviction in the `else if remove` branch of `ArrayValue.Transfer`: +// for a non-resource array, Transfer with `remove=true` deletes the source +// slabs and must evict any cached canonical wrapper for that value ID +// (otherwise the entry leaks for the lifetime of the cache). +func TestArrayValueTransferRemoveEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + arr := NewArrayValue( + inter, + &VariableSizedStaticType{ + Type: PrimitiveStaticTypeInt, + }, + oldOwner, + NewUnmeteredIntValueFromInt64(1), + ) + + valueID := arr.ValueID() + + // Simulate a prior canonicalizing access (e.g., `&outer[i]`). + inter.SetCanonicalAtreeContainer(valueID, arr) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + transferred := arr.Transfer( + inter, + atree.Address(newOwner), + true, // remove + nil, + nil, + true, // array is standalone. + ) + require.IsType(t, &ArrayValue{}, transferred) + + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted after non-resource Transfer with remove=true", + ) +} + +// TestDictionaryValueTransferRemoveEvictsCanonicalCache — see +// TestArrayValueTransferRemoveEvictsCanonicalCache. +func TestDictionaryValueTransferRemoveEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + dictionary := NewDictionaryValueWithAddress( + inter, + &DictionaryStaticType{ + KeyType: PrimitiveStaticTypeString, + ValueType: PrimitiveStaticTypeInt, + }, + oldOwner, + NewUnmeteredStringValue("k"), NewUnmeteredIntValueFromInt64(1), + ) + + valueID := dictionary.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, dictionary) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + transferred := dictionary.Transfer( + inter, + atree.Address(newOwner), + true, // remove + nil, + nil, + true, // dictionary is standalone. + ) + require.IsType(t, &DictionaryValue{}, transferred) + + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted after non-resource Transfer with remove=true", + ) +} + +// TestCompositeValueTransferRemoveEvictsCanonicalCache — see +// TestArrayValueTransferRemoveEvictsCanonicalCache. +func TestCompositeValueTransferRemoveEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + composite := newTestCompositeValue(inter, oldOwner) + composite.SetMember(inter, "f", NewUnmeteredIntValueFromInt64(1)) + + valueID := composite.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, composite) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + transferred := composite.Transfer( + inter, + atree.Address(newOwner), + true, // remove + nil, + nil, + true, // composite is standalone. + ) + require.IsType(t, &CompositeValue{}, transferred) + + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted after non-resource Transfer with remove=true", + ) +} + +// TestArrayValueTransferNoRemoveKeepsCanonicalCache is the negative +// counterpart: non-resource Transfer without `remove` leaves the source +// wrapper valid, so the cache entry must NOT be evicted. +func TestArrayValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + arr := NewArrayValue( + inter, + &VariableSizedStaticType{ + Type: PrimitiveStaticTypeInt, + }, + oldOwner, + NewUnmeteredIntValueFromInt64(1), + ) + + valueID := arr.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, arr) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + _ = arr.Transfer( + inter, + atree.Address(newOwner), + false, // no remove + nil, + nil, + true, + ) + + assert.Same(t, + Value(arr), + inter.CanonicalAtreeContainer(valueID), + "cache entry must be kept when source is not removed", + ) +} + func TestStringer(t *testing.T) { t.Parallel() From 3fd19e5336c26803ca26661189f2567f3e29f933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 15:49:53 -0700 Subject: [PATCH 103/139] also clear cache on storage map set/remove --- interpreter/domain_storagemap.go | 18 ++++ interpreter/domain_storagemap_test.go | 121 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/interpreter/domain_storagemap.go b/interpreter/domain_storagemap.go index cf403a9276..40df32489e 100644 --- a/interpreter/domain_storagemap.go +++ b/interpreter/domain_storagemap.go @@ -236,6 +236,15 @@ func (s *DomainStorageMap) SetValue(context ValueTransferContext, key StorageMap existed = existingStorable != nil if existed { existingValue := StoredValue(context, existingStorable, context.Storage()) + // Evict any canonical wrapper for the value being overwritten; + // its slabs are about to be deleted by DeepRemove + RemoveReferencedSlab, + // so a cached wrapper would back onto gone slabs. + // Atree doesn't reuse SlabIDs, + // so this can't cause a wrong-wrapper read, + // but the entry would otherwise leak for the lifetime of the cache. + if atreeBacked, ok := existingValue.(AtreeBackedValue); ok { + context.ClearCanonicalAtreeContainer(atreeBacked.ValueID()) + } existingValue.DeepRemove(context, true) // existingValue is standalone because it was overwritten in parent container. RemoveReferencedSlab(context, existingStorable) } @@ -288,6 +297,15 @@ func (s *DomainStorageMap) RemoveValue(context ValueRemoveContext, key StorageMa existed = existingValueStorable != nil if existed { existingValue := StoredValue(context, existingValueStorable, context.Storage()) + // Evict any canonical wrapper for the value being removed; + // its slabs are about to be deleted by DeepRemove + RemoveReferencedSlab, + // so a cached wrapper would back onto gone slabs. + // Atree doesn't reuse SlabIDs, + // so this can't cause a wrong-wrapper read, + // but the entry would otherwise leak for the lifetime of the cache. + if atreeBacked, ok := existingValue.(AtreeBackedValue); ok { + context.ClearCanonicalAtreeContainer(atreeBacked.ValueID()) + } existingValue.DeepRemove(context, true) // existingValue is standalone because it was removed from parent container. RemoveReferencedSlab(context, existingValueStorable) } diff --git a/interpreter/domain_storagemap_test.go b/interpreter/domain_storagemap_test.go index 34a4ce2990..e32a5da981 100644 --- a/interpreter/domain_storagemap_test.go +++ b/interpreter/domain_storagemap_test.go @@ -875,3 +875,124 @@ func atreeValueIDToSlabID(vid atree.ValueID) atree.SlabID { atree.SlabIndex(vid[8:]), ) } + +// storeArrayValue is a small helper for the cache-eviction tests below: +// constructs a non-resource `[Int]` and transfers it into the given +// storage map under `key`, leaving the stored atree slab tree intact. +func storeArrayValue( + t *testing.T, + inter *interpreter.Interpreter, + domainStorageMap *interpreter.DomainStorageMap, + address common.Address, + key interpreter.StorageMapKey, + elementValue int64, +) { + t.Helper() + arr := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(elementValue), + ) + transferred := arr.Transfer( + inter, + atree.Address(address), + false, // no remove + nil, + nil, + true, // standalone + ).(*interpreter.ArrayValue) + domainStorageMap.SetValue(inter, key, transferred) +} + +// TestDomainStorageMapSetValueEvictsCanonicalCacheForOverwrittenValue pins +// down the cache eviction added to `DomainStorageMap.SetValue`: when an +// existing value at a key is overwritten, its slabs are deleted, and any +// canonical wrapper cached for the old value's atree value ID must be +// evicted (otherwise the entry leaks for the lifetime of the cache). +func TestDomainStorageMapSetValueEvictsCanonicalCacheForOverwrittenValue(t *testing.T) { + t.Parallel() + + address := common.MustBytesToAddress([]byte{0x1}) + + ledger := NewTestLedger(nil, nil) + storage := runtime.NewStorage(ledger, nil, nil, runtime.StorageConfig{}) + + // AtreeStorageValidationEnabled is off because DomainStorageMap is + // constructed directly, not via runtime.Storage; matches the pattern of + // the other tests in this file. + const atreeValueValidationEnabled = true + const atreeStorageValidationEnabled = false + inter := NewTestInterpreterWithStorageAndAtreeValidationConfig( + t, storage, atreeValueValidationEnabled, atreeStorageValidationEnabled, + ) + + domainStorageMap := interpreter.NewDomainStorageMap( + nil, nil, storage, atree.Address(address), + ) + require.NotNil(t, domainStorageMap) + + key := interpreter.StringStorageMapKey("k") + + storeArrayValue(t, inter, domainStorageMap, address, key, 1) + + // ReadValue canonicalizes the wrapper for the stored array. + read := domainStorageMap.ReadValue(inter, key) + require.IsType(t, &interpreter.ArrayValue{}, read) + originalValueID := read.(*interpreter.ArrayValue).ValueID() + require.NotNil(t, + inter.CanonicalAtreeContainer(originalValueID), + "cache must hold the canonical wrapper after the canonicalizing read", + ) + + // Overwrite. The old array's slabs are deleted; the cache entry for its + // value ID must be evicted. + storeArrayValue(t, inter, domainStorageMap, address, key, 2) + + require.Nil(t, + inter.CanonicalAtreeContainer(originalValueID), + "cache entry must be evicted after the overwrite", + ) +} + +// TestDomainStorageMapRemoveValueEvictsCanonicalCache — counterpart of +// TestDomainStorageMapSetValueEvictsCanonicalCacheForOverwrittenValue for +// the RemoveValue path. +func TestDomainStorageMapRemoveValueEvictsCanonicalCache(t *testing.T) { + t.Parallel() + + address := common.MustBytesToAddress([]byte{0x1}) + + ledger := NewTestLedger(nil, nil) + storage := runtime.NewStorage(ledger, nil, nil, runtime.StorageConfig{}) + + const atreeValueValidationEnabled = true + const atreeStorageValidationEnabled = false + inter := NewTestInterpreterWithStorageAndAtreeValidationConfig( + t, storage, atreeValueValidationEnabled, atreeStorageValidationEnabled, + ) + + domainStorageMap := interpreter.NewDomainStorageMap( + nil, nil, storage, atree.Address(address), + ) + require.NotNil(t, domainStorageMap) + + key := interpreter.StringStorageMapKey("k") + + storeArrayValue(t, inter, domainStorageMap, address, key, 1) + + read := domainStorageMap.ReadValue(inter, key) + require.IsType(t, &interpreter.ArrayValue{}, read) + originalValueID := read.(*interpreter.ArrayValue).ValueID() + require.NotNil(t, inter.CanonicalAtreeContainer(originalValueID)) + + existed := domainStorageMap.RemoveValue(inter, key) + require.True(t, existed) + + require.Nil(t, + inter.CanonicalAtreeContainer(originalValueID), + "cache entry must be evicted after the remove", + ) +} From 3fdd6bdacb8e5605f71c0db559aec231e7c6e0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 3 Jun 2026 15:53:38 -0700 Subject: [PATCH 104/139] also clear cache in deep remove. recursion then also clears cache entries for children --- interpreter/domain_storagemap.go | 24 +++------ interpreter/domain_storagemap_test.go | 74 +++++++++++++++++++++++++++ interpreter/value_array.go | 5 ++ interpreter/value_composite.go | 5 ++ interpreter/value_dictionary.go | 5 ++ 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/interpreter/domain_storagemap.go b/interpreter/domain_storagemap.go index 40df32489e..2424ed63dc 100644 --- a/interpreter/domain_storagemap.go +++ b/interpreter/domain_storagemap.go @@ -236,15 +236,9 @@ func (s *DomainStorageMap) SetValue(context ValueTransferContext, key StorageMap existed = existingStorable != nil if existed { existingValue := StoredValue(context, existingStorable, context.Storage()) - // Evict any canonical wrapper for the value being overwritten; - // its slabs are about to be deleted by DeepRemove + RemoveReferencedSlab, - // so a cached wrapper would back onto gone slabs. - // Atree doesn't reuse SlabIDs, - // so this can't cause a wrong-wrapper read, - // but the entry would otherwise leak for the lifetime of the cache. - if atreeBacked, ok := existingValue.(AtreeBackedValue); ok { - context.ClearCanonicalAtreeContainer(atreeBacked.ValueID()) - } + // DeepRemove also evicts the cached canonical wrapper (if any) for + // `existingValue` and recursively for every nested container, + // so we don't need a separate ClearCanonicalAtreeContainer here. existingValue.DeepRemove(context, true) // existingValue is standalone because it was overwritten in parent container. RemoveReferencedSlab(context, existingStorable) } @@ -297,15 +291,9 @@ func (s *DomainStorageMap) RemoveValue(context ValueRemoveContext, key StorageMa existed = existingValueStorable != nil if existed { existingValue := StoredValue(context, existingValueStorable, context.Storage()) - // Evict any canonical wrapper for the value being removed; - // its slabs are about to be deleted by DeepRemove + RemoveReferencedSlab, - // so a cached wrapper would back onto gone slabs. - // Atree doesn't reuse SlabIDs, - // so this can't cause a wrong-wrapper read, - // but the entry would otherwise leak for the lifetime of the cache. - if atreeBacked, ok := existingValue.(AtreeBackedValue); ok { - context.ClearCanonicalAtreeContainer(atreeBacked.ValueID()) - } + // DeepRemove also evicts the cached canonical wrapper (if any) for + // `existingValue` and recursively for every nested container, + // so we don't need a separate ClearCanonicalAtreeContainer here. existingValue.DeepRemove(context, true) // existingValue is standalone because it was removed from parent container. RemoveReferencedSlab(context, existingValueStorable) } diff --git a/interpreter/domain_storagemap_test.go b/interpreter/domain_storagemap_test.go index e32a5da981..3d2865c95d 100644 --- a/interpreter/domain_storagemap_test.go +++ b/interpreter/domain_storagemap_test.go @@ -31,6 +31,7 @@ import ( . "github.com/onflow/cadence/test_utils/interpreter_utils" . "github.com/onflow/cadence/test_utils/runtime_utils" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -957,6 +958,79 @@ func TestDomainStorageMapSetValueEvictsCanonicalCacheForOverwrittenValue(t *test ) } +// TestDomainStorageMapRemoveValueEvictsCanonicalCacheForNestedContainers +// pins down the per-element eviction that comes from placing the cache +// clear inside `DeepRemove`: removing an outer `[[Int]]` evicts the cache +// entry for the outer array AND for each inner array that had been +// canonicalized via a prior `&outer[i]` access. +func TestDomainStorageMapRemoveValueEvictsCanonicalCacheForNestedContainers(t *testing.T) { + t.Parallel() + + address := common.MustBytesToAddress([]byte{0x1}) + + ledger := NewTestLedger(nil, nil) + storage := runtime.NewStorage(ledger, nil, nil, runtime.StorageConfig{}) + + const atreeValueValidationEnabled = true + const atreeStorageValidationEnabled = false + inter := NewTestInterpreterWithStorageAndAtreeValidationConfig( + t, storage, atreeValueValidationEnabled, atreeStorageValidationEnabled, + ) + + domainStorageMap := interpreter.NewDomainStorageMap( + nil, nil, storage, atree.Address(address), + ) + require.NotNil(t, domainStorageMap) + + key := interpreter.StringStorageMapKey("k") + + // Store an outer [[Int]] with one inner [Int]. + inner := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(1), + ) + outer := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + }, + common.ZeroAddress, + inner, + ) + transferredOuter := outer.Transfer( + inter, atree.Address(address), false, nil, nil, true, + ).(*interpreter.ArrayValue) + domainStorageMap.SetValue(inter, key, transferredOuter) + + // Canonicalize the outer wrapper (ReadValue) and the inner wrapper (Get). + readOuter := domainStorageMap.ReadValue(inter, key).(*interpreter.ArrayValue) + outerValueID := readOuter.ValueID() + readInner := readOuter.Get(inter, 0).(*interpreter.ArrayValue) + innerValueID := readInner.ValueID() + + require.NotNil(t, inter.CanonicalAtreeContainer(outerValueID)) + require.NotNil(t, inter.CanonicalAtreeContainer(innerValueID)) + + // RemoveValue tears down the outer; DeepRemove's recursive walk evicts + // both the outer's cache entry and the nested inner's. + require.True(t, domainStorageMap.RemoveValue(inter, key)) + + assert.Nil(t, + inter.CanonicalAtreeContainer(outerValueID), + "outer cache entry must be evicted", + ) + assert.Nil(t, + inter.CanonicalAtreeContainer(innerValueID), + "nested inner cache entry must also be evicted via recursive DeepRemove", + ) +} + // TestDomainStorageMapRemoveValueEvictsCanonicalCache — counterpart of // TestDomainStorageMapSetValueEvictsCanonicalCacheForOverwrittenValue for // the RemoveValue path. diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 930d2cbb5a..0e2d119ea9 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1728,6 +1728,11 @@ func (v *ArrayValue) DeepRemove(context ValueRemoveContext, hasNoParentContainer }() } + // Evict any canonical wrapper for this value: its slabs are about to be + // torn down. Done here (rather than at each call site) so that the + // recursive `value.DeepRemove(...)` below also covers nested wrappers. + context.ClearCanonicalAtreeContainer(v.valueID) + // Remove nested values and storables storage := v.array.Storage diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 0a21b0f28e..aacf21b5ee 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1708,6 +1708,11 @@ func (v *CompositeValue) DeepRemove(context ValueRemoveContext, hasNoParentConta }() } + // Evict any canonical wrapper for this value: its slabs are about to be + // torn down. Done here (rather than at each call site) so that the + // recursive `value.DeepRemove(...)` below also covers nested wrappers. + context.ClearCanonicalAtreeContainer(v.valueID) + // Remove nested values and storables storage := v.dictionary.Storage diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 4ab7f4ebac..2f92020e5d 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -1803,6 +1803,11 @@ func (v *DictionaryValue) DeepRemove(context ValueRemoveContext, hasNoParentCont }() } + // Evict any canonical wrapper for this value: its slabs are about to be + // torn down. Done here (rather than at each call site) so that the + // recursive `value.DeepRemove(...)` below also covers nested wrappers. + context.ClearCanonicalAtreeContainer(v.valueID) + // Remove nested values and storables storage := v.dictionary.Storage From bc25a43b5029af6f88d7f67fb96cc561ff65d568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 09:01:22 -0700 Subject: [PATCH 105/139] add tests and comments --- bbq/vm/context.go | 5 + interpreter/interpreter.go | 12 +- interpreter/value_array.go | 3 + interpreter/value_composite.go | 3 + interpreter/value_dictionary.go | 3 + interpreter/value_test.go | 234 ++++++++++++++++++++++++++++++++ 6 files changed, 257 insertions(+), 3 deletions(-) diff --git a/bbq/vm/context.go b/bbq/vm/context.go index f2c4c16f65..71cf53b76b 100644 --- a/bbq/vm/context.go +++ b/bbq/vm/context.go @@ -109,6 +109,11 @@ func (c *Context) ClearCanonicalAtreeContainer(valueID atree.ValueID) { delete(c.canonicalAtreeContainers, valueID) } +// ClearAllCanonicalAtreeContainers — see Interpreter.ClearAllCanonicalAtreeContainers. +func (c *Context) ClearAllCanonicalAtreeContainers() { + clear(c.canonicalAtreeContainers) +} + func (c *Context) RecordStorageMutation() { if c.inStorageIteration { c.storageMutatedDuringIteration = true diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index c22fc9cb1d..49e356d78a 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -6512,9 +6512,15 @@ func (interpreter *Interpreter) SetCanonicalAtreeContainer(valueID atree.ValueID } // ClearCanonicalAtreeContainer removes the cache entry for the given atree -// value ID. Call this when the corresponding wrapper is invalidated -// (Destroy, Transfer's `array`/`dictionary` nil-out) so the now-invalid -// wrapper is not handed out to subsequent loads. +// value ID. Call this when the corresponding wrapper is invalidated or its +// underlying slabs are being torn down, so the now-invalid wrapper is not +// handed out to subsequent loads. Production call sites: +// - `ArrayValue` / `DictionaryValue` / `CompositeValue` `Destroy` +// and resource `Transfer` (before `v.array` / `v.dictionary = nil`). +// - `ArrayValue` / `DictionaryValue` / `CompositeValue` non-resource +// `Transfer` with `remove=true` (source slabs deleted by PopIterate). +// - `DeepRemove` on the three container types (also evicts nested +// wrappers via the recursive walk). func (interpreter *Interpreter) ClearCanonicalAtreeContainer(valueID atree.ValueID) { delete(interpreter.SharedState.canonicalAtreeContainers, valueID) } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 0e2d119ea9..905cba2267 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1650,6 +1650,9 @@ func (v *ArrayValue) Transfer( // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, // so this can't cause a wrong-wrapper read, but the entry would // otherwise leak for the lifetime of the cache. + // + // `v.array` is intentionally NOT nilled: existing callers + // read metadata off the source wrapper after a non-resource remove. context.ClearCanonicalAtreeContainer(v.valueID) } diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index aacf21b5ee..2a663d9432 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1586,6 +1586,9 @@ func (v *CompositeValue) Transfer( // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, // so this can't cause a wrong-wrapper read, but the entry would // otherwise leak for the lifetime of the cache. + // + // `v.dictionary` is intentionally NOT nilled: existing callers + // read metadata off the source wrapper after a non-resource remove. context.ClearCanonicalAtreeContainer(v.valueID) } diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 2f92020e5d..19f7670a6a 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -1715,6 +1715,9 @@ func (v *DictionaryValue) Transfer( // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, // so this can't cause a wrong-wrapper read, but the entry would // otherwise leak for the lifetime of the cache. + // + // `v.dictionary` is intentionally NOT nilled: existing callers + // read metadata off the source wrapper after a non-resource remove. context.ClearCanonicalAtreeContainer(v.valueID) } diff --git a/interpreter/value_test.go b/interpreter/value_test.go index 13f9e32e2d..1286382d6b 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -821,6 +821,240 @@ func TestArrayValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { ) } +// TestArrayValueDestroyEvictsCanonicalCache pins down the cache eviction in +// ArrayValue.Destroy. Constructs an empty resource-kinded array (so the +// destruction walk has nothing to do), registers it in the cache, calls +// Destroy, asserts the cache entry is gone and v.array has been nilled. +func TestArrayValueDestroyEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + owner := common.Address{0x1} + + arr := NewArrayValue( + inter, + &VariableSizedStaticType{ + Type: PrimitiveStaticTypeAnyResource, + }, + owner, + ) + + valueID := arr.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, arr) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + arr.Destroy(inter) + + assert.True(t, arr.IsDestroyed(), + "the wrapper must be marked destroyed", + ) + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted by Destroy", + ) +} + +// TestDictionaryValueDestroyEvictsCanonicalCache — see +// TestArrayValueDestroyEvictsCanonicalCache. +func TestDictionaryValueDestroyEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + owner := common.Address{0x1} + + dictionary := NewDictionaryValueWithAddress( + inter, + &DictionaryStaticType{ + KeyType: PrimitiveStaticTypeString, + ValueType: PrimitiveStaticTypeAnyResource, + }, + owner, + ) + + valueID := dictionary.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, dictionary) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + dictionary.Destroy(inter) + + assert.True(t, dictionary.IsDestroyed()) + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted by Destroy", + ) +} + +// TestCompositeValueDestroyEvictsCanonicalCache — see +// TestArrayValueDestroyEvictsCanonicalCache. Uses a resource-kinded composite +// with no fields and no default-destroy events. +func TestCompositeValueDestroyEvictsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + owner := common.Address{0x1} + + resourceType := &sema.CompositeType{ + Location: TestLocation, + Identifier: "TestResource", + Kind: common.CompositeKindResource, + Members: &sema.StringMemberOrderedMap{}, + } + inter.Program.Elaboration.SetCompositeType(resourceType.ID(), resourceType) + + composite := NewCompositeValue( + inter, + TestLocation, + "TestResource", + common.CompositeKindResource, + nil, + owner, + ) + + valueID := composite.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, composite) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + composite.Destroy(inter) + + assert.True(t, composite.IsDestroyed()) + assert.Nil(t, + inter.CanonicalAtreeContainer(valueID), + "cache entry must be evicted by Destroy", + ) +} + +// TestClearAllCanonicalAtreeContainers pins down the bulk-evict API used by +// test harnesses that swap the underlying atree storage (e.g. smoke tests' +// resetStorage). Populates the cache with one wrapper per container type, +// calls ClearAll, asserts none of the three entries remain. +func TestClearAllCanonicalAtreeContainers(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + owner := common.Address{0x1} + + arr := NewArrayValue( + inter, + &VariableSizedStaticType{ + Type: PrimitiveStaticTypeInt, + }, + owner, + NewUnmeteredIntValueFromInt64(1), + ) + dictionary := NewDictionaryValueWithAddress( + inter, + &DictionaryStaticType{ + KeyType: PrimitiveStaticTypeString, + ValueType: PrimitiveStaticTypeInt, + }, + owner, + NewUnmeteredStringValue("k"), NewUnmeteredIntValueFromInt64(1), + ) + composite := newTestCompositeValue(inter, owner) + + inter.SetCanonicalAtreeContainer(arr.ValueID(), arr) + inter.SetCanonicalAtreeContainer(dictionary.ValueID(), dictionary) + inter.SetCanonicalAtreeContainer(composite.ValueID(), composite) + + require.NotNil(t, inter.CanonicalAtreeContainer(arr.ValueID())) + require.NotNil(t, inter.CanonicalAtreeContainer(dictionary.ValueID())) + require.NotNil(t, inter.CanonicalAtreeContainer(composite.ValueID())) + + inter.ClearAllCanonicalAtreeContainers() + + assert.Nil(t, inter.CanonicalAtreeContainer(arr.ValueID())) + assert.Nil(t, inter.CanonicalAtreeContainer(dictionary.ValueID())) + assert.Nil(t, inter.CanonicalAtreeContainer(composite.ValueID())) +} + +// TestDictionaryValueTransferNoRemoveKeepsCanonicalCache — see +// TestArrayValueTransferNoRemoveKeepsCanonicalCache. +func TestDictionaryValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + dictionary := NewDictionaryValueWithAddress( + inter, + &DictionaryStaticType{ + KeyType: PrimitiveStaticTypeString, + ValueType: PrimitiveStaticTypeInt, + }, + oldOwner, + NewUnmeteredStringValue("k"), NewUnmeteredIntValueFromInt64(1), + ) + + valueID := dictionary.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, dictionary) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + _ = dictionary.Transfer( + inter, + atree.Address(newOwner), + false, // no remove + nil, + nil, + true, + ) + + assert.Same(t, + Value(dictionary), + inter.CanonicalAtreeContainer(valueID), + "cache entry must be kept when source is not removed", + ) +} + +// TestCompositeValueTransferNoRemoveKeepsCanonicalCache — see +// TestArrayValueTransferNoRemoveKeepsCanonicalCache. +func TestCompositeValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { + + t.Parallel() + + inter := newTestInterpreter(t) + + oldOwner := common.Address{0x1} + newOwner := common.Address{0x2} + + composite := newTestCompositeValue(inter, oldOwner) + composite.SetMember(inter, "f", NewUnmeteredIntValueFromInt64(1)) + + valueID := composite.ValueID() + + inter.SetCanonicalAtreeContainer(valueID, composite) + require.NotNil(t, inter.CanonicalAtreeContainer(valueID)) + + _ = composite.Transfer( + inter, + atree.Address(newOwner), + false, // no remove + nil, + nil, + true, + ) + + assert.Same(t, + Value(composite), + inter.CanonicalAtreeContainer(valueID), + "cache entry must be kept when source is not removed", + ) +} + func TestStringer(t *testing.T) { t.Parallel() From f80ad3820c1461fe310f2f0b8fb8162618d2bd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 09:06:14 -0700 Subject: [PATCH 106/139] require ContainerElementContext to be non-nil --- interpreter/domain_storagemap.go | 4 ++-- interpreter/domain_storagemap_test.go | 8 +++++--- interpreter/storage.go | 26 +++++++++++--------------- interpreter/stringatreevalue_test.go | 2 +- runtime/storage_test.go | 12 ++++++------ tools/storage-explorer/main.go | 2 +- 6 files changed, 26 insertions(+), 28 deletions(-) diff --git a/interpreter/domain_storagemap.go b/interpreter/domain_storagemap.go index 2424ed63dc..a632940dce 100644 --- a/interpreter/domain_storagemap.go +++ b/interpreter/domain_storagemap.go @@ -164,8 +164,8 @@ func (s *DomainStorageMap) ValueExists(gauge common.ComputationGauge, key Storag // ReadValue returns the value for the given key. // Returns nil if the key does not exist. -// `context` is allowed to be nil for low-level inspection paths -// (in which case canonicalization is skipped). +// `context` must be non-nil: every read must canonicalize so two reads of the +// same path yield the same Cadence-level wrapper. func (s *DomainStorageMap) ReadValue(context ContainerElementContext, key StorageMapKey) Value { common.UseComputation( diff --git a/interpreter/domain_storagemap_test.go b/interpreter/domain_storagemap_test.go index 3d2865c95d..969a9b0d2c 100644 --- a/interpreter/domain_storagemap_test.go +++ b/interpreter/domain_storagemap_test.go @@ -133,6 +133,8 @@ func TestDomainStorageMapReadValue(t *testing.T) { runtime.StorageConfig{}, ) + inter := NewTestInterpreterWithStorage(t, storage) + domainStorageMap := interpreter.NewDomainStorageMap( nil, nil, @@ -143,7 +145,7 @@ func TestDomainStorageMapReadValue(t *testing.T) { require.Equal(t, uint64(0), domainStorageMap.Count()) key := interpreter.StringAtreeValue("key") - v := domainStorageMap.ReadValue(nil, interpreter.StringStorageMapKey(key)) + v := domainStorageMap.ReadValue(inter, interpreter.StringStorageMapKey(key)) require.Nil(t, v) valueID := domainStorageMap.ValueID() @@ -179,7 +181,7 @@ func TestDomainStorageMapReadValue(t *testing.T) { domainStorageMap, domainValues := createDomainStorageMap(storage, inter, address, count, random) for key, expectedValue := range domainValues { - value := domainStorageMap.ReadValue(nil, key) + value := domainStorageMap.ReadValue(inter, key) require.NotNil(t, value) checkCadenceValue(t, inter, value, expectedValue) @@ -193,7 +195,7 @@ func TestDomainStorageMapReadValue(t *testing.T) { continue } - value := domainStorageMap.ReadValue(nil, key) + value := domainStorageMap.ReadValue(inter, key) require.Nil(t, value) } diff --git a/interpreter/storage.go b/interpreter/storage.go index 513ef010ed..2e324c58db 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -72,10 +72,8 @@ type AtreeContainerCache interface { // ContainerElementContext is the typed parameter for reading and // canonicalizing an atree container element: // memory and computation metering, plus access to the canonical wrapper cache. -// A nil interface value is accepted by `MustConvertStoredContainerElement` -// and disables canonicalization -// (matches the previous untyped-gauge behavior, -// used by low-level inspection paths and tests). +// Must be non-nil — canonicalization is load-bearing for correctness, see +// `MustConvertStoredContainerElement`. type ContainerElementContext interface { common.Gauge AtreeContainerCache @@ -162,24 +160,22 @@ func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value // MustConvertStoredContainerElement wraps an atree value retrieved as a // container element (e.g. via `*atree.Array.Get` or `*atree.OrderedMap.Get`) // as a Cadence-level `Value`, -// deduplicating the resulting wrapper via the canonical wrapper cache when: -// - the context is non-nil, and -// - the wrapper has a real parent-notification callback -// (i.e. it came from a `Get` or mutable-iterator path, -// not from a read-only iterator). +// deduplicating the resulting wrapper via the canonical wrapper cache when +// the wrapper has a real parent-notification callback +// (i.e. it came from a `Get` or mutable-iterator path, +// not from a read-only iterator). // -// The second condition is enforced inside `canonicalizeContainerElement`, +// The condition is enforced inside `canonicalizeContainerElement`, // so callers do not need to know which atree path produced `value`: // passing in a read-only-iterator wrapper is safe; // it just won't be cached. // -// A nil `context` skips canonicalization entirely -// (used by tests and the low-level decode path). +// `context` must be non-nil. Canonicalization is load-bearing for correctness +// — a sibling read that yields a non-canonical wrapper observes a divergent +// view of the container, which is the bug class this whole machinery exists +// to prevent. func MustConvertStoredContainerElement(context ContainerElementContext, value atree.Value) Value { result := MustConvertStoredValue(context, value) - if context == nil { - return result - } return canonicalizeContainerElement(context, result) } diff --git a/interpreter/stringatreevalue_test.go b/interpreter/stringatreevalue_test.go index ff4e679d3e..e54b9e1513 100644 --- a/interpreter/stringatreevalue_test.go +++ b/interpreter/stringatreevalue_test.go @@ -60,7 +60,7 @@ func TestLargeStringAtreeValueInSeparateSlab(t *testing.T) { expected := NewUnmeteredUInt8Value(42) storageMap.SetValue(inter, key, expected) - actual := storageMap.ReadValue(nil, key) + actual := storageMap.ReadValue(inter, key) require.Equal(t, expected, actual) } diff --git a/runtime/storage_test.go b/runtime/storage_test.go index cc4e4db2af..437438f97f 100644 --- a/runtime/storage_test.go +++ b/runtime/storage_test.go @@ -3174,7 +3174,7 @@ func TestRuntimeStorageInternalAccess(t *testing.T) { // Read first - firstValue := storageMap.ReadValue(nil, interpreter.StringStorageMapKey("first")) + firstValue := storageMap.ReadValue(inter, interpreter.StringStorageMapKey("first")) RequireValuesEqual( t, inter, @@ -3184,7 +3184,7 @@ func TestRuntimeStorageInternalAccess(t *testing.T) { // Read second - secondValue := storageMap.ReadValue(nil, interpreter.StringStorageMapKey("second")) + secondValue := storageMap.ReadValue(inter, interpreter.StringStorageMapKey("second")) require.IsType(t, &interpreter.ArrayValue{}, secondValue) arrayValue := secondValue.(*interpreter.ArrayValue) @@ -3199,7 +3199,7 @@ func TestRuntimeStorageInternalAccess(t *testing.T) { // Read r - rValue := storageMap.ReadValue(nil, interpreter.StringStorageMapKey("r")) + rValue := storageMap.ReadValue(inter, interpreter.StringStorageMapKey("r")) require.IsType(t, &interpreter.CompositeValue{}, rValue) _, err = ExportValue(rValue, inter) @@ -6837,7 +6837,7 @@ func TestRuntimeStorage2(t *testing.T) { require.Equal(t, uint64(len(domainValues)), domainStorageMap.Count()) for k, expectedV := range domainValues { - v := domainStorageMap.ReadValue(nil, k) + v := domainStorageMap.ReadValue(inter, k) ev, ok := v.(interpreter.EquatableValue) require.True(t, ok) require.True(t, ev.Equal(inter, expectedV)) @@ -7121,7 +7121,7 @@ func TestRuntimeStorage2(t *testing.T) { require.Equal(t, uint64(len(domainValues)), domainStorageMap.Count()) for k, expectedValue := range domainValues { - v := domainStorageMap.ReadValue(nil, k) + v := domainStorageMap.ReadValue(inter, k) ev := v.(interpreter.EquatableValue) require.True(t, ev.Equal(inter, expectedValue)) } @@ -7952,7 +7952,7 @@ func checkAccountStorageMapData( // Check values stored in domain storage map for key, expectedValue := range expectedDomainValues { - value := domainStorageMap.ReadValue(nil, key) + value := domainStorageMap.ReadValue(inter, key) ev, ok := value.(interpreter.EquatableValue) require.True(tb, ok) diff --git a/tools/storage-explorer/main.go b/tools/storage-explorer/main.go index 6ec3e72fc5..654f2b19a4 100644 --- a/tools/storage-explorer/main.go +++ b/tools/storage-explorer/main.go @@ -241,7 +241,7 @@ func NewAccountStorageMapValueHandler( var preparedValue Value - value := storageMap.ReadValue(nil, key) + value := storageMap.ReadValue(inter, key) var nested []any err = json.NewDecoder(r.Body).Decode(&nested) From ce56b4418fad0e84e7fad94b2da013877da09e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 10:13:48 -0700 Subject: [PATCH 107/139] clear atree container pointer when removing --- interpreter/storage_test.go | 26 ++++++++++++++++---- interpreter/value_array.go | 15 +++++++----- interpreter/value_composite.go | 15 +++++++----- interpreter/value_dictionary.go | 15 +++++++----- interpreter/value_test.go | 42 +++++++++++++++++++++++++++++---- 5 files changed, 86 insertions(+), 27 deletions(-) diff --git a/interpreter/storage_test.go b/interpreter/storage_test.go index 316272a0a3..77266a7b31 100644 --- a/interpreter/storage_test.go +++ b/interpreter/storage_test.go @@ -197,14 +197,22 @@ func TestArrayStorage(t *testing.T) { value.Insert( inter, 0, - element, + // Copy element "onto the stack", insert requires a copy + element.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) require.True(t, bool(value.Contains(inter, element))) // array + new copy of composite element // NOTE: original composite value is inlined in parent array. - require.Equal(t, 2, storage.BasicSlabStorage.Count()) + require.Equal(t, 3, storage.BasicSlabStorage.Count()) retrievedStorable, ok, err := storage.BasicSlabStorage.Retrieve(value.SlabID()) require.NoError(t, err) @@ -246,7 +254,15 @@ func TestArrayStorage(t *testing.T) { Type: element.StaticType(inter), }, common.ZeroAddress, - element, + // Copy element "onto the stack", append requires a copy + element.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) require.True(t, bool(value.Contains(inter, element))) @@ -255,7 +271,7 @@ func TestArrayStorage(t *testing.T) { // array + new copy of composite element // NOTE: original composite value is inlined in parent array. - require.Equal(t, 2, storage.BasicSlabStorage.Count()) + require.Equal(t, 3, storage.BasicSlabStorage.Count()) _, ok, err := storage.BasicSlabStorage.Retrieve(value.SlabID()) require.NoError(t, err) @@ -263,7 +279,7 @@ func TestArrayStorage(t *testing.T) { value.Remove(inter, 0) - require.Equal(t, 3, storage.BasicSlabStorage.Count()) + require.Equal(t, 4, storage.BasicSlabStorage.Count()) retrievedStorable, ok, err := storage.BasicSlabStorage.Retrieve(value.SlabID()) require.NoError(t, err) diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 905cba2267..6fa8012bb7 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1646,14 +1646,17 @@ func (v *ArrayValue) Transfer( v.array = nil } else if remove { // Non-resource Transfer with remove: the source's slabs were just - // deleted by the PopIterate above, so any cached canonical wrapper - // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, - // so this can't cause a wrong-wrapper read, but the entry would - // otherwise leak for the lifetime of the cache. + // deleted by the PopIterate above. Evict the cached canonical wrapper + // (otherwise its entry would leak referencing gone slabs) and nil + // `v.array` so any subsequent use of the source wrapper fails cleanly + // via `isStaleAtreeView` / `InvalidatedContainerViewError` rather than + // crashing inside atree on a missing slab. // - // `v.array` is intentionally NOT nilled: existing callers - // read metadata off the source wrapper after a non-resource remove. + // Callers that need to use the source value after this point must + // Transfer it onto the stack (`remove=false`) first to obtain a fresh + // wrapper at a new SlabID. context.ClearCanonicalAtreeContainer(v.valueID) + v.array = nil } res := newArrayValueFromAtreeArray( diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 2a663d9432..136fbb0a66 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -1582,14 +1582,17 @@ func (v *CompositeValue) Transfer( v.dictionary = nil } else if remove { // Non-resource Transfer with remove: the source's slabs were just - // deleted by the PopIterate above, so any cached canonical wrapper - // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, - // so this can't cause a wrong-wrapper read, but the entry would - // otherwise leak for the lifetime of the cache. + // deleted by the PopIterate above. Evict the cached canonical wrapper + // (otherwise its entry would leak referencing gone slabs) and nil + // `v.dictionary` so any subsequent use of the source wrapper fails + // cleanly via `isStaleAtreeView` / `InvalidatedContainerViewError` + // rather than crashing inside atree on a missing slab. // - // `v.dictionary` is intentionally NOT nilled: existing callers - // read metadata off the source wrapper after a non-resource remove. + // Callers that need to use the source value after this point must + // Transfer it onto the stack (`remove=false`) first to obtain a fresh + // wrapper at a new SlabID. context.ClearCanonicalAtreeContainer(v.valueID) + v.dictionary = nil } info := NewCompositeTypeInfo( diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 19f7670a6a..515c5f0e30 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -1711,14 +1711,17 @@ func (v *DictionaryValue) Transfer( v.dictionary = nil } else if remove { // Non-resource Transfer with remove: the source's slabs were just - // deleted by the PopIterate above, so any cached canonical wrapper - // for `v` now backs onto gone slabs. Atree doesn't reuse SlabIDs, - // so this can't cause a wrong-wrapper read, but the entry would - // otherwise leak for the lifetime of the cache. + // deleted by the PopIterate above. Evict the cached canonical wrapper + // (otherwise its entry would leak referencing gone slabs) and nil + // `v.dictionary` so any subsequent use of the source wrapper fails + // cleanly via `isStaleAtreeView` / `InvalidatedContainerViewError` + // rather than crashing inside atree on a missing slab. // - // `v.dictionary` is intentionally NOT nilled: existing callers - // read metadata off the source wrapper after a non-resource remove. + // Callers that need to use the source value after this point must + // Transfer it onto the stack (`remove=false`) first to obtain a fresh + // wrapper at a new SlabID. context.ClearCanonicalAtreeContainer(v.valueID) + v.dictionary = nil } res := newDictionaryValueFromAtreeMap( diff --git a/interpreter/value_test.go b/interpreter/value_test.go index 1286382d6b..fec516a7a8 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -302,7 +302,15 @@ func TestOwnerArrayRemove(t *testing.T) { Type: PrimitiveStaticTypeAnyStruct, }, owner, - value, + // Copy value "onto the stack", append requires a copy + value.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) assert.Equal(t, owner, array.GetOwner()) @@ -517,7 +525,16 @@ func TestOwnerDictionaryRemove(t *testing.T) { ValueType: PrimitiveStaticTypeAnyStruct, }, newOwner, - keyValue, value1, + keyValue, + // Copy value "onto the stack", insert requires a copy + value1.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) assert.Equal(t, newOwner, dictionary.GetOwner()) @@ -527,7 +544,15 @@ func TestOwnerDictionaryRemove(t *testing.T) { existingValue := dictionary.Insert( inter, keyValue, - value2, + // Copy value "onto the stack", insert requires a copy + value2.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) require.IsType(t, &SomeValue{}, existingValue) innerValue := existingValue.(*SomeValue).InnerValue() @@ -563,7 +588,16 @@ func TestOwnerDictionaryInsertExisting(t *testing.T) { ValueType: PrimitiveStaticTypeAnyStruct, }, newOwner, - keyValue, value, + keyValue, + // Copy value "onto the stack", insert requires a copy + value.Transfer( + inter, + atree.Address{}, + false, + nil, + map[atree.ValueID]struct{}{}, + true, + ), ) assert.Equal(t, newOwner, dictionary.GetOwner()) From 984e7c0ff526999dc89def617b2f86a309dcae37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 14:07:57 -0700 Subject: [PATCH 108/139] improve comments --- bbq/vm/context.go | 6 ++- interpreter/interpreter_expression.go | 16 ++++-- interpreter/sharedstate.go | 7 +-- interpreter/value_array.go | 9 ++-- interpreter/value_composite.go | 18 +++++-- interpreter/value_dictionary.go | 18 +++++-- interpreter/value_test.go | 73 ++++++++++++++++++--------- 7 files changed, 101 insertions(+), 46 deletions(-) diff --git a/bbq/vm/context.go b/bbq/vm/context.go index 71cf53b76b..a2cf1603c8 100644 --- a/bbq/vm/context.go +++ b/bbq/vm/context.go @@ -109,7 +109,11 @@ func (c *Context) ClearCanonicalAtreeContainer(valueID atree.ValueID) { delete(c.canonicalAtreeContainers, valueID) } -// ClearAllCanonicalAtreeContainers — see Interpreter.ClearAllCanonicalAtreeContainers. +// ClearAllCanonicalAtreeContainers drops every entry in the canonical wrapper cache. +// Call this when the underlying `atree.Storage` is being swapped out +// (e.g. test harnesses that commit to a ledger and reopen storage): +// cached wrappers hold `*atree.Array`/`*atree.OrderedMap` pointers into the *old* storage, +// and would silently mutate the old storage if returned from a post-swap canonicalization. func (c *Context) ClearAllCanonicalAtreeContainers() { clear(c.canonicalAtreeContainers) } diff --git a/interpreter/interpreter_expression.go b/interpreter/interpreter_expression.go index cf18765121..e561d4c70e 100644 --- a/interpreter/interpreter_expression.go +++ b/interpreter/interpreter_expression.go @@ -498,11 +498,17 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value } // CheckInvalidatedValueOrValueReference checks whether a value is either: -// - an invalidated resource, or a reference to one -// - an atree-backed container wrapper whose underlying atree container has -// been invalidated, or whose cached value ID has diverged from the live one -// (defensive invariant check; -// see `AtreeBackedValue` and `InvalidatedContainerViewError`) +// - an invalidated resource, or a reference to one — panics with +// `InvalidatedResourceError` or `InvalidatedResourceReferenceError`. +// - an atree-backed container wrapper (ArrayValue, DictionaryValue, +// CompositeValue) whose underlying atree container has been invalidated +// (nilled out by Destroy or Transfer), or whose cached value ID has +// diverged from the live one — panics with `InvalidatedContainerViewError`. +// This second check is defensive: with atree's shared-state design and +// Cadence's canonical wrapper cache, the divergence path should not fire +// in practice; the nil-out path fires whenever a wrapper is used after +// the resource it backed was destroyed/transferred, or after a non-resource +// Transfer with `remove=true` deleted its source slabs. func CheckInvalidatedValueOrValueReference( value Value, context ValueStaticTypeContext, diff --git a/interpreter/sharedstate.go b/interpreter/sharedstate.go index 512c6b40ca..3f88586380 100644 --- a/interpreter/sharedstate.go +++ b/interpreter/sharedstate.go @@ -48,9 +48,10 @@ type SharedState struct { // canonicalAtreeContainers deduplicates the Cadence-level wrappers // (ArrayValue, DictionaryValue, CompositeValue) created for atree // containers, keyed by their atree value ID. - // The first wrapper created for a given value ID - // via a canonicalizing path (see `MustConvertStoredContainerElement`) - // becomes canonical; + // The first wrapper created for a given value ID via a canonicalizing + // path — `*atree.Array.Get`, `*atree.OrderedMap.Get`, mutable-iterator + // `Next`, `DomainStorageMap.ReadValue`, or any other reader that wraps + // an atree element it just retrieved — becomes canonical; // subsequent retrievals reuse the same wrapper instance. // // Cadence relies on this in two ways: diff --git a/interpreter/value_array.go b/interpreter/value_array.go index 6fa8012bb7..a233194f22 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -450,9 +450,12 @@ func (v *ArrayValue) IsDestroyed() bool { return v.isDestroyed } -// canonicalAtreeContainer reports the underlying atree array, -// or nil if the wrapper is invalidated (destroyed or its backing nilled out). -// See `canonicalizeContainerElement` for use. +// canonicalAtreeContainer reports the underlying atree array for canonical +// wrapper-cache bookkeeping, or nil if the wrapper is invalidated (destroyed +// or its backing nilled out by Transfer). The cache uses the returned +// container's `ValueID`, `HasParentUpdater`, and `HasReadOnlyMutationCallback` +// predicates to decide whether to install / replace the wrapper as canonical; +// a nil return tells the cache to skip canonicalization for this wrapper. func (v *ArrayValue) canonicalAtreeContainer() atreeContainer { if v.array == nil || v.isDestroyed { return nil diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 136fbb0a66..6808bfc43d 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -63,8 +63,13 @@ type CompositeValue struct { Kind common.CompositeKind isDestroyed bool - // valueID — defensive cache of the atree value ID captured at construction. - // See `ArrayValue.valueID` for the full rationale. + // valueID is the atree value ID captured at construction time. + // With atree's shared-state design + Cadence's canonical wrapper cache, + // the value ID returned by `v.dictionary.ValueID()` is stable for the lifetime + // of the wrapper, so the cached value matches the live one. + // The cache is used by `isStaleAtreeView` as a defensive invariant check + // to catch unexpected divergence (and a stable identifier when + // `v.dictionary` has been nilled out by Destroy/Transfer). valueID atree.ValueID } @@ -356,9 +361,12 @@ func (v *CompositeValue) IsDestroyed() bool { return v.isDestroyed } -// canonicalAtreeContainer reports the underlying atree map, -// or nil if the wrapper is invalidated (destroyed or its backing nilled out). -// See `canonicalizeContainerElement` for use. +// canonicalAtreeContainer reports the underlying atree map for canonical +// wrapper-cache bookkeeping, or nil if the wrapper is invalidated (destroyed +// or its backing nilled out by Transfer). The cache uses the returned +// container's `ValueID`, `HasParentUpdater`, and `HasReadOnlyMutationCallback` +// predicates to decide whether to install / replace the wrapper as canonical; +// a nil return tells the cache to skip canonicalization for this wrapper. func (v *CompositeValue) canonicalAtreeContainer() atreeContainer { if v.dictionary == nil || v.isDestroyed { return nil diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 515c5f0e30..1e3c7580ad 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -40,8 +40,13 @@ type DictionaryValue struct { isDestroyed bool elementSize uint - // valueID — defensive cache of the atree value ID captured at construction. - // See `ArrayValue.valueID` for the full rationale. + // valueID is the atree value ID captured at construction time. + // With atree's shared-state design + Cadence's canonical wrapper cache, + // the value ID returned by `v.dictionary.ValueID()` is stable for the lifetime + // of the wrapper, so the cached value matches the live one. + // The cache is used by `isStaleAtreeView` as a defensive invariant check + // to catch unexpected divergence (and a stable identifier when + // `v.dictionary` has been nilled out by Destroy/Transfer). valueID atree.ValueID } @@ -537,9 +542,12 @@ func (v *DictionaryValue) IsDestroyed() bool { return v.isDestroyed } -// canonicalAtreeContainer reports the underlying atree map, -// or nil if the wrapper is invalidated (destroyed or its backing nilled out). -// See `canonicalizeContainerElement` for use. +// canonicalAtreeContainer reports the underlying atree map for canonical +// wrapper-cache bookkeeping, or nil if the wrapper is invalidated (destroyed +// or its backing nilled out by Transfer). The cache uses the returned +// container's `ValueID`, `HasParentUpdater`, and `HasReadOnlyMutationCallback` +// predicates to decide whether to install / replace the wrapper as canonical; +// a nil return tells the cache to skip canonicalization for this wrapper. func (v *DictionaryValue) canonicalAtreeContainer() atreeContainer { if v.dictionary == nil || v.isDestroyed { return nil diff --git a/interpreter/value_test.go b/interpreter/value_test.go index fec516a7a8..889e396d1f 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -691,12 +691,14 @@ func TestOwnerCompositeTransfer(t *testing.T) { assert.Equal(t, common.ZeroAddress, value.GetOwner()) } -// TestArrayValueTransferRemoveEvictsCanonicalCache pins down the cache -// eviction in the `else if remove` branch of `ArrayValue.Transfer`: -// for a non-resource array, Transfer with `remove=true` deletes the source -// slabs and must evict any cached canonical wrapper for that value ID -// (otherwise the entry leaks for the lifetime of the cache). -func TestArrayValueTransferRemoveEvictsCanonicalCache(t *testing.T) { +// TestArrayValueTransferRemoveInvalidatesSourceAndEvictsCache pins down the +// `else if remove` branch of `ArrayValue.Transfer` for non-resources: +// - the cached canonical wrapper for the source's value ID is evicted, and +// - the source wrapper itself is invalidated (`v.array = nil`), so any +// subsequent use through `CheckInvalidatedValueOrValueReference` +// surfaces as `InvalidatedContainerViewError` rather than a nil-deref or +// a use-after-free of deleted atree slabs. +func TestArrayValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing.T) { t.Parallel() @@ -736,9 +738,14 @@ func TestArrayValueTransferRemoveEvictsCanonicalCache(t *testing.T) { ) } -// TestDictionaryValueTransferRemoveEvictsCanonicalCache — see -// TestArrayValueTransferRemoveEvictsCanonicalCache. -func TestDictionaryValueTransferRemoveEvictsCanonicalCache(t *testing.T) { +// TestDictionaryValueTransferRemoveInvalidatesSourceAndEvictsCache pins down +// the `else if remove` branch of `DictionaryValue.Transfer` for non-resources: +// - the cached canonical wrapper for the source's value ID is evicted, and +// - the source wrapper itself is invalidated (`v.dictionary = nil`), so any +// subsequent use through `CheckInvalidatedValueOrValueReference` +// surfaces as `InvalidatedContainerViewError` rather than a nil-deref or +// a use-after-free of deleted atree slabs. +func TestDictionaryValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing.T) { t.Parallel() @@ -778,9 +785,14 @@ func TestDictionaryValueTransferRemoveEvictsCanonicalCache(t *testing.T) { ) } -// TestCompositeValueTransferRemoveEvictsCanonicalCache — see -// TestArrayValueTransferRemoveEvictsCanonicalCache. -func TestCompositeValueTransferRemoveEvictsCanonicalCache(t *testing.T) { +// TestCompositeValueTransferRemoveInvalidatesSourceAndEvictsCache pins down +// the `else if remove` branch of `CompositeValue.Transfer` for non-resources: +// - the cached canonical wrapper for the source's value ID is evicted, and +// - the source wrapper itself is invalidated (`v.dictionary = nil`), so any +// subsequent use through `CheckInvalidatedValueOrValueReference` +// surfaces as `InvalidatedContainerViewError` rather than a nil-deref or +// a use-after-free of deleted atree slabs. +func TestCompositeValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing.T) { t.Parallel() @@ -813,9 +825,11 @@ func TestCompositeValueTransferRemoveEvictsCanonicalCache(t *testing.T) { ) } -// TestArrayValueTransferNoRemoveKeepsCanonicalCache is the negative -// counterpart: non-resource Transfer without `remove` leaves the source -// wrapper valid, so the cache entry must NOT be evicted. +// TestArrayValueTransferNoRemoveKeepsCanonicalCache asserts that a non-resource +// Transfer without `remove` leaves the source wrapper valid (`v.array` stays +// set, slabs untouched), so the cache entry must NOT be evicted. Guards +// against the `else if remove` cache-clear branch over-firing on plain +// (non-removing) Transfers. func TestArrayValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { t.Parallel() @@ -891,8 +905,11 @@ func TestArrayValueDestroyEvictsCanonicalCache(t *testing.T) { ) } -// TestDictionaryValueDestroyEvictsCanonicalCache — see -// TestArrayValueDestroyEvictsCanonicalCache. +// TestDictionaryValueDestroyEvictsCanonicalCache pins down the cache eviction +// in DictionaryValue.Destroy. Constructs an empty resource-kinded dictionary +// (so the destruction walk has nothing to do), registers it in the cache, +// calls Destroy, asserts the cache entry is gone and the wrapper is marked +// destroyed. func TestDictionaryValueDestroyEvictsCanonicalCache(t *testing.T) { t.Parallel() @@ -924,9 +941,11 @@ func TestDictionaryValueDestroyEvictsCanonicalCache(t *testing.T) { ) } -// TestCompositeValueDestroyEvictsCanonicalCache — see -// TestArrayValueDestroyEvictsCanonicalCache. Uses a resource-kinded composite -// with no fields and no default-destroy events. +// TestCompositeValueDestroyEvictsCanonicalCache pins down the cache eviction +// in CompositeValue.Destroy. Uses a resource-kinded composite with no fields +// and no default-destroy events (so the destruction walk has nothing to do), +// registers it in the cache, calls Destroy, asserts the cache entry is gone +// and the wrapper is marked destroyed. func TestCompositeValueDestroyEvictsCanonicalCache(t *testing.T) { t.Parallel() @@ -1012,8 +1031,11 @@ func TestClearAllCanonicalAtreeContainers(t *testing.T) { assert.Nil(t, inter.CanonicalAtreeContainer(composite.ValueID())) } -// TestDictionaryValueTransferNoRemoveKeepsCanonicalCache — see -// TestArrayValueTransferNoRemoveKeepsCanonicalCache. +// TestDictionaryValueTransferNoRemoveKeepsCanonicalCache asserts that a +// non-resource Transfer without `remove` leaves the source wrapper valid +// (`v.dictionary` stays set, slabs untouched), so the cache entry must NOT +// be evicted. Guards against the `else if remove` cache-clear branch +// over-firing on plain (non-removing) Transfers. func TestDictionaryValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { t.Parallel() @@ -1054,8 +1076,11 @@ func TestDictionaryValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { ) } -// TestCompositeValueTransferNoRemoveKeepsCanonicalCache — see -// TestArrayValueTransferNoRemoveKeepsCanonicalCache. +// TestCompositeValueTransferNoRemoveKeepsCanonicalCache asserts that a +// non-resource Transfer without `remove` leaves the source wrapper valid +// (`v.dictionary` stays set, slabs untouched), so the cache entry must NOT +// be evicted. Guards against the `else if remove` cache-clear branch +// over-firing on plain (non-removing) Transfers. func TestCompositeValueTransferNoRemoveKeepsCanonicalCache(t *testing.T) { t.Parallel() From 55d533edebf5a12e9bfc6fa9356a59eb2f4c07a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 14:10:54 -0700 Subject: [PATCH 109/139] assert container is invalidated --- interpreter/value_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/interpreter/value_test.go b/interpreter/value_test.go index 889e396d1f..525622a002 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -736,6 +736,15 @@ func TestArrayValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing.T) { inter.CanonicalAtreeContainer(valueID), "cache entry must be evicted after non-resource Transfer with remove=true", ) + + defer func() { + err, ok := recover().(error) + require.True(t, ok) + + var invalidatedContainerViewError *InvalidatedContainerViewError + require.ErrorAs(t, err, &invalidatedContainerViewError) + }() + CheckInvalidatedValueOrValueReference(arr, inter) } // TestDictionaryValueTransferRemoveInvalidatesSourceAndEvictsCache pins down @@ -783,6 +792,15 @@ func TestDictionaryValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing inter.CanonicalAtreeContainer(valueID), "cache entry must be evicted after non-resource Transfer with remove=true", ) + + defer func() { + err, ok := recover().(error) + require.True(t, ok) + + var invalidatedContainerViewError *InvalidatedContainerViewError + require.ErrorAs(t, err, &invalidatedContainerViewError) + }() + CheckInvalidatedValueOrValueReference(dictionary, inter) } // TestCompositeValueTransferRemoveInvalidatesSourceAndEvictsCache pins down @@ -823,6 +841,15 @@ func TestCompositeValueTransferRemoveInvalidatesSourceAndEvictsCache(t *testing. inter.CanonicalAtreeContainer(valueID), "cache entry must be evicted after non-resource Transfer with remove=true", ) + + defer func() { + err, ok := recover().(error) + require.True(t, ok) + + var invalidatedContainerViewError *InvalidatedContainerViewError + require.ErrorAs(t, err, &invalidatedContainerViewError) + }() + CheckInvalidatedValueOrValueReference(composite, inter) } // TestArrayValueTransferNoRemoveKeepsCanonicalCache asserts that a non-resource From e760fa6c6cb765bc42e1c67b7edad70aa916b29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 15:00:46 -0700 Subject: [PATCH 110/139] move and add test --- interpreter/indexing_test.go | 81 ++++++++++++++++++++++++++++++++++++ interpreter/member_test.go | 31 -------------- 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/interpreter/indexing_test.go b/interpreter/indexing_test.go index 78ca8ba6a6..bed120a420 100644 --- a/interpreter/indexing_test.go +++ b/interpreter/indexing_test.go @@ -22,6 +22,7 @@ import ( "encoding/binary" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/onflow/atree" @@ -231,6 +232,86 @@ func TestInterpretIndexingExpressionTransferSwapStatement(t *testing.T) { ) } +// TestInterpretSwapIndexOnDictionaryReference exercises non-resource +// through-reference IndexExpression swap: `dictRef[k1] <-> dictRef[k2]` +// where `dictRef` is `auth(Mutate) &{String: AnyStruct}`. +// +// Pre-B2 the runtime used GetIndex+NewRef for the swap operands and +// SetIndex stored a NonStorable-wrapped reference into the dictionary +// slot. The first set also evicted the slab held by the second +// operand's view, surfacing as InvalidatedContainerViewError at the +// second set (caught by the MutationCount detection added on this +// branch). +// +// Post-B2 the runtime uses extract-then-write (RemoveKey + placeholder) +// for IndexExpression swap operands; sema records target types (not +// cascaded reference types) in SwapStatementTypes so TransferAndConvert +// accepts the extracted underlying values. See sema/check_swap.go. +func TestInterpretSwapIndexOnDictionaryReference(t *testing.T) { + t.Parallel() + + t.Run("completes without error", func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + struct Foo { + var array: [Int] + init() { + self.array = [] + } + } + + fun test() { + let dict: {String: AnyStruct} = {"foo": Foo(), "bar": Foo()} + let dictRef = &dict as auth(Mutate) &{String: AnyStruct} + + dictRef["foo"] <-> dictRef["bar"] + } + `) + + _, err := inter.Invoke("test") + require.NoError(t, err) + }) + + t.Run("stores values, not references", func(t *testing.T) { + t.Parallel() + + // Reading via the direct (non-reference) dict variable returns + // the dict's actual element type (AnyStruct?). The `as! Foo` cast + // only succeeds when the slot holds a `Foo`, not a `&Foo`. + // + // Pre-B2: cast fails with "expected `Foo`, got `&Foo`". + // Post-B2: cast succeeds; the swap exchanged actual values. + inter := parseCheckAndPrepare(t, ` + struct Foo { + var marker: Int + init(_ m: Int) { self.marker = m } + } + + fun test(): Int { + let dict: {String: AnyStruct} = {"foo": Foo(11), "bar": Foo(22)} + let dictRef = &dict as auth(Mutate) &{String: AnyStruct} + + dictRef["foo"] <-> dictRef["bar"] + + // Read via the underlying dict (not through dictRef), which + // returns AnyStruct? — castable to Foo iff the slot holds a + // real Foo value, not a reference. + let f = (dict["foo"]! as! Foo).marker + let b = (dict["bar"]! as! Foo).marker + + // Encode the swap result as a single number so the assertion + // is order-sensitive: 22 at "foo", 11 at "bar". + return f * 100 + b + } + `) + + result, err := inter.Invoke("test") + require.NoError(t, err) + assert.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(2211), result) + }) +} + func TestInterpretIndexingTypeConfusedValue(t *testing.T) { t.Parallel() diff --git a/interpreter/member_test.go b/interpreter/member_test.go index 437d9519fe..1e6d8f616d 100644 --- a/interpreter/member_test.go +++ b/interpreter/member_test.go @@ -1282,37 +1282,6 @@ func TestInterpretMemberAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("anystruct swap on reference", func(t *testing.T) { - t.Parallel() - - // Non-resource through-reference swap uses extract-then-write - // semantics: both operands are removed from the dictionary via - // RemoveKey+InsertPlaceholder before either set, so the second - // set does not evict a slab still referenced by the first - // operand's view. The swap-statement types are recorded as the - // target (value) types, not the cascaded reference types, so - // TransferAndConvert's defensive type check accepts the - // extracted underlying values. See sema/check_swap.go. - inter := parseCheckAndPrepare(t, ` - struct Foo { - var array: [Int] - init() { - self.array = [] - } - } - - fun test() { - let dict: {String: AnyStruct} = {"foo": Foo(), "bar": Foo()} - let dictRef = &dict as auth(Mutate) &{String: AnyStruct} - - dictRef["foo"] <-> dictRef["bar"] - } - `) - - _, err := inter.Invoke("test") - require.NoError(t, err) - }) - t.Run("entitlement map access on field", func(t *testing.T) { t.Parallel() From 0c144862b1e4997530fa6c089c580afe6a068a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 4 Jun 2026 18:05:11 -0700 Subject: [PATCH 111/139] also mark all member expressions as nested resource moves --- interpreter/indexing_test.go | 259 +++++++++++++++++++++++++++++++++++ sema/check_swap.go | 55 +++----- 2 files changed, 278 insertions(+), 36 deletions(-) diff --git a/interpreter/indexing_test.go b/interpreter/indexing_test.go index bed120a420..02b47e9613 100644 --- a/interpreter/indexing_test.go +++ b/interpreter/indexing_test.go @@ -29,7 +29,9 @@ import ( "github.com/onflow/cadence/common" "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" ) // TestInterpretIndexingExpressionTransfer tests if the indexing value @@ -312,6 +314,263 @@ func TestInterpretSwapIndexOnDictionaryReference(t *testing.T) { }) } +// TestCheckSwapMemberOnCompositeReferenceRejected does two things: +// +// 1. Pins the static rejection of external field-write swaps via a +// reference. Both sides of `compositeRef.field1 <-> compositeRef.field2` +// count as assignments, and sema's `isWriteableMember` denies them +// outside the containing type. This is one of three sema-level +// closures documented in sema/check_swap.go that make the +// MemberExpression swap hazard unreachable in practice. +// +// 2. After suppressing the checker errors, invokes the function at +// runtime to verify the interpreter still produces correct, +// non-corrupted output. This exercises the defense-in-depth +// unconditional MemberExpression marking in sema/check_swap.go: +// if the sema closure above were ever relaxed, the runtime would +// stay correct. +func TestCheckSwapMemberOnCompositeReferenceRejected(t *testing.T) { + t.Parallel() + + inter, err := parseCheckAndPrepareWithOptions(t, ` + struct Foo { + var marker: Int + init(_ m: Int) { self.marker = m } + } + + struct Container { + access(all) var foo: AnyStruct + access(all) var bar: AnyStruct + init() { + self.foo = Foo(11) + self.bar = Foo(22) + } + } + + fun test(): Int { + let c = Container() + let cRef = &c as &Container + + cRef.foo <-> cRef.bar + + let f = (c.foo as! Foo).marker + let b = (c.bar as! Foo).marker + + return f * 100 + b + } + `, ParseCheckAndInterpretOptions{ + HandleCheckerError: func(err error) { + errs := RequireCheckerErrors(t, err, 2) + require.IsType(t, &sema.InvalidAssignmentAccessError{}, errs[0]) + require.IsType(t, &sema.InvalidAssignmentAccessError{}, errs[1]) + }, + }) + require.NoError(t, err) + + // Even though sema rejected this program (errors swallowed + // above), invoke at runtime to verify the interpreter would + // still produce a correct, non-corrupted result if the sema + // closure were ever relaxed. + result, err := inter.Invoke("test") + require.NoError(t, err) + assert.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(2211), result) +} + +// TestInterpretSwapMemberOnCompositeFromMethod exercises the in-method +// MemberExpression swap `self.foo <-> self.bar` where the fields are +// `AnyStruct`-typed. This is the path that survives sema's +// InvalidAssignmentAccessError check (see +// TestCheckSwapMemberOnCompositeReferenceRejected), because writes +// from within the containing type are unconditionally permitted. +// +// Despite the OrderedMap-backed field storage being structurally +// identical to a dictionary, this case carries no through-reference +// hazard: for struct/resource methods `self` is declared as the +// CompositeType itself (see sema.declareSelfValue), not a reference, +// so member reads do not cascade auth and do not return references +// into the composite's inlined slab. Attachments are the only +// composites where `self` is a reference (see +// TestInterpretSwapMemberOnAttachmentFromMethod). +func TestInterpretSwapMemberOnCompositeFromMethod(t *testing.T) { + t.Parallel() + + t.Run("completes without error", func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + struct Foo { + var array: [Int] + init() { + self.array = [] + } + } + + struct Container { + access(all) var foo: AnyStruct + access(all) var bar: AnyStruct + init() { + self.foo = Foo() + self.bar = Foo() + } + access(all) fun swap() { + self.foo <-> self.bar + } + } + + fun test() { + let c = Container() + c.swap() + } + `) + + _, err := inter.Invoke("test") + require.NoError(t, err) + }) + + t.Run("stores values, not references", func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + struct Foo { + var marker: Int + init(_ m: Int) { self.marker = m } + } + + struct Container { + access(all) var foo: AnyStruct + access(all) var bar: AnyStruct + init() { + self.foo = Foo(11) + self.bar = Foo(22) + } + access(all) fun swap() { + self.foo <-> self.bar + } + } + + fun test(): Int { + let c = Container() + c.swap() + + let f = (c.foo as! Foo).marker + let b = (c.bar as! Foo).marker + + return f * 100 + b + } + `) + + result, err := inter.Invoke("test") + require.NoError(t, err) + assert.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(2211), result) + }) +} + +// TestInterpretSwapMemberOnAttachmentFromMethod covers in-method +// swap on an attachment, the one composite kind where `self` is a +// reference (`auth(...) &A`, see sema.declareSelfValue's +// CompositeKindAttachment branch). On paper this is structurally +// identical to the IndexExpression-on-dictionary-reference hazard: +// reads through a reference, then two writes. +// +// In practice it is closed at sema: visitMember in +// check_member_expression.go has an explicit special case that +// suppresses the reference cascade for `self.` (the +// `accessedSelfMember == nil` guard), so reads return the underlying +// value and the SetField stores actual values. The test pins that +// post-swap reads yield the swapped underlying values, not stale +// references. +func TestInterpretSwapMemberOnAttachmentFromMethod(t *testing.T) { + t.Parallel() + + t.Run("completes without error", func(t *testing.T) { + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + struct Foo { + var array: [Int] + init() { + self.array = [] + } + } + + resource R {} + + attachment A for R { + access(all) var foo: AnyStruct + access(all) var bar: AnyStruct + init() { + self.foo = Foo() + self.bar = Foo() + } + access(all) fun doSwap() { + self.foo <-> self.bar + } + } + + fun test() { + let r <- create R() + let r2 <- attach A() to <-r + r2[A]!.doSwap() + destroy r2 + } + `) + + _, err := inter.Invoke("test") + require.NoError(t, err) + }) + + t.Run("swap exchanges values across fields", func(t *testing.T) { + t.Parallel() + + // Reading the attachment's AnyStruct fields from outside the + // attachment is impossible without a reference cascade + // (attachments are reference-only), so a direct `as! [Int]` + // cast on the post-swap state can't be observed. Instead the + // observation is folded into the attachment's own methods, + // where the array reference is well-defined: read the first + // element of each field after the swap and encode both into a + // single Int. Pre-fix: the swap would store references and the + // reads either fail or return the unswapped 11/22; post-fix: + // values are exchanged and the reads return 22/11. + inter := parseCheckAndPrepare(t, ` + resource R {} + + attachment A for R { + access(all) var foo: AnyStruct + access(all) var bar: AnyStruct + init() { + self.foo = [11] + self.bar = [22] + } + access(all) fun doSwap() { + self.foo <-> self.bar + } + access(all) fun probe(): Int { + // If swap stored actual values, the cast to [Int] + // succeeds. If swap stored references (pre-fix), + // the cast fails or the post-swap state is unreadable. + let fooArr = self.foo as! [Int] + let barArr = self.bar as! [Int] + return fooArr[0] * 100 + barArr[0] + } + } + + fun test(): Int { + let r <- create R() + let r2 <- attach A() to <-r + r2[A]!.doSwap() + let probed = r2[A]!.probe() + destroy r2 + return probed + } + `) + + result, err := inter.Invoke("test") + require.NoError(t, err) + assert.Equal(t, interpreter.NewUnmeteredIntValueFromInt64(2211), result) + }) +} + func TestInterpretIndexingTypeConfusedValue(t *testing.T) { t.Parallel() diff --git a/sema/check_swap.go b/sema/check_swap.go index 2f3282669d..a619197c03 100644 --- a/sema/check_swap.go +++ b/sema/check_swap.go @@ -54,45 +54,28 @@ func (checker *Checker) VisitSwapStatement(swap *ast.SwapStatement) (_ struct{}) }, ) - // Mark IndexExpression swap operands as nested resource moves - // unconditionally so the runtime uses extract-then-write semantics - // (RemoveIndex + placeholder) for them. This eliminates a corruption - // hazard in the non-resource through-reference IndexExpression swap - // path: read-via-reference returns a wrapper into the container's - // inlined slab, and the second set's atree eviction-cleanup would - // drain that slab while the first operand still holds a view of it. - // - // MemberExpression operands are not affected here: CompositeValue - // fields are stored at fixed slab offsets and a SetField does not - // trigger an eviction-cleanup that could drain a sibling field's - // view. RemoveField for non-resource fields would also be ill-defined - // (no nil sentinel for required non-optional fields). Resource-typed - // MemberExpression operands are still marked below by the existing - // resource-aware path. - - if _, ok := swap.Left.(*ast.IndexExpression); ok { - checker.elaborateNestedResourceMoveExpression(swap.Left) - } else if leftTargetType.IsResourceType() { - checker.elaborateNestedResourceMoveExpression(swap.Left) - } + for _, side := range []ast.Expression{swap.Left, swap.Right} { - if _, ok := swap.Right.(*ast.IndexExpression); ok { - checker.elaborateNestedResourceMoveExpression(swap.Right) - } else if rightTargetType.IsResourceType() { - checker.elaborateNestedResourceMoveExpression(swap.Right) - } + // Mark IndexExpression and MemberExpression swap operands + // as nested resource moves unconditionally, so the runtime uses + // extract-then-write semantics (RemoveIndex/RemoveField + placeholder) + // instead of get + set. - // If the left or right side is an index expression, - // and the indexed type (type of the target expression) is a resource type, - // then the target expression must be considered as a nested resource move expression. - // - // This is because the evaluation of the index expression - // should not be able to access/move the target resource. - // - // For example, if a side is `a.b[c()]`, then `a.b` is the target expression. - // If `a.b` is a resource, then `c()` should not be able to access/move it. + switch side.(type) { + case *ast.IndexExpression, *ast.MemberExpression: + checker.elaborateNestedResourceMoveExpression(side) + } + + // If the left or right side is an index expression, + // and the indexed type (type of the target expression) is a resource type, + // then the target expression must be considered as a nested resource move expression. + // + // This is because the evaluation of the index expression + // should not be able to access/move the target resource. + // + // For example, if a side is `a.b[c()]`, then `a.b` is the target expression. + // If `a.b` is a resource, then `c()` should not be able to access/move it. - for _, side := range []ast.Expression{swap.Left, swap.Right} { if indexExpression, ok := side.(*ast.IndexExpression); ok { indexExpressionTypes, ok := checker.Elaboration.IndexExpressionTypes(indexExpression) From 09606ba2ddb6a5c50994ed2683eabe2f77ed2927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 5 Jun 2026 10:34:40 -0700 Subject: [PATCH 112/139] adjust test expectation --- bbq/compiler/compiler_test.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 3763299bfe..4e3a343748 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -10579,28 +10579,26 @@ func TestCompileSwapMembers(t *testing.T) { IsTempVar: true, }, - // get left (s.x) + // get left (s.x) via extract-then-write: RemoveField opcode.PrettyInstructionGetLocal{Local: tempIndex1}, - opcode.PrettyInstructionGetField{ + opcode.PrettyInstructionRemoveField{ FieldName: constant.DecodedConstant{ Data: "x", Kind: constant.RawString, }, - AccessedType: sType, }, opcode.PrettyInstructionSetLocal{ Local: tempIndex3, IsTempVar: true, }, - // get right (s.y) + // get right (s.y) via extract-then-write: RemoveField opcode.PrettyInstructionGetLocal{Local: tempIndex2}, - opcode.PrettyInstructionGetField{ + opcode.PrettyInstructionRemoveField{ FieldName: constant.DecodedConstant{ Data: "y", Kind: constant.RawString, }, - AccessedType: sType, }, opcode.PrettyInstructionSetLocal{ Local: tempIndex4, From 1c3664a9b15295611131f0edf31ccf6f6effc8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 5 Jun 2026 11:00:04 -0700 Subject: [PATCH 113/139] add accessed type check to RemoveField instruction --- bbq/compiler/compiler.go | 14 ++++++++------ bbq/compiler/compiler_test.go | 1 + bbq/opcode/instructions.go | 12 ++++++++++-- bbq/opcode/instructions.yml | 2 ++ bbq/opcode/pretty_instructions.go | 5 ++++- bbq/opcode/pretty_test.go | 6 +++++- bbq/opcode/print_test.go | 2 +- bbq/vm/vm.go | 2 ++ 8 files changed, 33 insertions(+), 11 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index e8c0db92f7..d25c2b8496 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -3336,18 +3336,20 @@ func (c *Compiler[_, _]) compileMemberAccess(expression *ast.MemberExpression) { panic(errors.NewUnreachableError()) } + accessedType := memberAccessInfo.AccessedType + if memberAccessInfo.IsOptional { + accessedType = sema.UnwrapOptionalType(accessedType) + } + isNestedResourceMove := c.DesugaredElaboration.IsNestedResourceMoveExpression(expression) if isNestedResourceMove { + accessedTypeIndex := c.getOrAddType(accessedType) memberNameConstant := c.addRawStringConst(memberName) c.emit(opcode.InstructionRemoveField{ - FieldName: memberNameConstant.index, + FieldName: memberNameConstant.index, + AccessedType: accessedTypeIndex, }) } else { - accessedType := memberAccessInfo.AccessedType - if memberAccessInfo.IsOptional { - accessedType = sema.UnwrapOptionalType(accessedType) - } - memberKind := memberAccessInfo.Member.DeclarationKind switch memberKind { diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 6d03058133..1e2f2bdbc3 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -9472,6 +9472,7 @@ func TestCompileSecondValueAssignment(t *testing.T) { Data: "bar", Kind: constant.RawString, }, + AccessedType: fooType, }, opcode.PrettyInstructionTransferAndConvert{ ValueType: barType, diff --git a/bbq/opcode/instructions.go b/bbq/opcode/instructions.go index f81c0226d6..d67f8ee244 100644 --- a/bbq/opcode/instructions.go +++ b/bbq/opcode/instructions.go @@ -446,7 +446,8 @@ func (i InstructionGetField) Pretty(program ProgramForInstructions) PrettyInstru // Pops a value off the stack, the target. // Remove the value of the given field from the target, and pushes it onto the stack. type InstructionRemoveField struct { - FieldName uint16 + FieldName uint16 + AccessedType uint16 } var _ Instruction = InstructionRemoveField{} @@ -465,6 +466,8 @@ func (i InstructionRemoveField) String() string { func (i InstructionRemoveField) OperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfArgument(sb, "fieldName", i.FieldName, colorize) + sb.WriteByte(' ') + printfArgument(sb, "accessedType", i.AccessedType, colorize) } func (i InstructionRemoveField) ResolvedOperandsString(sb *strings.Builder, @@ -472,21 +475,26 @@ func (i InstructionRemoveField) ResolvedOperandsString(sb *strings.Builder, colorize bool) { sb.WriteByte(' ') printfConstantArgument(sb, "fieldName", program.GetConstants()[i.FieldName], colorize) + sb.WriteByte(' ') + printfTypeArgument(sb, "accessedType", program.GetTypes()[i.AccessedType], colorize) } func (i InstructionRemoveField) Encode(code *[]byte) { emitOpcode(code, i.Opcode()) emitUint16(code, i.FieldName) + emitUint16(code, i.AccessedType) } func DecodeRemoveField(ip *uint16, code []byte) (i InstructionRemoveField) { i.FieldName = decodeUint16(ip, code) + i.AccessedType = decodeUint16(ip, code) return i } func (i InstructionRemoveField) Pretty(program ProgramForInstructions) PrettyInstruction { return PrettyInstructionRemoveField{ - FieldName: program.GetConstants()[i.FieldName], + FieldName: program.GetConstants()[i.FieldName], + AccessedType: program.GetTypes()[i.AccessedType], } } diff --git a/bbq/opcode/instructions.yml b/bbq/opcode/instructions.yml index 83a1ca7c1d..7349baa5c5 100644 --- a/bbq/opcode/instructions.yml +++ b/bbq/opcode/instructions.yml @@ -110,6 +110,8 @@ operands: - name: "fieldName" type: "constantIndex" + - name: "accessedType" + type: "typeIndex" valueEffects: pop: - name: "target" diff --git a/bbq/opcode/pretty_instructions.go b/bbq/opcode/pretty_instructions.go index 53d10d2535..1475e44b6d 100644 --- a/bbq/opcode/pretty_instructions.go +++ b/bbq/opcode/pretty_instructions.go @@ -216,7 +216,8 @@ func (i PrettyInstructionGetField) String() string { // Pops a value off the stack, the target. // Remove the value of the given field from the target, and pushes it onto the stack. type PrettyInstructionRemoveField struct { - FieldName constant.DecodedConstant + FieldName constant.DecodedConstant + AccessedType interpreter.StaticType } var _ PrettyInstruction = PrettyInstructionRemoveField{} @@ -230,6 +231,8 @@ func (i PrettyInstructionRemoveField) String() string { sb.WriteString(i.Opcode().String()) sb.WriteByte(' ') printfConstantArgument(&sb, "fieldName", i.FieldName, false) + sb.WriteByte(' ') + printfTypeArgument(&sb, "accessedType", i.AccessedType, false) return sb.String() } diff --git a/bbq/opcode/pretty_test.go b/bbq/opcode/pretty_test.go index c5e180d69e..469fd44b4c 100644 --- a/bbq/opcode/pretty_test.go +++ b/bbq/opcode/pretty_test.go @@ -345,12 +345,16 @@ func TestPrettyInstructionMapping(t *testing.T) { }, }, { - opcode.InstructionRemoveField{FieldName: 1}, + opcode.InstructionRemoveField{ + FieldName: 1, + AccessedType: 1, + }, opcode.PrettyInstructionRemoveField{ FieldName: constant.DecodedConstant{ Data: "myOtherField", Kind: constant.String, }, + AccessedType: interpreter.PrimitiveStaticTypeString, }, }, diff --git a/bbq/opcode/print_test.go b/bbq/opcode/print_test.go index aaa9694cb9..a255a7a966 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -272,7 +272,7 @@ func TestPrintInstruction(t *testing.T) { "Void": {byte(Void)}, "GetField fieldName:258 accessedType:258": {byte(GetField), 1, 2, 1, 2}, "SetField fieldName:258 accessedType:258": {byte(SetField), 1, 2, 1, 2}, - "RemoveField fieldName:258": {byte(RemoveField), 1, 2}, + "RemoveField fieldName:258 accessedType:258": {byte(RemoveField), 1, 2, 1, 2}, "GetTypeIndex indexedType:258 indexingType:258": {byte(GetTypeIndex), 1, 2, 1, 2}, "SetTypeIndex indexedType:258 indexingType:258": {byte(SetTypeIndex), 1, 2, 1, 2}, "RemoveTypeIndex indexedType:258 indexingType:258": {byte(RemoveTypeIndex), 1, 2, 1, 2}, diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index ed8f007061..3547945ede 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -1528,6 +1528,8 @@ func checkMemberAccessTargetType( func opRemoveField(vm *VM, ins opcode.InstructionRemoveField) { memberAccessibleValue := vm.pop().(interpreter.MemberAccessibleValue) + checkMemberAccessTargetType(vm, ins.AccessedType, memberAccessibleValue) + // VM assumes the field name is always a string. fieldNameIndex := ins.FieldName fieldName := getRawStringConstant(vm, fieldNameIndex) From c6e917806c4906a6a580b577c1438d2ebb63c92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 13:57:13 -0700 Subject: [PATCH 114/139] add tests for entitled capability preservation in nested AnyStruct upcasts --- interpreter/dynamic_casting_test.go | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/interpreter/dynamic_casting_test.go b/interpreter/dynamic_casting_test.go index cd75f5cfa1..63756527c1 100644 --- a/interpreter/dynamic_casting_test.go +++ b/interpreter/dynamic_casting_test.go @@ -4613,4 +4613,149 @@ func TestInterpretDynamicCastingEntitledCapability(t *testing.T) { // This is mostly for consistency. require.NoError(t, err) }) + + // testNestedPreservation tests that upcasting a value + // which contains an entitled capability nested inside a container type + // to a container type with `AnyStruct` (or another supertype) at the capability's position + // does not strip the capability's entitlements: + // casting the upcast value back to its original type must succeed. + testNestedPreservation := func( + t *testing.T, + valueType string, + valueExpr string, + targetType string, + ) { + code := fmt.Sprintf( + ` + entitlement E1 + entitlement E2 + + fun getBorrowType(): Type { + return Type() + } + + fun test(cap: Capability) { + let original: %[1]s = %[2]s + let upcasted: %[3]s = original + let downcasted = upcasted as! %[1]s + }`, + valueType, + valueExpr, + targetType, + ) + + inter := parseCheckAndPrepare(t, code) + + result, err := inter.Invoke("getBorrowType") + require.NoError(t, err) + + require.IsType(t, interpreter.TypeValue{}, result) + typeValue := result.(interpreter.TypeValue) + borrowType := typeValue.Type + + capabilityValue := interpreter.NewUnmeteredCapabilityValue( + 4, + interpreter.AddressValue{}, + borrowType, + ) + + _, err = inter.Invoke("test", capabilityValue) + require.NoError(t, err) + } + + t.Run("nested in [AnyStruct]", func(t *testing.T) { + t.Parallel() + + testNestedPreservation(t, + "[Capability]", + "[cap]", + "[AnyStruct]", + ) + }) + + t.Run("nested in {String: AnyStruct}", func(t *testing.T) { + t.Parallel() + + testNestedPreservation(t, + "{String: Capability}", + `{"a": cap}`, + "{String: AnyStruct}", + ) + }) + + t.Run("nested in [AnyStruct?]", func(t *testing.T) { + t.Parallel() + + testNestedPreservation(t, + "[Capability?]", + "[cap]", + "[AnyStruct?]", + ) + }) + + t.Run("nested in [AnyStruct; 1]", func(t *testing.T) { + t.Parallel() + + testNestedPreservation(t, + "[Capability; 1]", + "[cap]", + "[AnyStruct; 1]", + ) + }) + + t.Run("nested in function return type", func(t *testing.T) { + t.Parallel() + + testNestedPreservation(t, + "fun(): Capability", + "fun(): Capability { return cap }", + "fun(): AnyStruct", + ) + }) + + t.Run("getType preserves entitlements after upcast to [AnyStruct]", func(t *testing.T) { + t.Parallel() + + code := ` + entitlement E1 + entitlement E2 + + fun getBorrowType(): Type { + return Type() + } + + fun test(cap: Capability): Type { + let capArray: [Capability] = [cap] + let upcastedCapArray: [AnyStruct] = capArray + return upcastedCapArray[0].getType() + } + ` + + inter := parseCheckAndPrepare(t, code) + + result, err := inter.Invoke("getBorrowType") + require.NoError(t, err) + + require.IsType(t, interpreter.TypeValue{}, result) + typeValue := result.(interpreter.TypeValue) + borrowType := typeValue.Type + + capabilityValue := interpreter.NewUnmeteredCapabilityValue( + 4, + interpreter.AddressValue{}, + borrowType, + ) + + result, err = inter.Invoke("test", capabilityValue) + require.NoError(t, err) + + // The runtime type of the capability must still include the entitlements, + // even after the containing array was assigned to `[AnyStruct]` + require.IsType(t, interpreter.TypeValue{}, result) + resultType := result.(interpreter.TypeValue).Type + assert.Equal(t, + common.TypeID("Capability"), + resultType.ID(), + ) + }) } From 39d152ea7e08cf908702bcc6f725dce12089dde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 14:11:59 -0700 Subject: [PATCH 115/139] forbid mapping access modifier on initializers Special functions do not go through checkDeclarationAccessModifier, so `access(mapping M) init()` was accepted without an error, even though mapping access may only be used on fields. Check mapping access in checkSpecialFunction, skipping events, whose synthesized initializer inherits the already-checked access of the event declaration. Also add tests for mapping access on interface members. --- sema/check_composite_declaration.go | 21 +++++++ sema/entitlements_test.go | 96 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/sema/check_composite_declaration.go b/sema/check_composite_declaration.go index 16ba3899f4..32871c1be3 100644 --- a/sema/check_composite_declaration.go +++ b/sema/check_composite_declaration.go @@ -2538,6 +2538,27 @@ func (checker *Checker) checkSpecialFunction( checker.Elaboration.SetFunctionDeclarationFunctionType(specialFunction.FunctionDeclaration, functionType) + // The access modifier of a special function is otherwise ignored + // (e.g. initializers are considered fully entitled to their container type), + // but a mapping access modifier is still invalid: + // mapping access may only be used on fields. + // + // Skip events: their initializer is synthesized by the parser + // from the event declaration, including its access modifier, + // which was already checked on the event declaration itself. + compositeKind := containerType.GetCompositeKind() + if compositeKind != common.CompositeKindEvent { + declaredAccess := checker.accessFromAstAccess(specialFunction.FunctionDeclaration.Access) + if _, ok := declaredAccess.(*EntitlementMapAccess); ok { + checker.checkEntitlementMapAccess( + functionType, + &compositeKind, + specialFunction.DeclarationKind(), + specialFunction.StartPosition(), + ) + } + } + checker.checkFunction( specialFunction.FunctionDeclaration.ParameterList, nil, diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index e3c058c1be..3611847cf3 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -1077,6 +1077,102 @@ func TestCheckInvalidEntitlementMappingAccess(t *testing.T) { assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) }) + t.Run("invalid struct interface fun", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + struct interface S { + access(mapping M) fun foo() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid resource interface fun", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + resource interface R { + access(mapping M) fun foo() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid struct init", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + struct S { + access(mapping M) init() {} + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid resource init", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + resource R { + access(mapping M) init() {} + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid struct interface init", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + struct interface S { + access(mapping M) init() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + + t.Run("invalid resource interface init", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + entitlement mapping M {} + + resource interface R { + access(mapping M) init() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + + assert.IsType(t, &sema.InvalidNonFieldMappingAccessError{}, errs[0]) + }) + t.Run("missing entitlement mapping declaration fun", func(t *testing.T) { t.Parallel() From 4070a97b8eb226d8272712726eda19bf8d05400a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 14:21:09 -0700 Subject: [PATCH 116/139] add tests for switch-statement unboxing of nested optional nils --- interpreter/switch_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/interpreter/switch_test.go b/interpreter/switch_test.go index ce992b3c73..cab17f9a18 100644 --- a/interpreter/switch_test.go +++ b/interpreter/switch_test.go @@ -333,4 +333,51 @@ func TestInterpretSwitchStatementOptionalUnboxing(t *testing.T) { result, ) }) + + t.Run("double optional outer nil with nil case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let x: Int?? = nil + switch x { + case 1: return "one" + case nil: return "nil" + default: return "other" + } + } + `) + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("nil"), + result, + ) + }) + + t.Run("double optional inner nil with nil case", func(t *testing.T) { + t.Parallel() + + invokable := parseCheckAndPrepare(t, ` + fun test(): String { + let inner: Int? = nil + let x: Int?? = inner + switch x { + case 1: return "one" + case nil: return "nil" + default: return "other" + } + } + `) + result, err := invokable.Invoke("test") + require.NoError(t, err) + AssertValuesEqual( + t, + invokable, + interpreter.NewUnmeteredStringValue("nil"), + result, + ) + }) } From ff6961d6d44108512c8f2769890a3d8f76a2e9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 14:41:36 -0700 Subject: [PATCH 117/139] fix return-info doc comments, remove duplicate test, add guard-else-continue tests --- sema/for_test.go | 22 ++++++++++++++++++++++ sema/return_info.go | 25 +++++++++++++++---------- sema/switch_test.go | 20 -------------------- sema/while_test.go | 22 ++++++++++++++++++++++ 4 files changed, 59 insertions(+), 30 deletions(-) diff --git a/sema/for_test.go b/sema/for_test.go index a4870b4f2a..e7e5f62786 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -1226,6 +1226,28 @@ func TestCheckGuardElseBreakInForLoop(t *testing.T) { require.NoError(t, err) } +func TestCheckGuardElseContinueInForLoop(t *testing.T) { + + t.Parallel() + + // A `continue` is a valid definite exit for a guard's else block. + // Like `break`, the potential loop-targeting jump must propagate + // out of the (potentially-unevaluated) else block, + // so code after the loop remains reachable. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + for _ in [1] { + guard let y = (nil as Int?) else { continue } + return y + } + return 3 + } + `) + + require.NoError(t, err) +} + func TestCheckResourceInvalidationInForLoop(t *testing.T) { t.Parallel() diff --git a/sema/return_info.go b/sema/return_info.go index 4ec3b53955..79f81934db 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -86,9 +86,10 @@ type ReturnInfo struct { // `DefinitelyExited && !MaybeJumped()` — see `checkResourceLoss` // for the rationale. // - // At a case body's terminal state and at a loop body's terminal state, - // `DefinitelyExited` is cleared alongside `DefinitelyReturned` and `DefinitelyHalted` - // when a corresponding `MaybeJumped*` is true, + // When the whole loop/switch has been checked + // and a corresponding `MaybeJumped*` is true, + // `WithLoop`/`WithSwitch` clear `DefinitelyExited` + // alongside `DefinitelyReturned` and `DefinitelyHalted`, // because the maybe-jumping path escapes the construct without terminating the function. DefinitelyExited bool // MaybeJumpedLoop indicates that some path within the current loop @@ -100,9 +101,10 @@ type ReturnInfo struct { // since such jumps are consumed by the loop and are irrelevant // outside it. // - // At a loop body's terminal state, used to clear DR/DH/DE so that the - // surrounding scope's "definitely returns/halts/exits" claim is not - // over-claimed (the jumping path falls past the loop, not the function). + // When the whole loop has been checked, `WithLoop` uses it to clear + // `DefinitelyReturned`/`DefinitelyHalted`/`DefinitelyExited`, + // so that the surrounding scope's "definitely returns/halts/exits" claim + // is not over-claimed (the jumping path falls past the loop, not the function). MaybeJumpedLoop bool // MaybeJumpedSwitch indicates that some path within the current switch // reached a `break` statement targeting that switch. @@ -112,10 +114,13 @@ type ReturnInfo struct { // exit, since such breaks are consumed by the switch and are irrelevant // outside it. // - // At a case body's terminal state, used to clear DR/DH/DE so that the - // switch-level merge does not over-claim definite-return — the break - // path means the switch as a whole can still fall through to the code - // after it. For example: + // When the whole switch has been checked + // (i.e. after all case bodies were checked and branch-merged), + // `WithSwitch` uses it to clear + // `DefinitelyReturned`/`DefinitelyHalted`/`DefinitelyExited`, + // so that the merged "every case definitely terminated" claim + // does not over-promise — a break path means the switch as a whole + // can still fall through to the code after it. For example: // // case x: // if cond { break } diff --git a/sema/switch_test.go b/sema/switch_test.go index 953cab9277..4efdaac2e6 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -713,26 +713,6 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { }) } -func TestCheckFoo(t *testing.T) { - - t.Parallel() - - _, err := ParseAndCheck(t, ` - fun test(cond: Bool): Int { - switch 1 { - case 1: - if cond { break } - return 2 - default: - return 0 - } - return 3 // sema (incorrectly) flags this as unreachable - } - `) - - require.NoError(t, err) -} - func TestCheckSwitchMaybeBreakDoesNotSuppressUnreachableInCase(t *testing.T) { t.Parallel() diff --git a/sema/while_test.go b/sema/while_test.go index 667b0b7e8a..f08486ada9 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -605,6 +605,28 @@ func TestCheckGuardElseBreakInWhileLoop(t *testing.T) { require.NoError(t, err) } +func TestCheckGuardElseContinueInWhileLoop(t *testing.T) { + + t.Parallel() + + // A `continue` is a valid definite exit for a guard's else block. + // Like `break`, the potential loop-targeting jump must propagate + // out of the (potentially-unevaluated) else block, + // so code after the loop remains reachable. + + _, err := ParseAndCheck(t, ` + fun test(): Int { + while true { + guard let y = (nil as Int?) else { continue } + return y + } + return 3 + } + `) + + require.NoError(t, err) +} + func TestCheckResourceInvalidationInWhileLoop(t *testing.T) { t.Parallel() From b48c2edcda4a2bd9aa5c18342fdebbb5734fb0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 14:59:12 -0700 Subject: [PATCH 118/139] improve comments --- bbq/compiler/compiler.go | 4 +++- interpreter/errors.go | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index 171e81155d..e0080b8f15 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -605,7 +605,9 @@ func (c *Compiler[_, _]) compileStatement(statement ast.Statement) { // emitUnreachableIfStatementEndsControlFlow emits an InstructionUnreachable // after the given statement's bytecode if the type checker determined that -// control flow does not continue past it (halt, exhaustive if/switch). +// control flow does not continue past it, +// e.g. because the statement halts (contains a call of a function returning Never), +// or is an if- or switch-statement in which all branches end control flow. // If execution nevertheless reaches the emitted instruction at runtime, // the VM panics with UnreachableInstructionError. // diff --git a/interpreter/errors.go b/interpreter/errors.go index 61bf26fe48..451a676846 100644 --- a/interpreter/errors.go +++ b/interpreter/errors.go @@ -51,8 +51,12 @@ func (e *unsupportedOperation) Error() string { ) } -// UnreachableInstructionError - +// UnreachableInstructionError is raised by defensive checks +// when execution reaches code which the type checker determined to be unreachable: +// by the VM, when it executes an InstructionUnreachable instruction, +// and by the interpreter, when execution continues past a statement +// which the checker determined to end control flow +// (despite the name, the interpreter does not execute instructions). type UnreachableInstructionError struct { ast.Range } @@ -63,7 +67,7 @@ func (*UnreachableInstructionError) IsInternalError() {} func (e *UnreachableInstructionError) Error() string { return fmt.Sprintf( - "%s instruction should be unreachable, but was reached during execution", + "%s code should be unreachable, but was reached during execution", errors.InternalErrorMessagePrefix, ) } From 99cc84a775f0cb9fbadd305038027158c24e2bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 15:28:04 -0700 Subject: [PATCH 119/139] fix elaboration used for defensive unreachable emission after inherited statement --- bbq/compiler/compiler.go | 211 ++++++++++++++-------------- bbq/compiler/compiler_test.go | 114 ++++++++++++++++ bbq/test_utils/utils.go | 9 ++ interpreter/unreachable_test.go | 235 ++++++++++++++++++++++++++++++++ test_utils/test_utils.go | 9 ++ 5 files changed, 476 insertions(+), 102 deletions(-) create mode 100644 interpreter/unreachable_test.go diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index e0080b8f15..fdb826f83a 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -596,9 +596,16 @@ func (c *Compiler[_, _]) compileStatement(statement ast.Statement) { c.compileWithPositionInfo( statement, func() { - c.emit(opcode.InstructionStatement{}) - ast.AcceptStatement[struct{}](statement, c) - c.emitUnreachableIfStatementEndsControlFlow(statement) + // The statement could be inherited, + // e.g. an inherited condition or before-statement of a condition. + // If so, use the corresponding elaboration, + // both for compiling the statement itself, + // and for the defensive unreachable check after it. + c.compilePotentiallyInheritedCode(statement, func() { + c.emit(opcode.InstructionStatement{}) + ast.AcceptStatement[struct{}](statement, c) + c.emitUnreachableIfStatementEndsControlFlow(statement) + }) }, ) } @@ -1480,79 +1487,77 @@ func (c *Compiler[_, _]) emitContinue() { } func (c *Compiler[_, _]) VisitIfStatement(statement *ast.IfStatement) (_ struct{}) { - // If-statements can be coming from inherited conditions. - // If so, use the corresponding elaboration. - c.compilePotentiallyInheritedCode(statement, func() { - var ( - elseJump int - additionalThenScope bool - ) + // NOTE: If-statements can be coming from inherited conditions. + // If so, the corresponding elaboration is used, + // which compileStatement already set up (see compilePotentiallyInheritedCode). + + var ( + elseJump int + additionalThenScope bool + ) + + switch test := statement.Test.(type) { + case ast.Expression: + c.compileExpression(test) + elseJump = c.emitUndefinedJumpIfFalse() - switch test := statement.Test.(type) { - case ast.Expression: - c.compileExpression(test) - elseJump = c.emitUndefinedJumpIfFalse() + case *ast.VariableDeclaration: - case *ast.VariableDeclaration: + varDeclTypes := c.DesugaredElaboration.VariableDeclarationTypes(test) + c.compileVariableDeclaration(test, varDeclTypes, true) - varDeclTypes := c.DesugaredElaboration.VariableDeclarationTypes(test) - c.compileVariableDeclaration(test, varDeclTypes, true) + tempIndex := c.currentFunction.generateLocalIndex() + c.emitSetTempLocal(tempIndex) + + // Test: check if the optional is nil, + // and jump to the else branch if it is + c.emitGetLocal(tempIndex) + elseJump = c.emitUndefinedJumpIfNil() - tempIndex := c.currentFunction.generateLocalIndex() - c.emitSetTempLocal(tempIndex) + // Then branch: unwrap the optional and declare the variable + c.emitGetLocal(tempIndex) + c.emit(opcode.InstructionUnwrap{}) - // Test: check if the optional is nil, - // and jump to the else branch if it is - c.emitGetLocal(tempIndex) - elseJump = c.emitUndefinedJumpIfNil() + // Declare the variable *after* unwrapping the optional, + // in a *new* scope + c.currentFunction.locals.PushNewWithCurrent() + additionalThenScope = true + name := test.Identifier.Identifier + c.emitDeclareLocal(name) - // Then branch: unwrap the optional and declare the variable - c.emitGetLocal(tempIndex) - c.emit(opcode.InstructionUnwrap{}) + default: + panic(errors.NewUnreachableError()) + } - // Declare the variable *after* unwrapping the optional, - // in a *new* scope - c.currentFunction.locals.PushNewWithCurrent() - additionalThenScope = true - name := test.Identifier.Identifier - c.emitDeclareLocal(name) + c.compileBlock( + statement.Then, + common.DeclarationKindUnknown, + nil, + ) - default: - panic(errors.NewUnreachableError()) - } + if additionalThenScope { + c.currentFunction.locals.Pop() + } + elseBlock := statement.Else + if elseBlock != nil { + thenJump := c.emitUndefinedJump() + c.patchJumpHere(elseJump) c.compileBlock( - statement.Then, + elseBlock, common.DeclarationKindUnknown, nil, ) + c.patchJumpHere(thenJump) + } else { + c.patchJumpHere(elseJump) + } - if additionalThenScope { - c.currentFunction.locals.Pop() - } - - elseBlock := statement.Else - if elseBlock != nil { - thenJump := c.emitUndefinedJump() - c.patchJumpHere(elseJump) - c.compileBlock( - elseBlock, - common.DeclarationKindUnknown, - nil, - ) - c.patchJumpHere(thenJump) - } else { - c.patchJumpHere(elseJump) - } - }) return } func (c *Compiler[_, _]) VisitGuardStatement(statement *ast.GuardStatement) (_ struct{}) { - // No need for compilePotentiallyInheritedCode, - // as only if-statements are the result of desugaring of conditions. - var elseJump int switch test := statement.Test.(type) { @@ -1764,39 +1769,36 @@ func (c *Compiler[_, _]) VisitForStatement(statement *ast.ForStatement) (_ struc } func (c *Compiler[_, _]) VisitEmitStatement(statement *ast.EmitStatement) (_ struct{}) { - // Emit statements can be coming from inherited conditions. - // If so, use the corresponding elaboration. - c.compilePotentiallyInheritedCode( - statement, - func() { - invocationExpression := statement.InvocationExpression - arguments := invocationExpression.Arguments - invocationTypes := c.DesugaredElaboration.InvocationExpressionTypes(invocationExpression) - - // Instead of compiling arguments as usual (via compileArguments), - // only convert the arguments and don't transfer them. - - for index, argument := range arguments { - c.compileExpression(argument.Expression) - argumentType := invocationTypes.ArgumentTypes[index] - parameterType := invocationTypes.ParameterTypes[index] - c.emitConvert(argumentType, parameterType) - } + // NOTE: Emit statements can be coming from inherited conditions. + // If so, the corresponding elaboration is used, + // which compileStatement already set up (see compilePotentiallyInheritedCode). - argCount := len(arguments) - if argCount >= math.MaxUint16 { - panic(errors.NewDefaultUserError("invalid argument count")) - } + invocationExpression := statement.InvocationExpression + arguments := invocationExpression.Arguments + invocationTypes := c.DesugaredElaboration.InvocationExpressionTypes(invocationExpression) - eventType := c.DesugaredElaboration.EmitStatementEventType(statement) - typeIndex := c.getOrAddType(eventType) + // Instead of compiling arguments as usual (via compileArguments), + // only convert the arguments and don't transfer them. - c.emit(opcode.InstructionEmitEvent{ - Type: typeIndex, - ArgCount: uint16(argCount), - }) - }, - ) + for index, argument := range arguments { + c.compileExpression(argument.Expression) + argumentType := invocationTypes.ArgumentTypes[index] + parameterType := invocationTypes.ParameterTypes[index] + c.emitConvert(argumentType, parameterType) + } + + argCount := len(arguments) + if argCount >= math.MaxUint16 { + panic(errors.NewDefaultUserError("invalid argument count")) + } + + eventType := c.DesugaredElaboration.EmitStatementEventType(statement) + typeIndex := c.getOrAddType(eventType) + + c.emit(opcode.InstructionEmitEvent{ + Type: typeIndex, + ArgCount: uint16(argCount), + }) return } @@ -1859,25 +1861,24 @@ func (c *Compiler[_, _]) VisitVariableDeclaration(declaration *ast.VariableDecla return } - // Some variable declarations can be coming from inherited before-statements. - // If so, use the corresponding elaboration. - c.compilePotentiallyInheritedCode(declaration, func() { + // NOTE: Some variable declarations can be coming from inherited before-statements. + // If so, the corresponding elaboration is used, + // which compileStatement already set up (see compilePotentiallyInheritedCode). - name := declaration.Identifier.Identifier + name := declaration.Identifier.Identifier - // Value can be nil only for synthetic-result variable. - if declaration.Value == nil { - c.currentFunction.declareLocal(c.Config.MemoryGauge, name) - return - } + // Value can be nil only for synthetic-result variable. + if declaration.Value == nil { + c.currentFunction.declareLocal(c.Config.MemoryGauge, name) + return + } - varDeclTypes := c.DesugaredElaboration.VariableDeclarationTypes(declaration) - c.compileVariableDeclaration(declaration, varDeclTypes, false) + varDeclTypes := c.DesugaredElaboration.VariableDeclarationTypes(declaration) + c.compileVariableDeclaration(declaration, varDeclTypes, false) - // Declare the variable *after* compiling both the value expressions. - // Assign the first value onto the variable. - c.emitDeclareLocal(name) - }) + // Declare the variable *after* compiling both the value expressions. + // Assign the first value onto the variable. + c.emitDeclareLocal(name) return } @@ -4711,6 +4712,12 @@ func (c *Compiler[_, _]) declareParameters(paramList *ast.ParameterList, declare } } +// compilePotentiallyInheritedCode runs the given compilation function +// with the elaboration of the program which declared the given statement: +// if the statement is inherited (e.g. an inherited condition +// or before-statement of a condition), +// the inherited code's elaboration is used instead of the current elaboration. +// Called for every statement, in compileStatement. func (c *Compiler[_, _]) compilePotentiallyInheritedCode(statement ast.Statement, f func()) { stmtElaboration, ok := c.DesugaredElaboration.inheritedCodeElaborations[statement] if ok { diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 221eee5a92..3ef3a294f9 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -14235,3 +14235,117 @@ func TestCompileUnreachableStatementAfterExhaustiveIf(t *testing.T) { prettyInstructions(functions[0].Code, program), ) } + +// TestCompileInheritedStatementEndingControlFlow tests that the defensive +// unreachable instruction is also emitted after an *inherited* statement +// which the checker determined to end control flow. +// The statement (here: a before-statement of an inherited post-condition) +// is declared in another program, so the compiler must consult +// the declaring program's elaboration, not the current elaboration +// (see compilePotentiallyInheritedCode). +// +// An inherited statement which ends control flow cannot currently be produced +// from source code, so simulate a checker bug by marking the interface's +// before-statement, which completes normally. +func TestCompileInheritedStatementEndingControlFlow(t *testing.T) { + + t.Parallel() + + programs := CompiledPrograms{} + + contractsAddress := common.MustBytesToAddress([]byte{0x1}) + aLocation := common.NewAddressLocation(nil, contractsAddress, "A") + bLocation := common.NewAddressLocation(nil, contractsAddress, "B") + + // Deploy interface contract + + aContract := ` + contract A { + struct interface SI { + fun test(x: Int): Int { + post { before(x) < x } + } + } + } + ` + + // Only need to compile + ParseCheckAndCompileCodeWithOptions( + t, + aContract, + aLocation, + ParseCheckAndCompileOptions{ + CheckerHandler: func(checker *sema.Checker) { + // Mark the before-statement of the post-condition + // (`let $before_0 = x`) as ending control flow + contractDeclaration := checker.Program.CompositeDeclarations()[0] + interfaceDeclaration := contractDeclaration.Members.Interfaces()[0] + functionDeclaration := interfaceDeclaration.Members.Functions()[0] + postConditions := functionDeclaration.FunctionBlock.PostConditions + rewrite := checker.Elaboration.PostConditionsRewrite(postConditions) + beforeStatement := rewrite.BeforeStatements[0] + checker.Elaboration.SetStatementEndsControlFlow(beforeStatement) + }, + }, + programs, + ) + + // Deploy contract with the implementation, + // which inherits the post-condition and its before-statement + + bContract := fmt.Sprintf( + ` + import A from %s + + contract B { + struct Test: A.SI { + fun test(x: Int): Int { + return 42 + } + } + } + `, + contractsAddress.HexWithPrefix(), + ) + + program := ParseCheckAndCompile(t, bContract, bLocation, programs) + + const testFunctionQualifiedName = "B.Test.test" + var testFunction *bbq.Function[opcode.Instruction] + for i, function := range program.Functions { + if function.QualifiedName == testFunctionQualifiedName { + testFunction = &program.Functions[i] + break + } + } + require.NotNil(t, testFunction, "missing function %s", testFunctionQualifiedName) + + // local var indexes + const ( + // Since the function is an object-method, receiver becomes the first parameter. + xIndex = iota + 1 + beforeVarIndex + ) + + instructions := prettyInstructions(testFunction.Code, program) + require.GreaterOrEqual(t, len(instructions), 5) + + assert.Equal(t, + []opcode.PrettyInstruction{ + // Inherited before-statement: + // var $before_0 = x + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionGetLocal{Local: xIndex}, + opcode.PrettyInstructionTransferAndConvert{ + ValueType: interpreter.PrimitiveStaticTypeInt, + TargetType: interpreter.PrimitiveStaticTypeInt, + }, + opcode.PrettyInstructionSetLocal{Local: beforeVarIndex}, + + // Defensive unreachable after the inherited before-statement, + // which was (artificially) marked as ending control flow + opcode.PrettyInstructionUnreachable{}, + }, + instructions[:5], + ) +} diff --git a/bbq/test_utils/utils.go b/bbq/test_utils/utils.go index 466e3a52d0..1ebd3831f4 100644 --- a/bbq/test_utils/utils.go +++ b/bbq/test_utils/utils.go @@ -80,6 +80,10 @@ type ParseCheckAndCompileOptions struct { ParseAndCheckOptions *ParseAndCheckOptions CompilerConfig *compiler.Config CheckerErrorHandler func(error) + // CheckerHandler is called with the checker after checking, + // before the program is compiled. + // It can be used to e.g. manipulate the elaboration for tests. + CheckerHandler func(*sema.Checker) } func ParseCheckAndCompile( @@ -112,6 +116,11 @@ func ParseCheckAndCompileCodeWithOptions( options.CheckerErrorHandler, programs, ) + + if options.CheckerHandler != nil { + options.CheckerHandler(checker) + } + programs[location] = &CompiledProgram{ DesugaredElaboration: compiler.NewDesugaredElaboration(checker.Elaboration), } diff --git a/interpreter/unreachable_test.go b/interpreter/unreachable_test.go new file mode 100644 index 0000000000..4bba4d7e3a --- /dev/null +++ b/interpreter/unreachable_test.go @@ -0,0 +1,235 @@ +/* + * Cadence - The resource-oriented smart contract programming language + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package interpreter_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/onflow/cadence/ast" + "github.com/onflow/cadence/bbq" + "github.com/onflow/cadence/bbq/compiler" + . "github.com/onflow/cadence/bbq/test_utils" + compilerUtils "github.com/onflow/cadence/bbq/vm/test" + "github.com/onflow/cadence/common" + "github.com/onflow/cadence/interpreter" + "github.com/onflow/cadence/sema" + . "github.com/onflow/cadence/test_utils/sema_utils" +) + +// TestInterpretStatementEndingControlFlowFallthrough tests the defensive check +// for statements which the checker determined to end control flow: +// if execution nevertheless continues past such a statement, +// execution must abort, instead of silently continuing. +// In the interpreter, the check is performed when visiting statements; +// in the compiler/VM, an unreachable instruction is emitted after the statement. +// +// The check can only fire when the checker's control-flow analysis over-claims +// (a checker bug), which cannot be produced from source code here. +// Therefore, simulate such a bug by marking a statement which completes normally. +func TestInterpretStatementEndingControlFlowFallthrough(t *testing.T) { + + t.Parallel() + + inter, err := parseCheckAndPrepareWithOptions(t, + ` + fun test() { + let x = 1 + } + `, + ParseCheckAndInterpretOptions{ + HandleChecker: func(checker *sema.Checker) { + // Simulate a checker bug: mark the variable declaration statement + // as ending control flow, even though execution continues past it + statement := checker.Program.FunctionDeclarations()[0].FunctionBlock.Block.Statements[0] + checker.Elaboration.SetStatementEndsControlFlow(statement) + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("test") + + var unreachableInstructionErr *interpreter.UnreachableInstructionError + require.ErrorAs(t, err, &unreachableInstructionErr) +} + +// TestInterpretInheritedStatementEndingControlFlowFallthrough tests +// the same defensive check as TestInterpretStatementEndingControlFlowFallthrough, +// but for an *inherited* statement: +// the before-statement of an inherited post-condition, +// which is declared in another program. +// The check must consult the elaboration of the declaring program, +// not the elaboration of the inheriting program: +// the interpreter executes the statement with the declaring program's interpreter, +// and the compiler resolves the declaring program's elaboration +// (see compilePotentiallyInheritedCode). +// +// An inherited statement which ends control flow cannot currently be produced +// from source code, so simulate a checker bug by marking the interface's +// before-statement, which completes normally. +func TestInterpretInheritedStatementEndingControlFlowFallthrough(t *testing.T) { + + t.Parallel() + + importLocation := common.NewAddressLocation( + nil, + common.MustBytesToAddress([]byte{0x1}), + "", + ) + + const importCode = ` + struct interface SI { + + fun test(x: Int): Int { + post { + before(x) < x + } + } + } + ` + + const testCode = ` + import SI from 0x1 + + struct S: SI { + + fun test(x: Int): Int { + return 42 + } + } + + fun main(): Int { + return S().test(x: 1) + } + ` + + // Simulate a checker bug: mark the before-statement + // of the interface function's post-condition (`var $before_0 = x`) + // as ending control flow, even though execution continues past it + markBeforeStatement := func(checker *sema.Checker) { + interfaceDeclaration := checker.Program.InterfaceDeclarations()[0] + functionDeclaration := interfaceDeclaration.Members.Functions()[0] + postConditions := functionDeclaration.FunctionBlock.PostConditions + rewrite := checker.Elaboration.PostConditionsRewrite(postConditions) + beforeStatement := rewrite.BeforeStatements[0] + checker.Elaboration.SetStatementEndsControlFlow(beforeStatement) + } + + var err error + if *compile { + + programs := CompiledPrograms{} + + _ = ParseCheckAndCompileCodeWithOptions(t, + importCode, + importLocation, + ParseCheckAndCompileOptions{ + CheckerHandler: markBeforeStatement, + }, + programs, + ) + + _, err = compilerUtils.CompileAndInvokeWithOptionsAndPrograms(t, + testCode, + "main", + compilerUtils.CompilerAndVMOptions{ + ParseCheckAndCompileOptions: ParseCheckAndCompileOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + ImportHandler: func(checker *sema.Checker, importedLocation common.Location, _ ast.Range) (sema.Import, error) { + importedProgram, ok := programs[importedLocation] + if !ok { + return nil, fmt.Errorf("cannot find program for location %s", importedLocation) + } + + return sema.ElaborationImport{ + Elaboration: importedProgram.DesugaredElaboration.OriginalElaboration(), + }, nil + }, + }, + }, + CompilerConfig: &compiler.Config{ + ImportHandler: func(location common.Location) *bbq.InstructionProgram { + return programs[location].Program + }, + LocationHandler: func(identifiers []ast.Identifier, location common.Location) ([]sema.ResolvedLocation, error) { + return []sema.ResolvedLocation{ + { + Location: location, + Identifiers: identifiers, + }, + }, nil + }, + }, + }, + }, + programs, + ) + + } else { + + importedChecker, importErr := ParseAndCheckWithOptions(t, + importCode, + ParseAndCheckOptions{ + Location: importLocation, + }, + ) + require.NoError(t, importErr) + + markBeforeStatement(importedChecker) + + inter, prepareErr := parseCheckAndInterpretWithOptions(t, + testCode, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + ImportHandler: func(checker *sema.Checker, importedLocation common.Location, _ ast.Range) (sema.Import, error) { + return sema.ElaborationImport{ + Elaboration: importedChecker.Elaboration, + }, nil + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + ImportLocationHandler: func(inter *interpreter.Interpreter, location common.Location) interpreter.Import { + + program := interpreter.ProgramFromChecker(importedChecker) + subInterpreter, err := inter.NewSubInterpreter(program, location) + if err != nil { + panic(err) + } + + return interpreter.InterpreterImport{ + Interpreter: subInterpreter, + } + }, + }, + }, + ) + require.NoError(t, prepareErr) + + _, err = inter.Invoke("main") + } + + var unreachableInstructionErr *interpreter.UnreachableInstructionError + require.ErrorAs(t, err, &unreachableInstructionErr) +} diff --git a/test_utils/test_utils.go b/test_utils/test_utils.go index 7c0f0bec5d..7f23231c67 100644 --- a/test_utils/test_utils.go +++ b/test_utils/test_utils.go @@ -48,6 +48,10 @@ type ParseCheckAndInterpretOptions struct { ParseAndCheckOptions *ParseAndCheckOptions InterpreterConfig *interpreter.Config HandleCheckerError func(error) + // HandleChecker is called with the checker after checking, + // before the program is interpreted or compiled. + // It can be used to e.g. manipulate the elaboration for tests. + HandleChecker func(*sema.Checker) } func ParseCheckAndPrepare(tb testing.TB, code string, compile bool) Invokable { @@ -493,6 +497,7 @@ func ParseCheckAndPrepareWithOptions( ParseAndCheckOptions: options.ParseAndCheckOptions, CompilerConfig: compilerConfig, CheckerErrorHandler: options.HandleCheckerError, + CheckerHandler: options.HandleChecker, }, Programs: programs, }, @@ -794,6 +799,10 @@ func parseCheckAndInterpretWithOptionsAndAtreeValidations( return nil, err } + if options.HandleChecker != nil { + options.HandleChecker(checker) + } + var uuid uint64 = 0 var config interpreter.Config From 25a3f5fa80af080d0c425a50fbafa601f04a4959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 15:35:51 -0700 Subject: [PATCH 120/139] clarify conservative behavior of entitlement set intersection and add tests --- sema/access.go | 52 +++++++++++++++++++----------- sema/access_test.go | 66 +++++++++++++++++++++++++++++++++++++-- sema/entitlements_test.go | 22 +++++++++---- 3 files changed, 113 insertions(+), 27 deletions(-) diff --git a/sema/access.go b/sema/access.go index 8db8f5ddb1..d25147e655 100644 --- a/sema/access.go +++ b/sema/access.go @@ -88,28 +88,42 @@ func NewAccessFromEntitlementOrderedSet( } // IntersectAccess returns the intersection of two accesses. -// The result only contains entitlements that are statically guaranteed by both sides. +// The result is deliberately conservative: +// a disjunction is only ever preserved when the other side +// guarantees every one of its options, +// and the result is unauthorized in all other cases involving disjunctions. // // Rules: // - If either side is not an EntitlementSetAccess (e.g. unauthorized, primitive, // or entitlement map access), the result is unauthorized. // - Conjunction ∩ Conjunction: standard set intersection (Conjunction). -// Both sides guarantee all of their entitlements, so anything in the -// intersection is guaranteed. +// Both sides guarantee all of their entitlements, +// so anything in the intersection is guaranteed. // - Conjunction ∩ Disjunction (and vice versa): -// A conjunction guarantees all of its entitlements, while a disjunction -// only guarantees that at least one (unspecified) entitlement from its set -// is present. The disjunction can therefore be preserved as the result only -// when the conjunction is a superset of the disjunction — in that case the -// conjunction guarantees every option of the disjunction. Otherwise the -// result is unauthorized, because the entitlement actually held by the -// disjunction side might not be guaranteed by the conjunction side. -// (E.g. auth(A) ∩ auth(A | B | C) = unauthorized, because the disjunction -// side might hold B or C, neither of which is guaranteed by the conjunction -// side; but auth(A, B, C) ∩ auth(A | B | C) = auth(A | B | C), because the -// conjunction side guarantees all options of the disjunction.) -// - Disjunction ∩ Disjunction: unauthorized. Neither side guarantees any -// specific entitlement, so nothing can be statically guaranteed in common. +// A conjunction guarantees all of its entitlements, +// while a disjunction only guarantees that at least one (unspecified) entitlement +// from its set is present. +// The disjunction is preserved as the result only when the conjunction +// is a superset of the disjunction — +// in that case the conjunction guarantees every option of the disjunction. +// Otherwise the result is unauthorized. +// (E.g. auth(A) ∩ auth(A | B | C) = unauthorized, +// but auth(A, B, C) ∩ auth(A | B | C) = auth(A | B | C).) +// - Disjunction ∩ Disjunction: unauthorized, +// even when the option sets are identical. +// +// NOTE: These rules are intentionally stricter than the entailment join +// (least upper bound) of the two accesses, which leastCommonAccess computes. +// For example, for auth(E | F) ∩ auth(E | F), +// every possible holder of either side entails auth(E | F), +// so preserving the disjunction would be sound; +// and for auth(A) ∩ auth(A | B | C), +// a holder of A entails auth(A | B | C), +// so auth(A | B | C) would be a sound result. +// IntersectAccess nevertheless returns unauthorized in these cases: +// whenever the two sides may hold different actual entitlements, +// expressiveness is traded for simpler reasoning +// about authorization escalation through nested references. func IntersectAccess(a, b Access) Access { aSet, ok := a.(EntitlementSetAccess) if !ok { @@ -145,8 +159,10 @@ func IntersectAccess(a, b Access) Access { return UnauthorizedAccess default: - // Disjunction ∩ Disjunction: neither side guarantees any specific - // entitlement, so the result cannot guarantee any entitlement either. + // Disjunction ∩ Disjunction: conservatively unauthorized, + // even when the option sets are identical + // (in which case preserving the disjunction would be sound — + // see the NOTE in the function documentation). return UnauthorizedAccess } } diff --git a/sema/access_test.go b/sema/access_test.go index 76b4151a92..ec1f5a168a 100644 --- a/sema/access_test.go +++ b/sema/access_test.go @@ -677,8 +677,11 @@ func TestIntersectAccess(t *testing.T) { t.Run("disjunction, identical", func(t *testing.T) { t.Parallel() - // Two disjunctions: neither guarantees any specific entitlement, - // so nothing can be statically guaranteed in common. + // Conservatively unauthorized: + // preserving the identical disjunction would be sound + // (every possible holder of either side entails it), + // but IntersectAccess deliberately rejects + // all disjunction ∩ disjunction combinations. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), disjunctionAccess(MutateType, InsertType), @@ -689,7 +692,9 @@ func TestIntersectAccess(t *testing.T) { t.Run("disjunction, partial overlap", func(t *testing.T) { t.Parallel() - // Both sides are disjunctions, so nothing is guaranteed in common. + // Neither side guarantees Insert, + // so the former result, the disjunction of the set intersection, + // i.e. auth(Insert), was unsound. result := IntersectAccess( disjunctionAccess(MutateType, InsertType), disjunctionAccess(InsertType, RemoveType), @@ -789,6 +794,10 @@ func TestIntersectAccess(t *testing.T) { // Conjunction guarantees only {Insert}, but the disjunction may hold // Mutate or Remove instead, which the conjunction does not guarantee. + // Conservatively unauthorized: + // preserving the disjunction would be sound + // (a holder of Insert entails auth(Mutate | Insert | Remove)), + // but the conjunction is not a superset of the disjunction. result := IntersectAccess( conjunctionAccess(InsertType), disjunctionAccess(MutateType, InsertType, RemoveType), @@ -803,6 +812,57 @@ func TestIntersectAccess(t *testing.T) { result := IntersectAccess(mapAccess, conjunctionAccess(MutateType)) assert.Equal(t, UnauthorizedAccess, result) }) + + t.Run("left unauthorized, disjunction right", func(t *testing.T) { + t.Parallel() + + result := IntersectAccess( + UnauthorizedAccess, + disjunctionAccess(MutateType, InsertType), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + + t.Run("right unauthorized, disjunction left", func(t *testing.T) { + t.Parallel() + + result := IntersectAccess( + disjunctionAccess(MutateType, InsertType), + UnauthorizedAccess, + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + + t.Run("entitlement map access, disjunction", func(t *testing.T) { + t.Parallel() + + mapAccess := NewEntitlementMapAccess(IdentityType) + result := IntersectAccess( + mapAccess, + disjunctionAccess(MutateType, InsertType), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + + t.Run("primitive access, conjunction", func(t *testing.T) { + t.Parallel() + + result := IntersectAccess( + PrimitiveAccess(ast.AccessAll), + conjunctionAccess(MutateType), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + + t.Run("primitive access, disjunction", func(t *testing.T) { + t.Parallel() + + result := IntersectAccess( + PrimitiveAccess(ast.AccessAll), + disjunctionAccess(MutateType, InsertType), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) } func TestEntitlementMapAccess_QualifiedKeyword(t *testing.T) { diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 3611847cf3..35df119fa1 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -8786,8 +8786,9 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { // Disjunction intersection: the inner disjunction is preserved only // when the outer conjunction guarantees all of the disjunction's options. - // Otherwise the intersection is unauthorized, because nothing about the - // disjunction's specific entitlements is statically guaranteed. + // Otherwise the intersection is conservatively unauthorized, + // even in cases where preserving a disjunction would be sound + // (see the NOTE on IntersectAccess). t.Run("array, conjunction outer not superset, disjunction inner, escalation prevented", func(t *testing.T) { t.Parallel() @@ -8841,12 +8842,16 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { ) }) - t.Run("array, disjunction outer, disjunction inner, escalation prevented", func(t *testing.T) { + t.Run("array, disjunction outer, disjunction inner, conservatively rejected", func(t *testing.T) { t.Parallel() - // auth(E | F) ∩ auth(E | F): both sides are disjunctions, so neither - // guarantees any specific entitlement. Result is unauthorized, even - // though the option sets are identical. + // auth(E | F) ∩ auth(E | F): both sides are disjunctions, + // so the result is unauthorized, even though the option sets are identical. + // This is conservative, not an escalation fix: + // preserving the disjunction would be sound here + // (every possible holder of either side entails auth(E | F)), + // but IntersectAccess deliberately rejects + // all disjunction ∩ disjunction combinations. _, err := ParseAndCheck(t, ` entitlement E entitlement F @@ -8931,6 +8936,11 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { // disjunction outer cannot be preserved as the result (it might // actually hold F, which the conjunction does not have). // Result is unauthorized. + // The previous behavior produced auth(E) here, + // which exceeds what the outer auth(E | F) reference grants — + // that is the escalation being prevented. + // (auth(E | F) would have been a sound result, + // but IntersectAccess is deliberately stricter.) _, err := ParseAndCheck(t, ` entitlement E entitlement F From b746cedc74631fb51cc4950dbe3df9f54cc90c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 16:04:40 -0700 Subject: [PATCH 121/139] fix misleading receiver-dereference comments on container extract methods, add cascading tests --- interpreter/misc_test.go | 42 ++++++++++++++++++ interpreter/value_array.go | 42 +++++++++++------- interpreter/value_dictionary.go | 28 +++++++----- sema/entitlements_test.go | 79 +++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 25 deletions(-) diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index 3cefe19083..81ff281a2e 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -13195,6 +13195,48 @@ func TestInterpretContainerMethodElementCascading(t *testing.T) { } `) }) + + // Nested inner references: the references live one level deeper than + // the element/value type itself ([[auth(E) &S]] and + // {String: [auth(E) &S]}). This exercises the recursive traversal of + // intersectReferenceAuthorizationsInType through container types, + // and the runtime conversion of a returned *container* + // (convert's array case, which rewrites the element references' + // authorizations) rather than of a directly returned reference. + + t.Run("array remove intersects nested inner auth, recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var a: [[auth(E) &S]] = [[&s as auth(E) &S]] + let ref = &a as auth(Mutate) &[[auth(E) &S]] + + let removed: [&S] = ref.remove(at: 0) + let strong = removed[0] as? auth(E) &S + return strong != nil + } + `) + }) + + t.Run("dictionary remove intersects nested inner auth, recovery prevented", func(t *testing.T) { + t.Parallel() + assertCannotRecoverAuth(t, ` + entitlement E + access(all) struct S {} + fun test(): Bool { + let s = S() + var d: {String: [auth(E) &S]} = {"a": [&s as auth(E) &S]} + let ref = &d as auth(Mutate) &{String: [auth(E) &S]} + + let removed: [&S] = ref.remove(key: "a")! + let strong = removed[0] as? auth(E) &S + return strong != nil + } + `) + }) }) } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index b7d92a5d4c..fe92e6cd23 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -1068,11 +1068,15 @@ func (v *ArrayValue) GetMethod( ), NativeArrayRemoveFunction, ). - // Receiver is kept as-is (not dereferenced) so the native - // function and any BBQ VM derivation can read accessedType - // from the un-dereferenced receiver and apply the inner- - // reference intersection in sema.ArrayRemoveFunctionType's - // return. + // Receiver is kept as-is (not dereferenced), + // matching the BBQ VM, where the same native function + // derives its type from the un-dereferenced receiver. + // The native function itself does not need the reference — + // it unwraps the receiver via arrayValueFromReceiver. + // The inner-reference intersection in + // sema.ArrayRemoveFunctionType's return type takes effect + // when the invocation result is converted to this bound + // function's return type (computed above from accessedType). WithDereferenceReceiver(false) case sema.ArrayTypeRemoveFirstFunctionName: @@ -1087,11 +1091,15 @@ func (v *ArrayValue) GetMethod( ), NativeArrayRemoveFirstFunction, ). - // Receiver is kept as-is (not dereferenced) so the native - // function and any BBQ VM derivation can read accessedType - // from the un-dereferenced receiver and apply the inner- - // reference intersection in - // sema.ArrayRemoveFirstFunctionType's return. + // Receiver is kept as-is (not dereferenced), + // matching the BBQ VM, where the same native function + // derives its type from the un-dereferenced receiver. + // The native function itself does not need the reference — + // it unwraps the receiver via arrayValueFromReceiver. + // The inner-reference intersection in + // sema.ArrayRemoveFirstFunctionType's return type takes effect + // when the invocation result is converted to this bound + // function's return type (computed above from accessedType). WithDereferenceReceiver(false) case sema.ArrayTypeRemoveLastFunctionName: @@ -1106,11 +1114,15 @@ func (v *ArrayValue) GetMethod( ), NativeArrayRemoveLastFunction, ). - // Receiver is kept as-is (not dereferenced) so the native - // function and any BBQ VM derivation can read accessedType - // from the un-dereferenced receiver and apply the inner- - // reference intersection in - // sema.ArrayRemoveLastFunctionType's return. + // Receiver is kept as-is (not dereferenced), + // matching the BBQ VM, where the same native function + // derives its type from the un-dereferenced receiver. + // The native function itself does not need the reference — + // it unwraps the receiver via arrayValueFromReceiver. + // The inner-reference intersection in + // sema.ArrayRemoveLastFunctionType's return type takes effect + // when the invocation result is converted to this bound + // function's return type (computed above from accessedType). WithDereferenceReceiver(false) case sema.ArrayTypeFirstIndexFunctionName: diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index cb41ada077..a98f4ddb70 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -969,11 +969,15 @@ func (v *DictionaryValue) GetMethod( ), NativeDictionaryRemoveFunction, ). - // Receiver is kept as-is (not dereferenced) so the native - // function and any BBQ VM derivation can read accessedType - // from the un-dereferenced receiver and apply the inner- - // reference intersection in sema.DictionaryRemoveFunctionType's - // return. + // Receiver is kept as-is (not dereferenced), + // matching the BBQ VM, where the same native function + // derives its type from the un-dereferenced receiver. + // The native function itself does not need the reference — + // it unwraps the receiver via dictionaryValueFromReceiver. + // The inner-reference intersection in + // sema.DictionaryRemoveFunctionType's return type takes effect + // when the invocation result is converted to this bound + // function's return type (computed above from accessedType). WithDereferenceReceiver(false) case sema.DictionaryTypeInsertFunctionName: @@ -988,11 +992,15 @@ func (v *DictionaryValue) GetMethod( ), NativeDictionaryInsertFunction, ). - // Receiver is kept as-is (not dereferenced) so the native - // function and any BBQ VM derivation can read accessedType - // from the un-dereferenced receiver and apply the inner- - // reference intersection in sema.DictionaryInsertFunctionType's - // return. + // Receiver is kept as-is (not dereferenced), + // matching the BBQ VM, where the same native function + // derives its type from the un-dereferenced receiver. + // The native function itself does not need the reference — + // it unwraps the receiver via dictionaryValueFromReceiver. + // The inner-reference intersection in + // sema.DictionaryInsertFunctionType's return type takes effect + // when the invocation result is converted to this bound + // function's return type (computed above from accessedType). WithDereferenceReceiver(false) case sema.DictionaryTypeContainsKeyFunctionName: diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index 35df119fa1..3213d566d4 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -9340,6 +9340,85 @@ func TestInvalidCheckEntitlementEscalation(t *testing.T) { assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) }) + // The cascading rule must also apply when the method is invoked + // via optional chaining on an optional reference: + // ShouldReturnReference/MaybeReferenceType unwrap the optional, + // so the outer reference's authorization caps the result's + // inner element references just like for a non-optional receiver. + t.Run("optional chaining preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let arr: [auth(Remove) &InnerVictim] + + init() { + self.iv = InnerVictim() + self.arr = [&self.iv as auth(Remove) &InnerVictim] + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &[auth(Remove) &InnerVictim] { + return &self.arr + } + } + + access(all) fun main() { + let ov = OuterVictim() + let optRef: auth(Insert) &[auth(Remove) &InnerVictim]? = ov.getInsertOnlyRef() + + optRef?.slice(from: 0, upTo: 1)![0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + + // The dictionary `values` field returns a copy of the values, + // like the copy methods do for arrays. + // The field-access cascading rule in visitMember (not the + // container-method resolvers) must intersect the inner element + // references with the outer reference's authorization, + // closing the same escalation shape through the field path. + t.Run("values field preserves disjoint inner auth", func(t *testing.T) { + t.Parallel() + + _, err := ParseAndCheck(t, ` + access(all) struct InnerVictim { + access(Remove) fun requiresRemove() {} + } + + access(all) struct OuterVictim { + access(self) let iv: InnerVictim + access(self) let dict: {String: auth(Remove) &InnerVictim} + + init() { + self.iv = InnerVictim() + self.dict = {"a": &self.iv as auth(Remove) &InnerVictim} + } + + access(all) fun getInsertOnlyRef(): auth(Insert) &{String: auth(Remove) &InnerVictim} { + return &self.dict + } + } + + access(all) fun main() { + let ov = OuterVictim() + let insertOnlyRef = ov.getInsertOnlyRef() + + insertOnlyRef.values[0].requiresRemove() + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidAccessError{}, errs[0]) + }) + } func TestCheckContainerMethodElementCascading(t *testing.T) { From 882305e7fd9b6c83fd4e74689534f368ca60b40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 16:12:31 -0700 Subject: [PATCH 122/139] add positive and primitive-access tests for attachment self authorization --- bbq/vm/test/vm_test.go | 64 ++++++++++++++++++ interpreter/attachments_test.go | 112 ++++++++++++++++++++++++++------ 2 files changed, 156 insertions(+), 20 deletions(-) diff --git a/bbq/vm/test/vm_test.go b/bbq/vm/test/vm_test.go index f7a820012c..9f431a5664 100644 --- a/bbq/vm/test/vm_test.go +++ b/bbq/vm/test/vm_test.go @@ -9802,6 +9802,70 @@ func TestAttachments(t *testing.T) { var forceCastTypeMismatchErr *interpreter.ForceCastTypeMismatchError require.ErrorAs(t, err, &forceCastTypeMismatchErr) }) + + t.Run("self auth retains function access", func(t *testing.T) { + t.Parallel() + + // Narrowing `self` to the function's access must not drop + // the function's own entitlements: + // inside an `access(X)` function, `self` must still be `auth(X) &A` + value, err := CompileAndInvoke(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun base() {} + } + + access(all) attachment A for S { + access(X) fun foo(): Bool { + return (self as? auth(X) &A) != nil + } + } + + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `, "test") + require.NoError(t, err) + + require.Equal(t, interpreter.TrueValue, value) + }) + + t.Run("self auth is unauthorized in non-entitled function", func(t *testing.T) { + t.Parallel() + + // Inside a function with primitive (non-entitlement) access, + // `self` must be fully unauthorized, + // even if the attachment was accessed via an entitled reference — + // otherwise a downcast inside the function could recover + // any entitlement of the accessed reference + value, err := CompileAndInvoke(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun base() {} + } + + access(all) attachment A for S { + access(all) fun foo(): Bool { + return (self as? auth(X) &A) == nil + } + } + + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `, "test") + require.NoError(t, err) + + require.Equal(t, interpreter.TrueValue, value) + }) } func TestDynamicMethodInvocationViaOptionalChaining(t *testing.T) { diff --git a/interpreter/attachments_test.go b/interpreter/attachments_test.go index 369ae7fba9..fa0aa8a973 100644 --- a/interpreter/attachments_test.go +++ b/interpreter/attachments_test.go @@ -2848,30 +2848,102 @@ func TestInterpretAttachmentBaseUnauthorizedInInit(t *testing.T) { func TestInterpretAttachmentSelfAuth(t *testing.T) { t.Parallel() - inter := parseCheckAndPrepare(t, ` - entitlement X - entitlement Y + t.Run("narrowed to function access", func(t *testing.T) { + t.Parallel() - struct S { - access(X) fun bar() {} - } + inter := parseCheckAndPrepare(t, ` + entitlement X + entitlement Y - access(all) attachment A for S { - access(X) fun foo() { - self as! auth(X, Y) &A + struct S { + access(X) fun bar() {} } - } - fun test() { - let s = attach A() to S() - let ref = &s as auth(X, Y) &S - ref[A]!.foo() - } - `) + access(all) attachment A for S { + access(X) fun foo() { + self as! auth(X, Y) &A + } + } - _, err := inter.Invoke("test") - RequireError(t, err) + fun test() { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + ref[A]!.foo() + } + `) + + _, err := inter.Invoke("test") + RequireError(t, err) + + var forceCastTypeMismatchErr *interpreter.ForceCastTypeMismatchError + require.ErrorAs(t, err, &forceCastTypeMismatchErr) + }) + + t.Run("retains function access", func(t *testing.T) { + t.Parallel() + + // Narrowing `self` to the function's access must not drop + // the function's own entitlements: + // inside an `access(X)` function, `self` must still be `auth(X) &A` + inter := parseCheckAndPrepare(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun bar() {} + } + + access(all) attachment A for S { + access(X) fun foo(): Bool { + return (self as? auth(X) &A) != nil + } + } - var forceCastTypeMismatchErr *interpreter.ForceCastTypeMismatchError - require.ErrorAs(t, err, &forceCastTypeMismatchErr) + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `) + + result, err := inter.Invoke("test") + require.NoError(t, err) + + assert.Equal(t, interpreter.TrueValue, result) + }) + + t.Run("unauthorized in non-entitled function", func(t *testing.T) { + t.Parallel() + + // Inside a function with primitive (non-entitlement) access, + // `self` must be fully unauthorized, + // even if the attachment was accessed via an entitled reference — + // otherwise a downcast inside the function could recover + // any entitlement of the accessed reference + inter := parseCheckAndPrepare(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun bar() {} + } + + access(all) attachment A for S { + access(all) fun foo(): Bool { + return (self as? auth(X) &A) == nil + } + } + + fun test(): Bool { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + return ref[A]!.foo() + } + `) + + result, err := inter.Invoke("test") + require.NoError(t, err) + + assert.Equal(t, interpreter.TrueValue, result) + }) } From 16ba9762ce051d3cf66f62414cb408798fd4f69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 16:20:31 -0700 Subject: [PATCH 123/139] fix stale comment on for-in type-confusion test, add reference-container test case --- interpreter/container_mutation_test.go | 51 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/interpreter/container_mutation_test.go b/interpreter/container_mutation_test.go index 31f71a0571..64257bb99b 100644 --- a/interpreter/container_mutation_test.go +++ b/interpreter/container_mutation_test.go @@ -1261,7 +1261,11 @@ func TestInterpretInnerContainerMutationWhileIteratingOuter(t *testing.T) { }) } -// VisitForStatement is missing a checkIndexedValue guard on the iterable. +// Iterating over a container in a for-in loop defensively checks the container, +// like explicit container indexing does: +// the container's actual (static) type must conform to the type expected by sema. +// A type-confused container is rejected with an IndexedTypeError +// before iteration begins. func TestInterpretForLoopFunctionElementTypeConfusion(t *testing.T) { t.Parallel() @@ -1324,6 +1328,51 @@ func TestInterpretForLoopFunctionElementTypeConfusion(t *testing.T) { var indexedTypeError *interpreter.IndexedTypeError require.ErrorAs(t, err, &indexedTypeError) }) + + t.Run("reference to array", func(t *testing.T) { + + t.Parallel() + + inter := parseCheckAndPrepare(t, ` + fun test(arr: &[fun(): Void]) { + for f in arr { + f() + } + } + `) + + // Sema sees &[fun(): Void], but the referenced array actually holds an IntValue. + // The static type of a reference is derived from the referenced value, + // so the defensive check must reject the reference. + confusedArray := interpreter.NewArrayValue( + inter, + &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + }, + common.ZeroAddress, + interpreter.NewUnmeteredIntValueFromInt64(42), + ) + + arrayReference := interpreter.NewUnmeteredEphemeralReferenceValue( + noopReferenceTracker{}, + interpreter.UnauthorizedAccess, + confusedArray, + sema.NewVariableSizedType( + nil, + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + nil, + sema.VoidTypeAnnotation, + ), + ), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", arrayReference) //nolint:staticcheck + RequireError(t, err) + + var indexedTypeError *interpreter.IndexedTypeError + require.ErrorAs(t, err, &indexedTypeError) + }) } func TestInterpretIndexExpressionFunctionElementTypeConfusion(t *testing.T) { From f2fe57b052ea3a0e23e4f7cdee2fd9d7c8b8d84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 17:27:51 -0700 Subject: [PATCH 124/139] scope jump offsets by jump target, so `continue` survives switch boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jump offsets were kept in a single set, scoped per switch case. That is correct for `break`, which targets the switch, but wrong for `continue`, which targets the enclosing loop: its offset was discarded when the case's child set was popped. As a result, an invalidation after the switch (e.g. `destroy r`) was recorded as definite instead of potential, and the resource loss on the `continue` path went unreported: while true { let r <- create R() switch x { case 1: continue // r leaks here — previously not reported } destroy r } Split the offsets into two sets with different lifetimes: - LoopJumpOffsets (`continue`, loop-targeting `break`), scoped by WithLoop - SwitchJumpOffsets (switch-targeting `break`), scoped by WithSwitch, now for the whole switch instead of per case — sibling-case isolation is already provided by ReturnInfo.Clone in checkConditionalBranches --- sema/check_switch.go | 11 +--- sema/check_while.go | 9 ++- sema/checker.go | 27 ++++++--- sema/function_activations.go | 12 +++- sema/return_info.go | 73 ++++++++++++++++++----- sema/switch_test.go | 112 +++++++++++++++++++++++++++++++++++ sema/while_test.go | 80 +++++++++++++++++++++++++ 7 files changed, 287 insertions(+), 37 deletions(-) diff --git a/sema/check_switch.go b/sema/check_switch.go index 5cfd1cedc6..53c091da15 100644 --- a/sema/check_switch.go +++ b/sema/check_switch.go @@ -100,8 +100,6 @@ func (checker *Checker) checkSwitchCasesStatements( return } - currentFunctionActivation := checker.functionActivations.Current() - // NOTE: always check blocks as if they're only *potentially* evaluated. // However, the default case's block must be checked directly as the "else", // because if a default case exists, the whole switch statement @@ -124,9 +122,7 @@ func (checker *Checker) checkSwitchCasesStatements( ) } - currentFunctionActivation.ReturnInfo.WithNewJumpTarget(func() { - checker.checkSwitchCaseStatements(switchCase) - }) + checker.checkSwitchCaseStatements(switchCase) return } @@ -139,10 +135,7 @@ func (checker *Checker) checkSwitchCasesStatements( _, _ = checker.checkConditionalBranches( func() Type { - - currentFunctionActivation.ReturnInfo.WithNewJumpTarget(func() { - checker.checkSwitchCaseStatements(switchCase) - }) + checker.checkSwitchCaseStatements(switchCase) // ignored return nil diff --git a/sema/check_while.go b/sema/check_while.go index 78d2a29525..15a1699b50 100644 --- a/sema/check_while.go +++ b/sema/check_while.go @@ -55,7 +55,10 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st innermost := functionActivation.InnermostControl() - functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) + // The jump offset is recorded in the set matching the break's target, + // because the two sets have different lifetimes: + // the loop set is scoped by `WithLoop`, the switch set by `WithSwitch` + // (see `LoopJumpOffsets` and `SwitchJumpOffsets`). switch innermost { case ControlKindNone: @@ -68,9 +71,11 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st return case ControlKindLoop: + functionActivation.ReturnInfo.AddLoopJumpOffset(statement.StartPos.Offset) functionActivation.ReturnInfo.MaybeJumpedLoop = true case ControlKindSwitch: + functionActivation.ReturnInfo.AddSwitchJumpOffset(statement.StartPos.Offset) functionActivation.ReturnInfo.MaybeJumpedSwitch = true } @@ -118,7 +123,7 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) return } - functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) + functionActivation.ReturnInfo.AddLoopJumpOffset(statement.StartPos.Offset) functionActivation.ReturnInfo.MaybeJumpedLoop = true // `continue` is a kind of definite exit (see `DefinitelyExited`). // Setting it means a subsequent statement is correctly reported as diff --git a/sema/checker.go b/sema/checker.go index fd844c3eef..2eaf97e514 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1526,6 +1526,12 @@ func (checker *Checker) checkResourceLoss(depth int) { variable.DeclarationKind != common.DeclarationKindSelf && !checker.resources.Get(Resource{Variable: variable}).DefinitivelyInvalidated() { + // NOTE: a variable may be reported as lost more than once, + // e.g. both by the return-site check and by a scope-end check. + // This over-reporting is intentional: each "exit" of the scope + // performs and reports its own loss check, + // which ensures that resource loss is detected at all exits. + checker.report( &ResourceLossError{ Range: ast.NewRange( @@ -2745,14 +2751,21 @@ func (checker *Checker) maybeAddResourceInvalidation(resource Resource, invalida declarationOffset := resource.Variable.Pos.Offset invalidationOffset := invalidation.StartPos.Offset - err := returnInfo.JumpOffsets.ForEach(func(jumpOffset int) error { - if declarationOffset < jumpOffset && jumpOffset < invalidationOffset { - return errFoundJump - } - return nil - }) + checkJumpOffsets := func(jumpOffsets *persistent.OrderedSet[int]) bool { + err := jumpOffsets.ForEach(func(jumpOffset int) error { + if declarationOffset < jumpOffset && jumpOffset < invalidationOffset { + return errFoundJump + } + return nil + }) + return err == errFoundJump + } - onlyPotential = err == errFoundJump + // Both loop-targeting and switch-targeting jumps make the + // invalidation only potential — the jump skips the invalidation. + // The sets only differ in lifetime (see `LoopJumpOffsets`). + onlyPotential = checkJumpOffsets(returnInfo.LoopJumpOffsets) || + checkJumpOffsets(returnInfo.SwitchJumpOffsets) } if onlyPotential { diff --git a/sema/function_activations.go b/sema/function_activations.go index cb3f8a3545..fb0f9a2ffa 100644 --- a/sema/function_activations.go +++ b/sema/function_activations.go @@ -69,7 +69,7 @@ func (a *FunctionActivation) popControl() { func (a *FunctionActivation) WithLoop(f func()) { a.pushControl(ControlKindLoop) savedMaybeJumpedLoop := a.ReturnInfo.MaybeJumpedLoop - a.ReturnInfo.WithNewJumpTarget(f) + a.ReturnInfo.WithNewLoopJumpTarget(f) if a.ReturnInfo.MaybeJumpedLoop { a.ReturnInfo.clearDefiniteExits() } @@ -85,10 +85,16 @@ func (a *FunctionActivation) WithLoop(f func()) { // function, so the merged "every case definitely terminated" claim // would over-promise. func (a *FunctionActivation) WithSwitch(f func()) { - // NOTE: new jump-offsets child-set for each case instead of whole switch + // The switch jump-offset set is scoped to the whole switch: + // breaks targeting the switch are consumed by it. + // Isolation between sibling cases is provided by `ReturnInfo.Clone` + // in `checkConditionalBranches` (each case is a branch with its own + // cloned jump-offset sets). Loop jump offsets (`continue`) are NOT + // scoped here — they target the enclosing loop and must survive + // past the switch (see `WithNewSwitchJumpTarget`). a.pushControl(ControlKindSwitch) savedMaybeJumpedSwitch := a.ReturnInfo.MaybeJumpedSwitch - f() + a.ReturnInfo.WithNewSwitchJumpTarget(f) if a.ReturnInfo.MaybeJumpedSwitch { a.ReturnInfo.clearDefiniteExits() } diff --git a/sema/return_info.go b/sema/return_info.go index 4ec3b53955..2325b538a5 100644 --- a/sema/return_info.go +++ b/sema/return_info.go @@ -26,12 +26,25 @@ import ( // ReturnInfo tracks control-flow information type ReturnInfo struct { - // JumpOffsets contains the offsets of all jumps - // (break or continue statements), potential or definite. + // LoopJumpOffsets contains the offsets of all jumps targeting a loop + // (`continue` statements, and `break` statements whose innermost + // enclosing control construct is a loop), potential or definite. // - // If non-empty, indicates that (the branch of) the function - // contains a potential break or continue statement - JumpOffsets *persistent.OrderedSet[int] + // Kept separate from SwitchJumpOffsets because the two kinds of jumps + // have different lifetimes: a loop-targeting jump is consumed by the + // loop (`WithLoop` scopes this set), while a switch-targeting `break` + // is consumed by the switch (`WithSwitch` scopes SwitchJumpOffsets). + // In particular, a `continue` inside a switch case targets the + // enclosing loop, so its offset must survive past the switch boundary + // up to the loop — it must not be discarded when the switch ends. + LoopJumpOffsets *persistent.OrderedSet[int] + // SwitchJumpOffsets contains the offsets of all `break` statements + // targeting a switch, potential or definite. + // + // Scoped to the switch by `WithSwitch`: once the switch has been + // checked, breaks targeting it are consumed and are irrelevant + // to the code that follows. + SwitchJumpOffsets *persistent.OrderedSet[int] // MaybeReturned indicates that (the branch of) the function // contains a potentially taken return statement MaybeReturned bool @@ -125,7 +138,8 @@ type ReturnInfo struct { func NewReturnInfo() *ReturnInfo { return &ReturnInfo{ - JumpOffsets: persistent.NewOrderedSet[int](nil), + LoopJumpOffsets: persistent.NewOrderedSet[int](nil), + SwitchJumpOffsets: persistent.NewOrderedSet[int](nil), } } @@ -155,7 +169,7 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * elseReturnInfo.DefinitelyExited) // Propagate jump offsets from both branches. Each branch's - // `Clone()` gave it a separate child JumpOffsets set, so jumps in + // `Clone()` gave it separate child jump-offset sets, so jumps in // one branch did not pollute the sibling's view of "did a jump // occur between this resource's declaration and its use?" // (see `maybeAddResourceInvalidation`). Now that both branches @@ -166,8 +180,12 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * } func (ri *ReturnInfo) addJumpOffsetsFrom(other *ReturnInfo) { - _ = other.JumpOffsets.ForEach(func(offset int) error { - ri.JumpOffsets.Add(offset) + _ = other.LoopJumpOffsets.ForEach(func(offset int) error { + ri.LoopJumpOffsets.Add(offset) + return nil + }) + _ = other.SwitchJumpOffsets.ForEach(func(offset int) error { + ri.SwitchJumpOffsets.Add(offset) return nil }) } @@ -188,9 +206,10 @@ func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInf func (ri *ReturnInfo) Clone() *ReturnInfo { result := NewReturnInfo() *result = *ri - // Clone JumpOffsets so that jumps recorded in this clone + // Clone the jump-offset sets so that jumps recorded in this clone // do not leak into sibling clones (e.g. then vs. else branch). - result.JumpOffsets = ri.JumpOffsets.Clone() + result.LoopJumpOffsets = ri.LoopJumpOffsets.Clone() + result.SwitchJumpOffsets = ri.SwitchJumpOffsets.Clone() return result } @@ -224,12 +243,34 @@ func (ri *ReturnInfo) clearDefiniteExits() { ri.DefinitelyExited = false } -func (ri *ReturnInfo) AddJumpOffset(offset int) { - ri.JumpOffsets.Add(offset) +func (ri *ReturnInfo) AddLoopJumpOffset(offset int) { + ri.LoopJumpOffsets.Add(offset) +} + +func (ri *ReturnInfo) AddSwitchJumpOffset(offset int) { + ri.SwitchJumpOffsets.Add(offset) +} + +// WithNewLoopJumpTarget runs f with a fresh child set of loop jump +// offsets, and discards the child set afterwards: jumps targeting the +// loop are consumed by it and are irrelevant to the code that follows. +// Switch jump offsets are NOT scoped here — a switch-targeting `break` +// is always consumed by its switch, which lies entirely inside or +// outside the loop, so its offsets can never escape into the loop's set. +func (ri *ReturnInfo) WithNewLoopJumpTarget(f func()) { + ri.LoopJumpOffsets = ri.LoopJumpOffsets.Clone() + f() + ri.LoopJumpOffsets = ri.LoopJumpOffsets.Parent } -func (ri *ReturnInfo) WithNewJumpTarget(f func()) { - ri.JumpOffsets = ri.JumpOffsets.Clone() +// WithNewSwitchJumpTarget runs f with a fresh child set of switch jump +// offsets, and discards the child set afterwards: breaks targeting the +// switch are consumed by it and are irrelevant to the code that follows. +// Loop jump offsets are intentionally NOT scoped here — a `continue` +// inside a switch case targets the enclosing loop, so its offset must +// survive past the switch boundary up to the loop. +func (ri *ReturnInfo) WithNewSwitchJumpTarget(f func()) { + ri.SwitchJumpOffsets = ri.SwitchJumpOffsets.Clone() f() - ri.JumpOffsets = ri.JumpOffsets.Parent + ri.SwitchJumpOffsets = ri.SwitchJumpOffsets.Parent } diff --git a/sema/switch_test.go b/sema/switch_test.go index 953cab9277..95c48ad4e9 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -1303,4 +1303,116 @@ func TestCheckResourceInvalidationInSwitch(t *testing.T) { errs := RequireCheckerErrors(t, err, 1) assert.IsType(t, &sema.ResourceLossError{}, errs[0]) }) + + t.Run("continue in switch inside loop, destroy after switch", func(t *testing.T) { + t.Parallel() + + // `continue` targets the enclosing loop, not the switch, + // so it skips the `destroy r` after the switch and `r` leaks. + // The jump offset of the `continue` must survive past the + // case and switch boundaries for the `destroy r` to be treated + // as only a potential invalidation. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + case true: + continue + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue in if in switch inside loop, destroy after switch", func(t *testing.T) { + t.Parallel() + + // Like the previous test, but with the `continue` + // nested in an `if` inside the case. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + case true: + if true { + continue + } + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("continue in default case inside loop, destroy after switch", func(t *testing.T) { + t.Parallel() + + // The default case is checked directly (not as a conditional + // branch), so it exercises a different path than the case above. + // The `continue` is conditional — an unconditional `continue` + // in a default-only switch would (correctly) also make the + // `destroy r` unreachable. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + default: + if true { + continue + } + } + destroy r + } + } + `) + + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + }) + + t.Run("destroy then continue in switch inside loop", func(t *testing.T) { + t.Parallel() + + // The resource is destroyed before the `continue`, so no leak. + // The `continue`'s jump offset lies after the invalidation + // and must not downgrade it. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + switch true { + case true: + destroy r + continue + default: + destroy r + } + } + } + `) + + require.NoError(t, err) + }) } diff --git a/sema/while_test.go b/sema/while_test.go index 667b0b7e8a..0546d280e9 100644 --- a/sema/while_test.go +++ b/sema/while_test.go @@ -872,6 +872,86 @@ func TestCheckResourceInvalidationInWhileLoopWithIfElse(t *testing.T) { require.NoError(t, err) }) + + t.Run("if return else break", func(t *testing.T) { + t.Parallel() + + // `r` leaks on both paths, and each exit reports its own loss: + // the `return` reports at the return site, + // and the loop-body scope-end check reports again + // because of the `break` path. + // The over-reporting is intentional — it ensures that + // resource loss is detected at all exits. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + return + } else { + break + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) + }) + + t.Run("if break else return", func(t *testing.T) { + t.Parallel() + + // Like the previous test: one report from the `return`, + // one from the loop-body scope-end check (`break` path). + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + break + } else { + return + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) + }) + + t.Run("if return without else", func(t *testing.T) { + t.Parallel() + + // `r` leaks on the return path and on the fall-through path + // (end of the loop iteration). Each exit reports its own loss. + + _, err := ParseAndCheck(t, ` + resource R {} + + fun test() { + while true { + let r <- create R() + if true { + return + } + } + } + `) + + errs := RequireCheckerErrors(t, err, 2) + assert.IsType(t, &sema.ResourceLossError{}, errs[0]) + assert.IsType(t, &sema.ResourceLossError{}, errs[1]) + }) } func TestCheckResourceInvalidationInNestedWhileLoops(t *testing.T) { From 90cc860f468343d66965c4c7aceda68166e1e849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 19:28:29 -0700 Subject: [PATCH 125/139] update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 219d8a26e9..c05d98c39d 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.4" +const Version = "v1.10.4-rc.5" From 0914b07ee1a025750a2dfba46ebddaee683c3bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 19:30:22 -0700 Subject: [PATCH 126/139] match atree version --- tools/compatibility-check/go.mod | 2 +- tools/compatibility-check/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index cf7903c76b..1255e7505d 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -90,4 +90,4 @@ require ( replace github.com/onflow/cadence => ../../ -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index a138adc696..949a5cf4bf 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 h1:mXufEy0+qiXbomVTXnvYz0LiPaO0SuHjDDlgc8KD25g= -github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 h1:WdpfAE+AWfS8/t5vJejMi7A6I3Bq7/PaPEZDN7tEsFw= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 7fa937cd0cede2221274d319b8ea173331be0eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 19:28:29 -0700 Subject: [PATCH 127/139] update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 219d8a26e9..c05d98c39d 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.4" +const Version = "v1.10.4-rc.5" From 4bb08eb01448230d0585fccc0467d899f250e46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Wed, 10 Jun 2026 19:30:22 -0700 Subject: [PATCH 128/139] match atree version --- tools/compatibility-check/go.mod | 2 +- tools/compatibility-check/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index cf7903c76b..1255e7505d 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -90,4 +90,4 @@ require ( replace github.com/onflow/cadence => ../../ -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index a138adc696..949a5cf4bf 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1 h1:mXufEy0+qiXbomVTXnvYz0LiPaO0SuHjDDlgc8KD25g= -github.com/onflow/atree-internal v0.15.1-0.20260409135025-b51f9763b9a1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144 h1:WdpfAE+AWfS8/t5vJejMi7A6I3Bq7/PaPEZDN7tEsFw= +github.com/onflow/atree-internal v0.15.1-0.20260603181140-3c61ff247144/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 254286d2e22e4a72d1b906ecd3abc52bd7ae1a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 11 Jun 2026 09:18:07 -0700 Subject: [PATCH 129/139] improve comments --- bbq/vm/context.go | 10 ++++++++++ interpreter/value_array.go | 8 ++++---- interpreter/value_dictionary.go | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/bbq/vm/context.go b/bbq/vm/context.go index a2cf1603c8..869d5a226f 100644 --- a/bbq/vm/context.go +++ b/bbq/vm/context.go @@ -91,6 +91,16 @@ func (c *Context) newReusing() *Context { newContext.semaTypeCache = c.semaTypeCache newContext.linkedGlobalsCache = c.linkedGlobalsCache + // canonicalAtreeContainers is deliberately NOT carried over: + // carrying it across reuses would grow unboundedly over a VM's lifetime, + // and an empty cache is always safe — storage (via the shared Config) is unchanged, + // so entries simply re-canonicalize on first access. + // Wrappers surviving via linkedGlobalsCache (e.g. contract values) + // may therefore differ in identity from post-reset canonical wrappers. + // This is benign: + // structural consistency comes from atree's shared per-value-ID state, + // and field access re-canonicalizes through the new context. + return newContext } diff --git a/interpreter/value_array.go b/interpreter/value_array.go index a233194f22..959bbbfc2e 100644 --- a/interpreter/value_array.go +++ b/interpreter/value_array.go @@ -314,10 +314,10 @@ func (v *ArrayValue) iterate( // so it is safe to call regardless of which atree iterator (`Iterate` vs // `IterateReadOnlyLoadedValues`) produced the element. // - // For Transfer'd elements, the wrapper is transient - // (Transfer's `array`/`dictionary` nil-out would otherwise leave a stale cache entry - // that gets cleaned up lazily on next access), - // so skip the cache lookup entirely. + // For Transfer'd elements, the element passed to `f` is a decoupled copy + // (the transfer below uses remove=false), + // so the source-element wrapper is transient and caching it has no benefit — + // leave it as a transient fresh wrapper and skip the cache lookup entirely. var elementValue Value if transferElements { elementValue = MustConvertStoredValue(context, element) diff --git a/interpreter/value_dictionary.go b/interpreter/value_dictionary.go index 1e3c7580ad..679414954e 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -646,6 +646,18 @@ func (v *DictionaryValue) ForEachKey( }, ) + // Keys are intentionally NOT canonicalized + // (the read-only key iterator yields trap-bearing wrappers, + // which the canonical wrapper cache rejects anyway): + // dictionary keys must be hashable, + // and the only container-backed hashable type is enum + // (see sema.IsHashableStructType), + // which is immutable — + // so a non-canonical key wrapper cannot cause mutation divergence. + // Canonicalization is also not achievable by switching iterators: + // atree installs parent-updaters only on values, never on keys, + // even in mutable iterators, + // so the cache's safety filter would always reject key wrappers. key := MustConvertStoredValue(context, item) if asReference { @@ -2112,6 +2124,16 @@ func (i *DictionaryKeyIterator) Next(context ValueIteratorContext) Value { // (which wires a trap callback on returned elements), // canonicalizeContainerElement self-filters trap-bearing wrappers // and returns them as-is, so this is safe. + // Handing out non-canonical key wrappers here is also harmless: + // dictionary keys must be hashable, + // and the only container-backed hashable type is enum + // (see sema.IsHashableStructType), + // which is immutable — + // so a non-canonical key wrapper cannot cause mutation divergence. + // Canonicalization is also not achievable by switching iterators: + // atree installs parent-updaters only on values, never on keys, + // even in mutable iterators, + // so the cache's safety filter would always reject key wrappers. return MustConvertStoredContainerElement(context, atreeKeyValue) } From 7985617e5121a8562638869a306da5952a824c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 11 Jun 2026 09:18:55 -0700 Subject: [PATCH 130/139] avoid code duplication when reading enum's raw value field --- interpreter/value_composite.go | 44 +++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 6808bfc43d..40a134ece7 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -995,10 +995,16 @@ func formatComposite( return format.Composite(typeId, preparedFields) } -func (v *CompositeValue) GetField(context ContainerElementContext, name string) Value { +// getFieldWithoutCanonicalization reads the raw field value via atree, +// WITHOUT canonicalizing the resulting wrapper. +// Returns nil if the field does not exist. +// Callers that hand the field value to user code must canonicalize it +// (see GetField); only callers that cannot canonicalize for contract reasons +// and are known to read non-container values (see HashInput) may use the raw result. +func (v *CompositeValue) getFieldWithoutCanonicalization(gauge common.Gauge, name string) Value { common.UseComputation( - context, + gauge, common.ComputationUsage{ Kind: common.ComputationKindAtreeMapGet, Intensity: 1, @@ -1018,7 +1024,15 @@ func (v *CompositeValue) GetField(context ContainerElementContext, name string) panic(errors.NewExternalError(err)) } - return MustConvertStoredContainerElement(context, storedValue) + return MustConvertStoredValue(gauge, storedValue) +} + +func (v *CompositeValue) GetField(context ContainerElementContext, name string) Value { + fieldValue := v.getFieldWithoutCanonicalization(context, name) + if fieldValue == nil { + return nil + } + return canonicalizeContainerElement(context, fieldValue) } func (v *CompositeValue) Equal(context ValueComparisonContext, other Value) bool { @@ -1077,25 +1091,17 @@ func (v *CompositeValue) HashInput(gauge common.Gauge, scratch []byte) []byte { if v.Kind == common.CompositeKindEnum { typeID := v.TypeID() - // Read the enum's raw value directly via atree: - // `GetField` requires a `ContainerReadContext` (so it can canonicalize - // container-element wrappers via `MustConvertStoredContainerElement`), + // Read the enum's raw value WITHOUT canonicalization: + // `GetField` requires a `ContainerElementContext` + // (so it can canonicalize container-element wrappers), // but enum raw values are primitives, - // so canonicalization is a no-op and the wider context is unnecessary. + // so canonicalization would be a no-op and the wider context is unnecessary. // The `HashableValue.HashInput` contract only requires `common.Gauge`. - common.UseComputation(gauge, common.ComputationUsage{ - Kind: common.ComputationKindAtreeMapGet, - Intensity: 1, - }) - storedRawValue, err := v.dictionary.Get( - StringAtreeValueComparator, - StringAtreeValueHashInput, - StringAtreeValue(sema.EnumRawValueFieldName), - ) - if err != nil { - panic(errors.NewExternalError(err)) + rawValue := v.getFieldWithoutCanonicalization(gauge, sema.EnumRawValueFieldName) + if rawValue == nil { + // Enums always have a raw value field + panic(errors.NewUnreachableError()) } - rawValue := MustConvertStoredValue(gauge, storedRawValue) rawValueHashInput := rawValue.(HashableValue). HashInput(gauge, scratch) From 8b29b403bdd321d264baf3ae0cf3c9c1e7f91087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 12 Jun 2026 11:31:22 -0700 Subject: [PATCH 131/139] emit unreachable instruction after invocation of function with never return type --- bbq/compiler/compiler.go | 16 +++ bbq/compiler/compiler_test.go | 203 ++++++++++++++++++++++++++++++---- 2 files changed, 198 insertions(+), 21 deletions(-) diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index fdb826f83a..95fda9d7b8 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -2930,6 +2930,22 @@ func (c *Compiler[_, _]) emitInvocation( HasImplicitArgument: hasImplicitArgument, SkipArgumentConversion: invocationTypes.PassArgumentsWithoutTransferOrConvert, }) + + // Defensive: an invocation with return type Never must not return normally. + // If it nevertheless does (e.g. due to a mistyped native function, + // or a return type which disagrees with the invoked function's actual type), + // the VM panics with UnreachableInstructionError, + // instead of continuing with an impossible value. + // + // The VM also validates return values against the invocation's return type + // (see checkAndConvertReturnValue), which no value conforms to for Never. + // However, that validation is only performed for value returns: + // the void return path (InstructionReturn) pushes Void without validation, + // in which case this instruction is the check that catches + // the impossible normal return. + if invocationTypes.ReturnType == sema.NeverType { + c.emit(opcode.InstructionUnreachable{}) + } } func (c *Compiler[_, _]) addImplicitArgumentTyped( diff --git a/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 3ef3a294f9..ef35b185c0 100644 --- a/bbq/compiler/compiler_test.go +++ b/bbq/compiler/compiler_test.go @@ -4426,7 +4426,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -4448,6 +4448,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -4547,7 +4551,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4569,6 +4573,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -4662,7 +4670,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 23}, + opcode.PrettyInstructionJumpIfFalse{Target: 24}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4684,6 +4692,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -4810,7 +4822,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -4832,6 +4844,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -4854,7 +4870,7 @@ func TestCompileFunctionConditions(t *testing.T) { opcode.PrettyInstructionSetLocal{Local: tempResultIndex}, // jump to post conditions - opcode.PrettyInstructionJump{Target: 18}, + opcode.PrettyInstructionJump{Target: 19}, // let result $noTransfer $_result opcode.PrettyInstructionStatement{}, @@ -4878,7 +4894,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 33}, + opcode.PrettyInstructionJumpIfFalse{Target: 35}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4899,6 +4915,10 @@ func TestCompileFunctionConditions(t *testing.T) { }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -5055,7 +5075,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 25}, + opcode.PrettyInstructionJumpIfFalse{Target: 26}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -5077,6 +5097,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -5294,7 +5318,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 13}, + opcode.PrettyInstructionJumpIfFalse{Target: 14}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -5316,6 +5340,10 @@ func TestCompileFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -5448,7 +5476,7 @@ func TestCompileFunctionConditions(t *testing.T) { }, opcode.PrettyInstructionGreater{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 16}, + opcode.PrettyInstructionJumpIfFalse{Target: 17}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -5468,6 +5496,11 @@ func TestCompileFunctionConditions(t *testing.T) { interpreter.PrimitiveStaticTypeString, }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionDrop{}, // Function body @@ -5488,7 +5521,7 @@ func TestCompileFunctionConditions(t *testing.T) { opcode.PrettyInstructionSetLocal{Local: tempResultIndex}, // jump to post conditions - opcode.PrettyInstructionJump{Target: 22}, + opcode.PrettyInstructionJump{Target: 23}, // let result $noTransfer $_result opcode.PrettyInstructionStatement{}, @@ -5504,7 +5537,7 @@ func TestCompileFunctionConditions(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: xIndex}, opcode.PrettyInstructionLess{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 37}, + opcode.PrettyInstructionJumpIfFalse{Target: 39}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -5525,6 +5558,11 @@ func TestCompileFunctionConditions(t *testing.T) { }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionDrop{}, // return $_result @@ -6535,7 +6573,7 @@ func TestCompileTransaction(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 15}, + opcode.PrettyInstructionJumpIfFalse{Target: 16}, // $failPreCondition("pre failed") opcode.PrettyInstructionStatement{}, @@ -6557,6 +6595,10 @@ func TestCompileTransaction(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -6606,7 +6648,7 @@ func TestCompileTransaction(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 37}, + opcode.PrettyInstructionJumpIfFalse{Target: 39}, // $failPostCondition("post failed") opcode.PrettyInstructionStatement{}, @@ -6628,6 +6670,10 @@ func TestCompileTransaction(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -6877,7 +6923,7 @@ func TestCompileReturns(t *testing.T) { opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionTrue{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetGlobal{Global: 1}, @@ -6897,6 +6943,11 @@ func TestCompileReturns(t *testing.T) { }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionDrop{}, // return @@ -6973,7 +7024,7 @@ func TestCompileReturns(t *testing.T) { opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionTrue{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 23}, + opcode.PrettyInstructionJumpIfFalse{Target: 24}, opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetGlobal{Global: 1}, @@ -6993,6 +7044,11 @@ func TestCompileReturns(t *testing.T) { }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionDrop{}, // return $_result @@ -7045,7 +7101,7 @@ func TestCompileReturns(t *testing.T) { opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionTrue{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 15}, + opcode.PrettyInstructionJumpIfFalse{Target: 16}, opcode.PrettyInstructionStatement{}, opcode.PrettyInstructionGetGlobal{Global: 2}, @@ -7065,6 +7121,11 @@ func TestCompileReturns(t *testing.T) { }, ReturnType: interpreter.PrimitiveStaticTypeNever, }, + + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + opcode.PrettyInstructionDrop{}, // return $_result @@ -11615,7 +11676,7 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -11637,6 +11698,10 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -11765,7 +11830,7 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -11787,6 +11852,10 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -11871,7 +11940,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -11893,6 +11962,10 @@ func TestCompileInnerFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -12011,7 +12084,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -12033,6 +12106,10 @@ func TestCompileInnerFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPostCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -12122,7 +12199,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -12144,6 +12221,10 @@ func TestCompileInnerFunctionConditions(t *testing.T) { ReturnType: interpreter.PrimitiveStaticTypeNever, }, + // Defensive unreachable after the invocation of $failPreCondition, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + // Drop since it's a statement-expression opcode.PrettyInstructionDrop{}, @@ -14349,3 +14430,83 @@ func TestCompileInheritedStatementEndingControlFlow(t *testing.T) { instructions[:5], ) } + +// TestCompileNeverInvocation tests that a defensive unreachable instruction +// is emitted directly after the invocation of a function with return type Never, +// in addition to the unreachable instruction emitted after the statement +// which the checker determined to end control flow. +// The invocation-level instruction aborts execution as soon as +// a supposedly never-returning function returns (e.g. a mistyped native function), +// before the rest of the enclosing statement is executed. +func TestCompileNeverInvocation(t *testing.T) { + + t.Parallel() + + checker, err := ParseAndCheck(t, ` + fun f(): Never { + return f() + } + + fun test() { + f() + } + `) + require.NoError(t, err) + + comp := compiler.NewInstructionCompiler( + interpreter.ProgramFromChecker(checker), + checker.Location, + ) + program := comp.Compile() + + functions := program.Functions + require.Len(t, functions, 2) + + // fun f(): Never + assert.Equal(t, + []opcode.PrettyInstruction{ + // return f() + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionGetGlobal{Global: 0}, + opcode.PrettyInstructionInvoke{ + ReturnType: interpreter.PrimitiveStaticTypeNever, + }, + + // Defensive unreachable after the invocation of f, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + + opcode.PrettyInstructionTransferAndConvert{ + ValueType: interpreter.PrimitiveStaticTypeNever, + TargetType: interpreter.PrimitiveStaticTypeNever, + }, + opcode.PrettyInstructionReturnValue{}, + }, + prettyInstructions(functions[0].Code, program), + ) + + // fun test() + assert.Equal(t, + []opcode.PrettyInstruction{ + // f() + opcode.PrettyInstructionStatement{}, + opcode.PrettyInstructionGetGlobal{Global: 0}, + opcode.PrettyInstructionInvoke{ + ReturnType: interpreter.PrimitiveStaticTypeNever, + }, + + // Defensive unreachable after the invocation of f, + // which has return type Never + opcode.PrettyInstructionUnreachable{}, + + opcode.PrettyInstructionDrop{}, + + // Defensive unreachable after the expression statement, + // which the checker determined to end control flow + opcode.PrettyInstructionUnreachable{}, + + opcode.PrettyInstructionReturn{}, + }, + prettyInstructions(functions[1].Code, program), + ) +} From 903d292929fdd7bc3913c3ffba7557929a066667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 12 Jun 2026 11:35:19 -0700 Subject: [PATCH 132/139] validate void returns --- bbq/vm/vm.go | 22 ++++- interpreter/interpreter.go | 19 ++++ interpreter/interpreter_invocation.go | 1 + interpreter/interpreter_transaction.go | 1 + interpreter/unreachable_test.go | 122 +++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) diff --git a/bbq/vm/vm.go b/bbq/vm/vm.go index d3d1f16bd9..5c32b3164c 100644 --- a/bbq/vm/vm.go +++ b/bbq/vm/vm.go @@ -662,8 +662,26 @@ func checkAndConvertReturnValue( } func opReturn(vm *VM) { - vm.popCallFrame() - vm.push(interpreter.Void) + poppedCallFrame := vm.popCallFrame() + + value := interpreter.Void + + // Defensively check the expected return type allows returning void. + // In a correct program, the void return path is only ever taken + // in functions with return type Void. + // Without this check, a function with a non-Void return type + // which (incorrectly) returns void would push Void + // masquerading as a value of the return type – + // particularly dangerous for return type Never, + // which is assignable to any type. + if poppedCallFrame != nil { + expectedReturnType := poppedCallFrame.returnType + if expectedReturnType != interpreter.PrimitiveStaticTypeVoid { + value = checkAndConvertReturnValue(vm.context, value, expectedReturnType) + } + } + + vm.push(value) } func opJump(vm *VM, ins opcode.InstructionJump) { diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index ab1e5695f6..f5f4ee000a 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -940,6 +940,7 @@ func (interpreter *Interpreter) visitFunctionBody( body func() StatementResult, postConditions []ast.Condition, returnType sema.Type, + isConstructor bool, ) Value { // block scope: each function block gets an activation record @@ -960,6 +961,23 @@ func (interpreter *Interpreter) visitFunctionBody( if result, ok := result.(ReturnResult); ok { returnValue = result.Value } else { + // Implicit void return: + // the function body completed without an explicit return. + // + // Defensively check the return type allows returning void: + // in a correct program, only a function with return type Void + // completes without an explicit return. + // Initializers are exempt: the constructor function type + // has the composite type as the return type, + // but the return value is constructed by the caller. + // + // This mirrors the check in the VM's void return path (see opReturn). + if !isConstructor && returnType != sema.VoidType { + panic(&ValueTransferTypeError{ + ExpectedType: returnType, + ActualType: sema.VoidType, + }) + } returnValue = Void } } else { @@ -3127,6 +3145,7 @@ func (interpreter *Interpreter) functionConditionsWrapper( body, rewrittenPostConditions, functionType.ReturnTypeAnnotation.Type, + functionType.IsConstructor, ) }, ), diff --git a/interpreter/interpreter_invocation.go b/interpreter/interpreter_invocation.go index 43cbbfee56..c09001920f 100644 --- a/interpreter/interpreter_invocation.go +++ b/interpreter/interpreter_invocation.go @@ -317,6 +317,7 @@ func (interpreter *Interpreter) invokeInterpretedFunctionActivated( }, function.PostConditions, function.Type.ReturnTypeAnnotation.Type, + function.Type.IsConstructor, ) } diff --git a/interpreter/interpreter_transaction.go b/interpreter/interpreter_transaction.go index cd5752e411..40964120c1 100644 --- a/interpreter/interpreter_transaction.go +++ b/interpreter/interpreter_transaction.go @@ -141,6 +141,7 @@ func (interpreter *Interpreter) declareTransactionEntryPoint(declaration *ast.Tr body, postConditionsRewrite.RewrittenPostConditions, sema.VoidType, + false, ) }, } diff --git a/interpreter/unreachable_test.go b/interpreter/unreachable_test.go index 4bba4d7e3a..d4f6c94ffb 100644 --- a/interpreter/unreachable_test.go +++ b/interpreter/unreachable_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/onflow/cadence/activations" "github.com/onflow/cadence/ast" "github.com/onflow/cadence/bbq" "github.com/onflow/cadence/bbq/compiler" @@ -32,6 +33,7 @@ import ( "github.com/onflow/cadence/common" "github.com/onflow/cadence/interpreter" "github.com/onflow/cadence/sema" + "github.com/onflow/cadence/stdlib" . "github.com/onflow/cadence/test_utils/sema_utils" ) @@ -233,3 +235,123 @@ func TestInterpretInheritedStatementEndingControlFlowFallthrough(t *testing.T) { var unreachableInstructionErr *interpreter.UnreachableInstructionError require.ErrorAs(t, err, &unreachableInstructionErr) } + +// TestInterpretVoidReturnWithMismatchedReturnType tests the defensive handling +// of an invocation whose static return type disagrees with the invoked function, +// and the invoked function returns void: +// the invocation must abort with an internal error, +// instead of continuing with Void masquerading as a value of the return type. +// This is particularly important for return type Never, +// which is assignable to any type. +// +// Such a disagreement cannot be produced from source code, +// so simulate a checker bug by overriding the return type +// of the invocation of a void function in the elaboration. +// +// In the VM, the void return path (InstructionReturn) performs +// a defensive check that the call frame's expected return type allows void +// (see opReturn). +// Unlike the value return path (InstructionReturnValue) +// and native function invocations, which validate the returned value, +// the void return path would otherwise push Void without any validation. +// +// In the interpreter, the return-value validation catches the disagreement, +// when the invocation's result is validated against the invocation's return type +// (see ConvertAndBoxWithValidation, performed in invokeFunctionValueWithEval). +func TestInterpretVoidReturnWithMismatchedReturnType(t *testing.T) { + + t.Parallel() + + inter, err := parseCheckAndPrepareWithOptions(t, + ` + fun f() {} + + fun test() { + f() + } + `, + ParseCheckAndInterpretOptions{ + HandleChecker: func(checker *sema.Checker) { + // Simulate a checker bug: override the return type + // of the invocation `f()` to Never, + // even though the invoked function returns void + functionDeclaration := checker.Program.FunctionDeclarations()[1] + statement := functionDeclaration.FunctionBlock.Block.Statements[0] + expressionStatement := statement.(*ast.ExpressionStatement) + fInvocation := expressionStatement.Expression.(*ast.InvocationExpression) + + invocationTypes := checker.Elaboration.InvocationExpressionTypes(fInvocation) + invocationTypes.ReturnType = sema.NeverType + checker.Elaboration.SetInvocationExpressionTypes(fInvocation, invocationTypes) + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("test") + + var valueTransferTypeErr *interpreter.ValueTransferTypeError + require.ErrorAs(t, err, &valueTransferTypeErr) +} + +// TestInterpretImplicitVoidReturnWithMismatchedReturnType tests +// the defensive handling of a function which completes +// without an explicit return (an implicit void return), +// even though its return type is not Void: +// the invocation must abort with an internal error, +// instead of continuing with Void masquerading as a value of the return type. +// +// Such a function cannot be produced from source code: +// the checker requires a function with a return type other than Void +// to definitely return or halt. +// Therefore, simulate a checker bug by overriding the function type +// of a void function in the elaboration. +// +// The check is performed in visitFunctionBody, when the body completes +// without an explicit return (see the call in invokeInterpretedFunctionActivated). +// It protects all invocation paths, including internal direct invocations +// which skip the call-site return-value validation +// (see ConvertAndBoxWithValidation, performed in invokeFunctionValueWithEval). +// +// This test is interpreter-only: +// the equivalent check in the VM is the defensive check +// in the void return path (see opReturn), +// which TestInterpretVoidReturnWithMismatchedReturnType covers in compile mode. +func TestInterpretImplicitVoidReturnWithMismatchedReturnType(t *testing.T) { + + t.Parallel() + + inter, err := parseCheckAndInterpretWithOptions(t, + ` + fun f() {} + + fun test() { + f() + } + `, + ParseCheckAndInterpretOptions{ + HandleChecker: func(checker *sema.Checker) { + // Simulate a checker bug: override the function type of `f` + // to have return type Int, + // even though the function declaration has no return type (Void), + // so its body completes without an explicit return + functionDeclaration := checker.Program.FunctionDeclarations()[0] + functionType := sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + nil, + sema.IntTypeAnnotation, + ) + checker.Elaboration.SetFunctionDeclarationFunctionType( + functionDeclaration, + functionType, + ) + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("test") + + var valueTransferTypeErr *interpreter.ValueTransferTypeError + require.ErrorAs(t, err, &valueTransferTypeErr) +} From e2e67ddc53fb7594631922e281ea3ef4e0ccae44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 12 Jun 2026 11:37:27 -0700 Subject: [PATCH 133/139] validate invocations of functions with never return type --- bbq/compiler/compiler_metering_test.go | 2 +- interpreter/interpreter_expression.go | 15 +++++ interpreter/unreachable_test.go | 79 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/bbq/compiler/compiler_metering_test.go b/bbq/compiler/compiler_metering_test.go index 57a28708bc..976697c880 100644 --- a/bbq/compiler/compiler_metering_test.go +++ b/bbq/compiler/compiler_metering_test.go @@ -259,7 +259,7 @@ func TestCompilerMemoryMetering(t *testing.T) { common.MemoryKindCompilerLocal: 4, common.MemoryKindCompilerConstant: 1, common.MemoryKindCompilerFunction: 6, - common.MemoryKindCompilerInstruction: 24, + common.MemoryKindCompilerInstruction: 26, common.MemoryKindCompilerBBQProgram: 1, common.MemoryKindCompilerBBQConstant: 1, diff --git a/interpreter/interpreter_expression.go b/interpreter/interpreter_expression.go index e5f74b59d4..d1f5c4613f 100644 --- a/interpreter/interpreter_expression.go +++ b/interpreter/interpreter_expression.go @@ -1318,6 +1318,21 @@ func (interpreter *Interpreter) visitInvocationExpressionWithImplicitArgument( interpreter.reportInvokedFunctionReturn() + // Defensive: an invocation with return type Never must not return normally. + // If it nevertheless does (e.g. due to a mistyped native function, + // or a return type which disagrees with the invoked function's actual type), + // panic instead of continuing with an impossible value. + // + // This is a second line of defense: + // the return-value validation (see ConvertAndBoxWithValidation, + // performed in invokeFunctionValueWithEval) aborts execution first, + // as no value conforms to Never. + if returnType == sema.NeverType { + panic(&UnreachableInstructionError{ + Range: ast.NewRangeFromPositioned(interpreter, invocationExpression), + }) + } + // If this is invocation is optional chaining, wrap the result // as an optional, as the result is expected to be an optional if isOptionalChaining { diff --git a/interpreter/unreachable_test.go b/interpreter/unreachable_test.go index d4f6c94ffb..f0928c01c6 100644 --- a/interpreter/unreachable_test.go +++ b/interpreter/unreachable_test.go @@ -236,6 +236,85 @@ func TestInterpretInheritedStatementEndingControlFlowFallthrough(t *testing.T) { require.ErrorAs(t, err, &unreachableInstructionErr) } +// TestInterpretNeverInvocation tests the defensive handling of an invocation +// of a function with return type Never which nevertheless returns at runtime. +// +// A Never-returning function which returns cannot be produced from source code: +// the checker requires the body of a Never-returning function +// to definitely return or halt. +// Therefore, simulate such a bug with a mistyped native function, +// which is declared with return type Never, but returns a value. +// +// In both the interpreter and the VM, the return-value validation +// aborts execution with an internal error when the returned value +// is validated against the declared return type Never, +// which no value conforms to +// (see ConvertAndBoxWithValidation in the interpreter, +// and checkAndConvertReturnValue in the VM). +// The defensive Never checks performed after invocations +// (the check in visitInvocationExpressionWithImplicitArgument in the interpreter, +// and the unreachable instruction emitted after the invocation in the compiler/VM) +// are a second line of defense behind those validations. +func TestInterpretNeverInvocation(t *testing.T) { + + t.Parallel() + + // Simulate a mistyped native function: + // declared with return type Never, but returns a value + fFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "f", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + nil, + sema.NeverTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + _ []interpreter.Value, + ) interpreter.Value { + return interpreter.NewUnmeteredIntValueFromInt64(42) + }, + ) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(fFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, fFunction) + + inter, err := parseCheckAndPrepareWithOptions(t, + ` + fun test() { + f() + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *interpreter.VariableActivation { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("test") + + var valueTransferTypeErr *interpreter.ValueTransferTypeError + require.ErrorAs(t, err, &valueTransferTypeErr) +} + // TestInterpretVoidReturnWithMismatchedReturnType tests the defensive handling // of an invocation whose static return type disagrees with the invoked function, // and the invoked function returns void: From 7703ee27c4d669c778ccfe5d8d077c615b943af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 12 Jun 2026 13:50:10 -0700 Subject: [PATCH 134/139] update to internal atree v0.16.1-rc.2 --- go.mod | 2 +- go.sum | 4 ++-- tools/compatibility-check/go.mod | 2 +- tools/compatibility-check/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 23d10ace76..4b73eb8644 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.1 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.2 diff --git a/go.sum b/go.sum index d4935d91d0..ad0beb8e75 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.16.1-rc.1 h1:ytBR6w4I1iW26vYwRxHIFHPgsIirZ66LprBYvos033A= -github.com/onflow/atree-internal v0.16.1-rc.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.16.1-rc.2 h1:FxY+wBFuFKh3cAsN+B0mrdRBVljcE2n6L8FFKjJUMzA= +github.com/onflow/atree-internal v0.16.1-rc.2/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index 99efb9128e..68fd9867df 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -90,4 +90,4 @@ require ( replace github.com/onflow/cadence => ../../ -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.1 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.2 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index ebc3a18947..bf2145adcf 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree-internal v0.16.1-rc.1 h1:ytBR6w4I1iW26vYwRxHIFHPgsIirZ66LprBYvos033A= -github.com/onflow/atree-internal v0.16.1-rc.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.16.1-rc.2 h1:FxY+wBFuFKh3cAsN+B0mrdRBVljcE2n6L8FFKjJUMzA= +github.com/onflow/atree-internal v0.16.1-rc.2/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 653f24b075d3bc1c7876926f30c9aeacddafb591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 15 Jun 2026 13:28:38 -0700 Subject: [PATCH 135/139] pass invocation context to CreateAccount so a contract function can be invoked from there against the same storage --- cmd/cmd.go | 2 +- runtime/account_test.go | 2 +- runtime/authorizer.go | 7 +- runtime/contract_function_executor.go | 185 ++++++++++++++++----- runtime/contract_update_validation_test.go | 6 +- runtime/empty.go | 2 +- runtime/error_test.go | 2 +- runtime/external.go | 4 +- runtime/import_test.go | 3 +- runtime/interface.go | 9 +- runtime/runtime_test.go | 174 +++++++++++++++++-- runtime/transaction_executor.go | 1 - stdlib/account.go | 10 +- test_utils/runtime_utils/testinterface.go | 9 +- 14 files changed, 343 insertions(+), 73 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index 19ad6b7f7a..3d9b59c434 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -389,7 +389,7 @@ func (*StandardLibraryHandler) RecordContractRemoval(_ common.AddressLocation) { // NO-OP } -func (*StandardLibraryHandler) CreateAccount(_ common.Address) (address common.Address, err error) { +func (*StandardLibraryHandler) CreateAccount(_ common.Address, _ interpreter.InvocationContext) (address common.Address, err error) { return common.ZeroAddress, goerrors.New("accounts are not available in this environment") } diff --git a/runtime/account_test.go b/runtime/account_test.go index 10d09b5c35..249fbf508e 100644 --- a/runtime/account_test.go +++ b/runtime/account_test.go @@ -1293,7 +1293,7 @@ func newAccountKeyTestRuntimeInterface(storage *testAccountKeyStorage) *TestRunt OnGetSigningAccounts: func() ([]Address, error) { return []Address{accountKeyTestAddress}, nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return accountKeyTestAddress, nil }, OnAddAccountKey: func(address Address, publicKey *stdlib.PublicKey, hashAlgo HashAlgorithm, weight int) (*stdlib.AccountKey, error) { diff --git a/runtime/authorizer.go b/runtime/authorizer.go index 6501188070..982eec6f7a 100644 --- a/runtime/authorizer.go +++ b/runtime/authorizer.go @@ -25,15 +25,16 @@ import ( "github.com/onflow/cadence/sema" ) +// newAccountReferenceValueFromAddress builds an `&Account` reference value for the given address and authorization. func newAccountReferenceValueFromAddress( context interpreter.AccountCreationContext, address common.Address, - environment Environment, authorization sema.Access, ) *interpreter.EphemeralReferenceValue { addressValue := interpreter.NewAddressValue(context, address) - accountValue := environment.newAccountValue(context, addressValue) + getAccount := context.GetAccountHandlerFunc() + accountValue := getAccount(context, addressValue) staticAuthorization := interpreter.ConvertSemaAccessToStaticAuthorization( context, @@ -49,7 +50,6 @@ func newAccountReferenceValueFromAddress( } func authorizerValues( - environment Environment, context interpreter.AccountCreationContext, addresses []Address, parameters []sema.Parameter, @@ -70,7 +70,6 @@ func authorizerValues( accountReferenceValue := newAccountReferenceValueFromAddress( context, address, - environment, referenceType.Authorization, ) diff --git a/runtime/contract_function_executor.go b/runtime/contract_function_executor.go index 0c113c75a1..9453a2920e 100644 --- a/runtime/contract_function_executor.go +++ b/runtime/contract_function_executor.go @@ -220,9 +220,12 @@ func (executor *contractFunctionExecutor) executeWithInterpreter( // ensure the contract is loaded inter = inter.EnsureLoaded(executor.contractLocation) - arguments := make([]interpreter.Value, 0, len(executor.arguments)) - - arguments, err = executor.appendArguments(inter, arguments) + arguments, err := convertArguments( + executor.environment, + inter, + executor.arguments, + executor.argumentTypes, + ) if err != nil { return nil, err } @@ -314,9 +317,12 @@ func (executor *contractFunctionExecutor) executeWithVM( } // receiver + arguments - arguments := make([]interpreter.Value, 0, len(executor.arguments)) - - arguments, err = executor.appendArguments(context, arguments) + arguments, err := convertArguments( + executor.environment, + context, + executor.arguments, + executor.argumentTypes, + ) if err != nil { return nil, err } @@ -353,31 +359,59 @@ type ArgumentConversionContext interface { interpreter.AccountCreationContext } -func (executor *contractFunctionExecutor) convertArgument( +// convertArguments converts the given arguments to interpreter values, +// using `context` for value construction and `environment` for importing values. +// +// `environment` may be nil. In that case, only arguments whose conversion +// does not require an environment are supported; any other argument type returns an error. +func convertArguments( + environment Environment, context ArgumentConversionContext, - argument cadence.Value, - argumentType sema.Type, -) (interpreter.Value, error) { - environment := executor.environment + arguments []cadence.Value, + argumentTypes []sema.Type, +) ([]interpreter.Value, error) { + convertedArguments := make([]interpreter.Value, 0, len(arguments)) - // Convert `Address` arguments to account reference values (`&Account`) - // if it is the expected argument type, - // so there is no need for the caller to construct the value + for i, argumentType := range argumentTypes { + convertedArgument, err := convertArgument( + environment, + context, + arguments[i], + argumentType, + ) + if err != nil { + return nil, err + } + convertedArguments = append(convertedArguments, convertedArgument) + } - if address, ok := argument.(cadence.Address); ok { + return convertedArguments, nil +} - if referenceType, ok := argumentType.(*sema.ReferenceType); ok && - referenceType.Type == sema.AccountType { +// convertArgument converts a single argument to an interpreter value. +// +// `environment` may be nil. In that case, only arguments whose conversion +// does not require an environment are supported; any other argument type returns an error. +func convertArgument( + environment Environment, + context ArgumentConversionContext, + argument cadence.Value, + argumentType sema.Type, +) (interpreter.Value, error) { - accountReferenceValue := newAccountReferenceValueFromAddress( - context, - common.Address(address), - environment, - referenceType.Authorization, - ) + // Convert `Address` arguments to account reference values (`&Account`) if that is the expected argument type, + // so there is no need for the caller to construct the value. + accountReferenceValue := convertAccountReferenceArgument(context, argument, argumentType) + if accountReferenceValue != nil { + return accountReferenceValue, nil + } - return accountReferenceValue, nil - } + // Importing any other argument type requires an environment. + if environment == nil { + return nil, errors.NewDefaultUserError( + "cannot convert argument of type %s without an environment", + argumentType.QualifiedString(), + ) } return ImportValue( @@ -389,24 +423,93 @@ func (executor *contractFunctionExecutor) convertArgument( ) } -func (executor *contractFunctionExecutor) appendArguments( +// convertAccountReferenceArgument converts an `Address` argument to an account reference value (`&Account`) +// when the expected argument type is an `&Account` reference, and returns it. +// +// Returns nil if the argument is not an `Address`, or the expected type is not an `&Account` reference, +// in which case no conversion is performed. +func convertAccountReferenceArgument( context ArgumentConversionContext, - arguments []interpreter.Value, -) ( - []interpreter.Value, - error, -) { - for i, argumentType := range executor.argumentTypes { - argument, err := executor.convertArgument( - context, - executor.arguments[i], - argumentType, - ) - if err != nil { - return nil, err + argument cadence.Value, + argumentType sema.Type, +) interpreter.Value { + address, ok := argument.(cadence.Address) + if !ok { + return nil + } + + referenceType, ok := argumentType.(*sema.ReferenceType) + if !ok || referenceType.Type != sema.AccountType { + return nil + } + + return newAccountReferenceValueFromAddress( + context, + common.Address(address), + referenceType.Authorization, + ) +} + +// InvokeContractFunctionOnContext invokes a function of a contract using the supplied, +// already-executing invocation `context`, so that the call SHARES the same storage as that context. +// +// Unlike Runtime.InvokeContractFunction, this helper does NOT create a new Storage +// and does NOT commit storage: the outer program that owns `context` is responsible for committing. +// This is what lets a host (e.g. FVM) run a system-contract function as part of account creation +// against the same atree storage as the transaction that triggered it, +// so the writes are not lost to a separate, independently-committed storage instance. +// +// Arguments are converted using the same conversion as a regular contract-function invocation, +// but WITHOUT an environment. Therefore, only arguments whose conversion does not require an environment are supported: +// Any argument that requires an environment for ImportValue results in an error. +func InvokeContractFunctionOnContext( + context interpreter.InvocationContext, + contractLocation common.AddressLocation, + functionName string, + arguments []cadence.Value, + argumentTypes []sema.Type, +) (val cadence.Value, err error) { + + defer context.RecoverErrors(func(internalErr error) { + err = internalErr + }) + + // Reuse the regular argument conversion, without an environment (see the doc comment above for the + // resulting limitation on supported argument types). + convertedArguments, err := convertArguments(nil, context, arguments, argumentTypes) + if err != nil { + return nil, err + } + + contractValue := context.GetContractValue(contractLocation) + if contractValue == nil { + return nil, interpreter.NotDeclaredError{ + ExpectedKind: common.DeclarationKindContract, + Name: contractLocation.Name, + } + } + + function := context.GetMethod(contractValue, functionName, nil) + if function == nil { + return nil, interpreter.NotDeclaredError{ + ExpectedKind: common.DeclarationKindFunction, + Name: functionName, } - arguments = append(arguments, argument) } - return arguments, nil + functionType := function.FunctionType(context) + + result, err := interpreter.InvokeFunctionValue( + context, + function, + convertedArguments, + argumentTypes, + functionType.ParameterTypes(), + functionType.ReturnTypeAnnotation.Type, + ) + if err != nil { + return nil, err + } + + return ExportValue(result, context) } diff --git a/runtime/contract_update_validation_test.go b/runtime/contract_update_validation_test.go index a226742fcf..847efe4068 100644 --- a/runtime/contract_update_validation_test.go +++ b/runtime/contract_update_validation_test.go @@ -705,7 +705,7 @@ func TestRuntimeContractUpdateValidation(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { result := interpreter.NewUnmeteredAddressValueFromBytes([]byte{nextAccount}) nextAccount++ return result.ToAddress(), nil @@ -873,7 +873,7 @@ func TestRuntimeContractUpdateValidation(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { result := interpreter.NewUnmeteredAddressValueFromBytes([]byte{nextAccount}) nextAccount++ return result.ToAddress(), nil @@ -1033,7 +1033,7 @@ func TestRuntimeContractUpdateValidation(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { result := interpreter.NewUnmeteredAddressValueFromBytes([]byte{nextAccount}) nextAccount++ return result.ToAddress(), nil diff --git a/runtime/empty.go b/runtime/empty.go index aaf11f2b51..b7c8a77051 100644 --- a/runtime/empty.go +++ b/runtime/empty.go @@ -69,7 +69,7 @@ func (EmptyRuntimeInterface) AllocateSlabIndex(_ []byte) (atree.SlabIndex, error panic("unexpected call to AllocateSlabIndex") } -func (EmptyRuntimeInterface) CreateAccount(_ Address) (address Address, err error) { +func (EmptyRuntimeInterface) CreateAccount(_ Address, _ interpreter.InvocationContext) (address Address, err error) { panic("unexpected call to CreateAccount") } diff --git a/runtime/error_test.go b/runtime/error_test.go index e1a806cc08..90ddbfc0a1 100644 --- a/runtime/error_test.go +++ b/runtime/error_test.go @@ -585,7 +585,7 @@ func TestRuntimeMultipleInterfaceDefaultImplementationsError(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { result := interpreter.NewUnmeteredAddressValueFromBytes([]byte{nextAccount}) nextAccount++ return result.ToAddress(), nil diff --git a/runtime/external.go b/runtime/external.go index 5f7b3e9c62..b6956a70c0 100644 --- a/runtime/external.go +++ b/runtime/external.go @@ -123,9 +123,9 @@ func (e ExternalInterface) AllocateSlabIndex(owner []byte) (index atree.SlabInde return } -func (e ExternalInterface) CreateAccount(payer Address) (address Address, err error) { +func (e ExternalInterface) CreateAccount(payer Address, context interpreter.InvocationContext) (address Address, err error) { errors.WrapPanic(func() { - address, err = e.Interface.CreateAccount(payer) + address, err = e.Interface.CreateAccount(payer, context) }) if err != nil { err = interpreter.WrappedExternalError(err) diff --git a/runtime/import_test.go b/runtime/import_test.go index 4224b6b66b..36575ca23a 100644 --- a/runtime/import_test.go +++ b/runtime/import_test.go @@ -29,6 +29,7 @@ import ( "github.com/onflow/cadence" "github.com/onflow/cadence/common" "github.com/onflow/cadence/encoding/json" + "github.com/onflow/cadence/interpreter" . "github.com/onflow/cadence/runtime" "github.com/onflow/cadence/sema" . "github.com/onflow/cadence/test_utils/common_utils" @@ -473,7 +474,7 @@ func TestRuntimeContractImport(t *testing.T) { accountCodes[location] = code return nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return addressValue, nil }, OnEmitEvent: func(event cadence.Event) error { diff --git a/runtime/interface.go b/runtime/interface.go index bc4e1b9572..8521e9e5fb 100644 --- a/runtime/interface.go +++ b/runtime/interface.go @@ -65,7 +65,14 @@ type Interface interface { // AllocateSlabIndex allocates a new slab index under the given account. AllocateSlabIndex(owner []byte) (atree.SlabIndex, error) // CreateAccount creates a new account. - CreateAccount(payer Address) (address Address, err error) + // + // `context` is the invocation context of the currently-executing program + // that triggered the account creation (e.g. the transaction that called the `Account` constructor). + // + // Implementations of this function may use it to perform follow-up work + // (such as invoking a Cadence function) against the SAME storage as the outer program, + // so that writes made during account creation are visible to and committed by the outer program. + CreateAccount(payer Address, context interpreter.InvocationContext) (address Address, err error) // AddAccountKey appends a key to an account. AddAccountKey(address Address, publicKey *PublicKey, hashAlgo HashAlgorithm, weight int) (*AccountKey, error) // GetAccountKey retrieves a key from an account by index. diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index 5cce43f2ec..c2f2b45366 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -2829,7 +2829,7 @@ func TestRuntimeTransaction_CreateAccount(t *testing.T) { OnGetSigningAccounts: func() ([]Address, error) { return []Address{{42}}, nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { // Check that pending writes were committed before assert.True(t, performedWrite) return Address{42}, nil @@ -3394,6 +3394,160 @@ func TestRuntimeInvokeContractFunction(t *testing.T) { }) } +func TestRuntimeInvokeContractFunctionOnContext(t *testing.T) { + + t.Parallel() + + contractAddress := common.MustBytesToAddress([]byte{0x1}) + newAccountAddress := common.MustBytesToAddress([]byte{0x2}) + + contract := []byte(` + access(all) contract Test { + + access(all) fun setup(account: auth(SaveValue) &Account) { + account.storage.save(42, to: /storage/setupValue) + } + + access(all) fun setup2(_ arg: String) {} + } + `) + + transaction := []byte(` + transaction { + prepare(signer: auth(Storage) &Account) { + Account(payer: signer) + } + } + `) + + accountReferenceType := sema.NewReferenceType( + nil, + sema.NewEntitlementSetAccess( + []*sema.EntitlementType{sema.SaveValueType}, + sema.Conjunction, + ), + sema.AccountType, + ) + + testLocation := common.AddressLocation{ + Address: contractAddress, + Name: "Test", + } + + runtime := NewTestRuntime() + + var accountCode []byte + + var createAccountCalled bool + + runtimeInterface := &TestRuntimeInterface{ + Storage: NewTestLedger(nil, nil), + OnGetCode: func(_ Location) ([]byte, error) { + return accountCode, nil + }, + OnGetSigningAccounts: func() ([]Address, error) { + return []Address{contractAddress}, nil + }, + OnResolveLocation: NewSingleIdentifierLocationResolver(t), + OnGetAccountContractCode: func(_ common.AddressLocation) ([]byte, error) { + return accountCode, nil + }, + OnUpdateAccountContractCode: func(_ common.AddressLocation, code []byte) error { + accountCode = code + return nil + }, + OnEmitEvent: func(event cadence.Event) error { + return nil + }, + OnCreateAccount: func(payer Address, context interpreter.InvocationContext) (Address, error) { + createAccountCalled = true + + // Re-entrantly invoke Test.setup on the same context, writing to the new account's storage. + // The helper converts the Address argument into an `auth(SaveValue) &Account`. + _, setupErr := InvokeContractFunctionOnContext( + context, + testLocation, + "setup", + []cadence.Value{ + cadence.Address(newAccountAddress), + }, + []sema.Type{ + accountReferenceType, + }, + ) + require.NoError(t, setupErr) + + // An argument whose conversion would require an environment (here, a String) + // is unsupported and must be rejected. + _, setup2Err := InvokeContractFunctionOnContext( + context, + testLocation, + "setup2", + []cadence.Value{ + cadence.String("foo"), + }, + []sema.Type{ + sema.StringType, + }, + ) + require.Error(t, setup2Err) + assert.ErrorContains(t, setup2Err, "without an environment") + + return newAccountAddress, setupErr + }, + } + + nextTransactionLocation := NewTransactionLocationGenerator() + nextScriptLocation := NewScriptLocationGenerator() + + // Deploy the Test contract. + err := runtime.ExecuteTransaction( + Script{Source: DeploymentTransaction("Test", contract)}, + Context{ + Interface: runtimeInterface, + Location: nextTransactionLocation(), + UseVM: *compile, + }, + ) + require.NoError(t, err) + + // Run the transaction that creates an account, triggering the re-entrant invocation. + err = runtime.ExecuteTransaction( + Script{Source: transaction}, + Context{ + Interface: runtimeInterface, + Location: nextTransactionLocation(), + UseVM: *compile, + }, + ) + require.NoError(t, err) + + require.True(t, createAccountCalled) + + // The write performed by Test.setup persisted. The helper does not commit storage itself, + // so the value is only visible afterwards because it ran against the outer transaction's storage, + // which the transaction committed. + // A separate, uncommitted storage would have lost the write. + value, err := runtime.ExecuteScript( + Script{ + Source: []byte(` + access(all) fun main(): Int { + return getAuthAccount(0x2) + .storage.load(from: /storage/setupValue) + ?? panic("value was not stored") + } + `), + }, + Context{ + Interface: runtimeInterface, + Location: nextScriptLocation(), + UseVM: *compile, + }, + ) + require.NoError(t, err) + assert.Equal(t, cadence.NewInt(42), value) +} + func TestRuntimeContractNestedResource(t *testing.T) { t.Parallel() @@ -3678,7 +3832,7 @@ func TestRuntimeStorageLoadedDestructionConcreteTypeWithAttachment(t *testing.T) accountCodes[location] = code return nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return addressValue, nil }, OnEmitEvent: func(event cadence.Event) error { @@ -3814,7 +3968,7 @@ func TestRuntimeStorageLoadedDestructionConcreteTypeWithAttachmentUnloadedContra accountCodes[location] = code return nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return addressValue, nil }, OnEmitEvent: func(event cadence.Event) error { @@ -3959,7 +4113,7 @@ func TestRuntimeStorageLoadedDestructionConcreteTypeSameNamedInterface(t *testin accountCodes[location] = code return nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return addressValue, nil }, OnEmitEvent: func(event cadence.Event) error { @@ -4534,7 +4688,7 @@ func TestRuntimeFungibleTokenCreateAccount(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return address2Value, nil }, OnGetSigningAccounts: func() ([]Address, error) { @@ -4691,7 +4845,7 @@ func TestRuntimeInvokeStoredInterfaceFunction(t *testing.T) { return accountCodes[location], nil }, Storage: NewTestLedger(nil, nil), - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { result := interpreter.NewUnmeteredAddressValueFromBytes([]byte{nextAccount}) nextAccount++ return result.ToAddress(), nil @@ -6709,7 +6863,7 @@ func TestRuntimeDeployCodeCaching(t *testing.T) { var signerAddresses []Address runtimeInterface := &TestRuntimeInterface{ - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { accountCounter++ return Address{accountCounter}, nil }, @@ -6849,7 +7003,7 @@ func TestRuntimeUpdateCodeCaching(t *testing.T) { var programHits []string runtimeInterface := &TestRuntimeInterface{ - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { accountCounter++ return Address{accountCounter}, nil }, @@ -7063,7 +7217,7 @@ func TestRuntimeOnGetOrLoadProgramHits(t *testing.T) { var programsHits []Location runtimeInterface := &TestRuntimeInterface{ - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { accountCounter++ return Address{accountCounter}, nil }, @@ -11707,7 +11861,7 @@ func TestRuntimeAccountStorageBorrowEphemeralReferenceValue(t *testing.T) { accountCodes[location] = code return nil }, - OnCreateAccount: func(payer Address) (address Address, err error) { + OnCreateAccount: func(payer Address, _ interpreter.InvocationContext) (address Address, err error) { return addressValue, nil }, OnEmitEvent: func(event cadence.Event) error { diff --git a/runtime/transaction_executor.go b/runtime/transaction_executor.go index 7c038c7c54..3165403344 100644 --- a/runtime/transaction_executor.go +++ b/runtime/transaction_executor.go @@ -189,7 +189,6 @@ func (executor *transactionExecutor) preprocess() (err error) { executor.authorizerValues = func(context interpreter.AccountCreationContext) []interpreter.Value { return authorizerValues( - executor.environment, context, authorizerAddresses, prepareParameters, diff --git a/stdlib/account.go b/stdlib/account.go index c2c18d3840..31ff8c604a 100644 --- a/stdlib/account.go +++ b/stdlib/account.go @@ -106,7 +106,11 @@ type AccountCreator interface { EventEmitter AccountHandler // CreateAccount creates a new account. - CreateAccount(payer common.Address) (address common.Address, err error) + // + // `context` is the invocation context of the program creating the account, + // forwarded so the implementation can run follow-up work (e.g. invoke another Cadence function) + // against the SAME storage as the creating program. See runtime.Interface.CreateAccount. + CreateAccount(payer common.Address, context interpreter.InvocationContext) (address common.Address, err error) } func NativeAccountConstructor(creator AccountCreator) interpreter.NativeFunction { @@ -147,7 +151,7 @@ func NewVMAccountConstructor(creator AccountCreator) StandardLibraryValue { } func NewAccount( - context interpreter.MemberAccessibleContext, + context interpreter.InvocationContext, payer interpreter.MemberAccessibleValue, creator AccountCreator, ) interpreter.Value { @@ -185,7 +189,7 @@ func NewAccount( context, func() (address common.Address) { var err error - address, err = creator.CreateAccount(payerAddress) + address, err = creator.CreateAccount(payerAddress, context) if err != nil { panic(err) } diff --git a/test_utils/runtime_utils/testinterface.go b/test_utils/runtime_utils/testinterface.go index 3f0361eee4..0492eeb6d3 100644 --- a/test_utils/runtime_utils/testinterface.go +++ b/test_utils/runtime_utils/testinterface.go @@ -46,7 +46,10 @@ type TestRuntimeInterface struct { location runtime.Location, load func() (*runtime.Program, error), ) (*runtime.Program, error) - OnCreateAccount func(payer runtime.Address) (address runtime.Address, err error) + OnCreateAccount func( + payer runtime.Address, + context interpreter.InvocationContext, + ) (address runtime.Address, err error) OnAddEncodedAccountKey func(address runtime.Address, publicKey []byte) error OnRemoveEncodedAccountKey func(address runtime.Address, index int) (publicKey []byte, err error) OnAddAccountKey func( @@ -212,11 +215,11 @@ func (i *TestRuntimeInterface) AllocateSlabIndex(owner []byte) (atree.SlabIndex, return i.Storage.AllocateSlabIndex(owner) } -func (i *TestRuntimeInterface) CreateAccount(payer runtime.Address) (address runtime.Address, err error) { +func (i *TestRuntimeInterface) CreateAccount(payer runtime.Address, context interpreter.InvocationContext) (address runtime.Address, err error) { if i.OnCreateAccount == nil { panic("must specify TestRuntimeInterface.OnCreateAccount") } - return i.OnCreateAccount(payer) + return i.OnCreateAccount(payer, context) } func (i *TestRuntimeInterface) AddEncodedAccountKey(address runtime.Address, publicKey []byte) error { From 054163a5edbdde9c008564111dbf946eb0de229f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Fri, 26 Jun 2026 12:29:15 -0700 Subject: [PATCH 136/139] Canonicalize optional container elements Recursively canonicalize SomeValue inner values when a stored container element is converted through MustConvertStoredContainerElement. This closes the gap where an optional-wrapped array, dictionary, or composite could produce distinct Cadence wrappers even though direct container reads were canonicalized. Add a Cadence-level regression that compares references taken through an optional container element, plus focused Go-level cache tests for single and nested SomeValue wrappers. Update transfer tests to pass the interpreter context into ArrayValue.Get instead of nil, so canonicalizing reads always have a container cache. --- interpreter/array_test.go | 94 ++++++++++++++++++++++++++++++++++++ interpreter/storage.go | 9 ++++ interpreter/transfer_test.go | 4 +- interpreter/value_test.go | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) diff --git a/interpreter/array_test.go b/interpreter/array_test.go index f46ebcd336..32d65d1a56 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -985,6 +985,100 @@ func TestInterpretOptionalContainerAliasingViaArrayIndex(t *testing.T) { require.NoError(t, err) } +func TestInterpretOptionalContainerReferenceUsesCanonicalInnerWrapper(t *testing.T) { + t.Parallel() + + sameWrapperFunction := stdlib.NewInterpreterStandardLibraryStaticFunction( + "sameWrapper", + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "first", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "second", + TypeAnnotation: sema.NewTypeAnnotation( + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ), + }, + }, + sema.BoolTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + first := args[0].(*interpreter.EphemeralReferenceValue) + second := args[1].(*interpreter.EphemeralReferenceValue) + return interpreter.BoolValue(first.Value == second.Value) + }, + ) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(sameWrapperFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, sameWrapperFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( + t, + ` + access(all) resource Vault { + access(all) var balance: UFix64 + init(balance: UFix64) { self.balance = balance } + } + + access(all) fun main() { + let outer: @[[Vault]?] <- [<-[<-create Vault(balance: 0.0)]] + + let ref = (&outer[0] as &[Vault]?)! + let ref2 = (&outer[0] as &[Vault]?)! + + assert(sameWrapper(ref, ref2), + message: "optional container references must share the canonical inner wrapper") + + destroy outer + } + `, + ParseCheckAndInterpretOptions{ + ParseAndCheckOptions: &ParseAndCheckOptions{ + CheckerConfig: &sema.Config{ + BaseValueActivationHandler: func(_ common.Location) *sema.VariableActivation { + return baseValueActivation + }, + }, + }, + InterpreterConfig: &interpreter.Config{ + BaseActivationHandler: func(_ common.Location) *activations.Activation[interpreter.Variable] { + return baseActivation + }, + }, + }, + ) + require.NoError(t, err) + + _, err = inter.Invoke("main") + require.NoError(t, err) +} + // TestInterpretOptionalContainerAliasingViaDictionaryLookup exercises // the SomeStorable path through dictionary lookup: when a dict's value // type is an optional container, DictionaryValue.Get retrieves a diff --git a/interpreter/storage.go b/interpreter/storage.go index 2e324c58db..7555dba5fc 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -133,6 +133,15 @@ var _ atreeContainer = &atree.OrderedMap{} // This means `MustConvertStoredContainerElement` is safe to call on any path — // it acts as canonicalize-or-passthrough based on the wrapper's actual state. func canonicalizeContainerElement(cache AtreeContainerCache, fresh Value) Value { + if someValue, ok := fresh.(*SomeValue); ok { + innerValue := someValue.InnerValue() + if innerValue == nil { + return fresh + } + someValue.value = canonicalizeContainerElement(cache, innerValue) + return someValue + } + freshCacheable, ok := fresh.(canonicalizableContainer) if !ok { return fresh diff --git a/interpreter/transfer_test.go b/interpreter/transfer_test.go index de385f3714..15872d0e58 100644 --- a/interpreter/transfer_test.go +++ b/interpreter/transfer_test.go @@ -207,7 +207,7 @@ func TestInterpretConversionOnTransfer(t *testing.T) { // Elements must be boxed. - element := array.Get(nil, 0) + element := array.Get(inter, 0) require.IsType(t, &interpreter.SomeValue{}, element) someValue := element.(*interpreter.SomeValue) @@ -243,7 +243,7 @@ func TestInterpretConversionOnTransfer(t *testing.T) { // Elements must be boxed. - element := array.Get(nil, 0) + element := array.Get(inter, 0) require.IsType(t, &interpreter.SomeValue{}, element) someValue := element.(*interpreter.SomeValue) diff --git a/interpreter/value_test.go b/interpreter/value_test.go index 525622a002..98594785e6 100644 --- a/interpreter/value_test.go +++ b/interpreter/value_test.go @@ -1058,6 +1058,97 @@ func TestClearAllCanonicalAtreeContainers(t *testing.T) { assert.Nil(t, inter.CanonicalAtreeContainer(composite.ValueID())) } +func TestMustConvertStoredContainerElementCanonicalizesSomeInnerContainer(t *testing.T) { + t.Parallel() + + inter := newTestInterpreter(t) + + innerType := &VariableSizedStaticType{ + Type: PrimitiveStaticTypeInt, + } + outerType := &VariableSizedStaticType{ + Type: &OptionalStaticType{ + Type: innerType, + }, + } + + inner := NewArrayValue( + inter, + innerType, + common.ZeroAddress, + NewUnmeteredIntValueFromInt64(1), + ) + outer := NewArrayValue( + inter, + outerType, + common.ZeroAddress, + NewSomeValueNonCopying(inter, inner), + ) + + firstSome := outer.Get(inter, 0).(*SomeValue) + firstInner := firstSome.InnerValue().(*ArrayValue) + valueID := firstInner.ValueID() + + cached := inter.CanonicalAtreeContainer(valueID) + require.NotNil(t, cached) + require.Same(t, firstInner, cached) + + secondSome := outer.Get(inter, 0).(*SomeValue) + secondInner := secondSome.InnerValue().(*ArrayValue) + + assert.Same(t, firstInner, secondInner) + assert.Same(t, firstInner, inter.CanonicalAtreeContainer(valueID)) +} + +func TestMustConvertStoredContainerElementCanonicalizesNestedSomeInnerContainer(t *testing.T) { + t.Parallel() + + inter := newTestInterpreter(t) + + innerType := &VariableSizedStaticType{ + Type: PrimitiveStaticTypeInt, + } + outerType := &VariableSizedStaticType{ + Type: &OptionalStaticType{ + Type: &OptionalStaticType{ + Type: innerType, + }, + }, + } + + inner := NewArrayValue( + inter, + innerType, + common.ZeroAddress, + NewUnmeteredIntValueFromInt64(1), + ) + outer := NewArrayValue( + inter, + outerType, + common.ZeroAddress, + NewSomeValueNonCopying( + inter, + NewSomeValueNonCopying(inter, inner), + ), + ) + + firstOuterSome := outer.Get(inter, 0).(*SomeValue) + firstInnerSome := firstOuterSome.InnerValue().(*SomeValue) + firstInner := firstInnerSome.InnerValue().(*ArrayValue) + valueID := firstInner.ValueID() + + cached := inter.CanonicalAtreeContainer(valueID) + require.NotNil(t, cached) + require.Same(t, firstInner, cached) + + secondOuterSome := outer.Get(inter, 0).(*SomeValue) + secondInnerSome := secondOuterSome.InnerValue().(*SomeValue) + secondInner := secondInnerSome.InnerValue().(*ArrayValue) + + assert.Same(t, firstInner, secondInner) + assert.Same(t, firstInner, inter.CanonicalAtreeContainer(valueID)) +} + // TestDictionaryValueTransferNoRemoveKeepsCanonicalCache asserts that a // non-resource Transfer without `remove` leaves the source wrapper valid // (`v.dictionary` stays set, slabs untouched), so the cache entry must NOT From d98f8276550537c0f8373303b2cd5278795f9cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 29 Jun 2026 10:35:11 -0700 Subject: [PATCH 137/139] update to internal atree v0.16.1-rc.3 --- go.mod | 2 +- go.sum | 4 ++-- tools/compatibility-check/go.mod | 2 +- tools/compatibility-check/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 4b73eb8644..c2ae5c89cc 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.2 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.3 diff --git a/go.sum b/go.sum index ad0beb8e75..24c51757a5 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.16.1-rc.2 h1:FxY+wBFuFKh3cAsN+B0mrdRBVljcE2n6L8FFKjJUMzA= -github.com/onflow/atree-internal v0.16.1-rc.2/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.16.1-rc.3 h1:p6rSSOImZkxE5F4765voRjW18D29EStT9pzt5zL60qA= +github.com/onflow/atree-internal v0.16.1-rc.3/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index 68fd9867df..d1dff0f48d 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -90,4 +90,4 @@ require ( replace github.com/onflow/cadence => ../../ -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.2 +replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.3 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index bf2145adcf..89e9942345 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree-internal v0.16.1-rc.2 h1:FxY+wBFuFKh3cAsN+B0mrdRBVljcE2n6L8FFKjJUMzA= -github.com/onflow/atree-internal v0.16.1-rc.2/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree-internal v0.16.1-rc.3 h1:p6rSSOImZkxE5F4765voRjW18D29EStT9pzt5zL60qA= +github.com/onflow/atree-internal v0.16.1-rc.3/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= From 8b71a06aed1a427a5a1f54f18d8a37a7a46e3283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Mon, 29 Jun 2026 10:35:24 -0700 Subject: [PATCH 138/139] update version --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index c05d98c39d..f056e3c074 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.4-rc.5" +const Version = "v1.10.4-rc.6" From c157cbe1a58d972de628165c1cfeffcba1977c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Thu, 2 Jul 2026 08:43:44 -0700 Subject: [PATCH 139/139] update to atree v0.16.1 --- go.mod | 4 +--- go.sum | 4 ++-- tools/compatibility-check/go.mod | 4 +--- tools/compatibility-check/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index c2ae5c89cc..e5e4c0da85 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/kr/pretty v0.3.1 github.com/leanovate/gopter v0.2.11 github.com/logrusorgru/aurora/v4 v4.0.0 - github.com/onflow/atree v0.16.0 + github.com/onflow/atree v0.16.1 github.com/onflow/crypto v0.25.3 github.com/onflow/fixed-point v0.1.1 github.com/rivo/uniseg v0.4.7 @@ -62,5 +62,3 @@ require ( gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.3 diff --git a/go.sum b/go.sum index 24c51757a5..1325aa7c63 100644 --- a/go.sum +++ b/go.sum @@ -251,8 +251,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onflow/atree-internal v0.16.1-rc.3 h1:p6rSSOImZkxE5F4765voRjW18D29EStT9pzt5zL60qA= -github.com/onflow/atree-internal v0.16.1-rc.3/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree v0.16.1 h1:EmlaIz/GwQ39o5agAb2KT2ynt4SHRBkgMMWU5bp6iTs= +github.com/onflow/atree v0.16.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90= diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index d1dff0f48d..fa1f9165d0 100644 --- a/tools/compatibility-check/go.mod +++ b/tools/compatibility-check/go.mod @@ -43,7 +43,7 @@ require ( github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.7 // indirect - github.com/onflow/atree v0.16.0 // indirect + github.com/onflow/atree v0.16.1 // indirect github.com/onflow/crypto v0.25.3 // indirect github.com/onflow/fixed-point v0.1.1 // indirect github.com/onflow/flow-core-contracts/lib/go/templates v1.9.2 // indirect @@ -89,5 +89,3 @@ require ( ) replace github.com/onflow/cadence => ../../ - -replace github.com/onflow/atree => github.com/onflow/atree-internal v0.16.1-rc.3 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index 89e9942345..cda35ef693 100644 --- a/tools/compatibility-check/go.sum +++ b/tools/compatibility-check/go.sum @@ -347,8 +347,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onflow/atree-internal v0.16.1-rc.3 h1:p6rSSOImZkxE5F4765voRjW18D29EStT9pzt5zL60qA= -github.com/onflow/atree-internal v0.16.1-rc.3/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= +github.com/onflow/atree v0.16.1 h1:EmlaIz/GwQ39o5agAb2KT2ynt4SHRBkgMMWU5bp6iTs= +github.com/onflow/atree v0.16.1/go.mod h1:hiOT/vKK/Zyw34Ru9OFbfEemC5NnQ7SHFB43bN9/4qI= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fixed-point v0.1.1 h1:j0jYZVO8VGyk1476alGudEg7XqCkeTVxb5ElRJRKS90=