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 8edffc9738..8f53b3f207 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 4a25310c07..693a6e1903 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 e7256cdf6d..6a2833ebb1 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 }} @@ -33,10 +34,16 @@ 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 - cache: true + cache: false - name: Make output dirs run: | @@ -75,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/.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 }} 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 diff --git a/bbq/compiler/compiler.go b/bbq/compiler/compiler.go index 82ab9cf853..c1a5f12a23 100644 --- a/bbq/compiler/compiler.go +++ b/bbq/compiler/compiler.go @@ -601,12 +601,42 @@ func (c *Compiler[_, _]) compileStatement(statement ast.Statement) { c.compileWithPositionInfo( statement, func() { - c.emit(opcode.InstructionStatement{}) - ast.AcceptStatement[struct{}](statement, c) + // 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) + }) }, ) } +// emitUnreachableIfStatementEndsControlFlow emits an InstructionUnreachable +// after the given statement's bytecode if the type checker determined that +// 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. +// +// 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. @@ -1476,79 +1506,77 @@ func (c *Compiler[_, _]) nearestLoopControlFlow() *controlFlow { } 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). - switch test := statement.Test.(type) { - case ast.Expression: - c.compileExpression(test) - elseJump = c.emitUndefinedJumpIfFalse() + var ( + elseJump int + additionalThenScope bool + ) - case *ast.VariableDeclaration: + switch test := statement.Test.(type) { + case ast.Expression: + c.compileExpression(test) + elseJump = c.emitUndefinedJumpIfFalse() + + 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) { @@ -1593,7 +1621,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 @@ -1650,8 +1680,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) @@ -1724,7 +1758,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) @@ -1770,39 +1803,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 } @@ -1865,25 +1895,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 } @@ -2943,6 +2972,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[_, _]) VisitInvocationExpression(expression *ast.InvocationExpression) (_ struct{}) { @@ -3357,18 +3402,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 { @@ -4736,6 +4783,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_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/bbq/compiler/compiler_test.go b/bbq/compiler/compiler_test.go index 1b46d9594c..74b4b3de59 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), @@ -4423,7 +4426,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -4445,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{}, @@ -4544,7 +4551,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4566,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{}, @@ -4659,7 +4670,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 23}, + opcode.PrettyInstructionJumpIfFalse{Target: 24}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4681,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{}, @@ -4807,7 +4822,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -4829,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{}, @@ -4851,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{}, @@ -4875,7 +4894,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 33}, + opcode.PrettyInstructionJumpIfFalse{Target: 35}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -4896,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{}, @@ -5052,7 +5075,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 25}, + opcode.PrettyInstructionJumpIfFalse{Target: 26}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -5074,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{}, @@ -5291,7 +5318,7 @@ func TestCompileFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 13}, + opcode.PrettyInstructionJumpIfFalse{Target: 14}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -5313,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{}, @@ -5445,7 +5476,7 @@ func TestCompileFunctionConditions(t *testing.T) { }, opcode.PrettyInstructionGreater{}, opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 16}, + opcode.PrettyInstructionJumpIfFalse{Target: 17}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -5465,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 @@ -5485,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{}, @@ -5501,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{}, @@ -5522,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 @@ -5562,6 +5603,10 @@ func TestForLoop(t *testing.T) { elementVarIndex ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ opcode.PrettyInstructionStatement{}, @@ -5569,7 +5614,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()` @@ -5631,13 +5676,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. @@ -5741,6 +5790,10 @@ func TestForLoop(t *testing.T) { x2Index ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ @@ -5762,7 +5815,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()` @@ -5854,6 +5907,10 @@ func TestForLoop(t *testing.T) { yIndex ) + intArrayType := &interpreter.VariableSizedStaticType{ + Type: interpreter.PrimitiveStaticTypeInt, + } + assert.Equal(t, []opcode.PrettyInstruction{ @@ -5861,7 +5918,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()` @@ -5887,7 +5944,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()` @@ -6516,7 +6573,7 @@ func TestCompileTransaction(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 15}, + opcode.PrettyInstructionJumpIfFalse{Target: 16}, // $failPreCondition("pre failed") opcode.PrettyInstructionStatement{}, @@ -6538,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{}, @@ -6587,7 +6648,7 @@ func TestCompileTransaction(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 37}, + opcode.PrettyInstructionJumpIfFalse{Target: 39}, // $failPostCondition("post failed") opcode.PrettyInstructionStatement{}, @@ -6609,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{}, @@ -6858,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}, @@ -6878,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 @@ -6954,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}, @@ -6974,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 @@ -7026,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}, @@ -7046,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 @@ -9459,6 +9539,7 @@ func TestCompileSecondValueAssignment(t *testing.T) { Data: "bar", Kind: constant.RawString, }, + AccessedType: fooType, }, opcode.PrettyInstructionTransferAndConvert{ ValueType: barType, @@ -10566,9 +10647,9 @@ 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, @@ -10580,9 +10661,9 @@ func TestCompileSwapMembers(t *testing.T) { 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, @@ -10684,12 +10765,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{ @@ -10732,7 +10814,7 @@ func TestCompileSwapIndexInStructs(t *testing.T) { opcode.PrettyInstructionGetLocal{Local: charsIndex}, opcode.PrettyInstructionSetLocal{ - Local: tempIndex1, + Local: leftTargetIndex, IsTempVar: true, }, @@ -10747,13 +10829,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, }, @@ -10768,70 +10850,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 @@ -11390,7 +11484,7 @@ func TestForStatementCapturing(t *testing.T) { }, // get iterator - opcode.PrettyInstructionIterator{}, + opcode.PrettyInstructionIterator{IndexedType: arrayType}, opcode.PrettyInstructionSetLocal{Local: iterIndex}, // set i = -1 @@ -11588,7 +11682,7 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -11610,6 +11704,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{}, @@ -11738,7 +11836,7 @@ func TestCompileFunctionExpressionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -11760,6 +11858,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{}, @@ -11844,7 +11946,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -11866,6 +11968,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{}, @@ -11984,7 +12090,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 21}, + opcode.PrettyInstructionJumpIfFalse{Target: 22}, // $failPostCondition("") opcode.PrettyInstructionStatement{}, @@ -12006,6 +12112,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{}, @@ -12095,7 +12205,7 @@ func TestCompileInnerFunctionConditions(t *testing.T) { // if ! opcode.PrettyInstructionNot{}, - opcode.PrettyInstructionJumpIfFalse{Target: 12}, + opcode.PrettyInstructionJumpIfFalse{Target: 13}, // $failPreCondition("") opcode.PrettyInstructionStatement{}, @@ -12117,6 +12227,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{}, @@ -12820,7 +12934,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{}, @@ -12851,7 +12965,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{}, @@ -14091,3 +14205,316 @@ 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), + ) +} + +// 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], + ) +} + +// 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), + ) +} 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/bbq/opcode/instructions.go b/bbq/opcode/instructions.go index 7818ff5399..23531c987d 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], } } @@ -2998,6 +3006,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{} @@ -3007,22 +3016,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 @@ -3723,7 +3748,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 c61c13d71a..f126d01e87 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" @@ -964,6 +966,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 21c2afc382..f922f5bb42 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() } @@ -1481,6 +1484,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{} @@ -1490,7 +1494,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..469fd44b4c 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{}}, @@ -346,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, }, }, @@ -440,6 +443,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 457cb3166b..9f78d8656d 100644 --- a/bbq/opcode/print_test.go +++ b/bbq/opcode/print_test.go @@ -272,12 +272,12 @@ 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}, "SetAttachmentBase": {byte(SetAttachmentBase), 1, 2, 3}, - "SetIndex indexedType:258": {byte(SetIndex), 1, 2}, + "SetIndex indexedType:258": {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)}, @@ -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/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/bbq/vm/context.go b/bbq/vm/context.go index 952853f1af..08d02367d3 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. @@ -86,9 +91,43 @@ 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 } +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) +} + +// 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) +} + func (c *Context) RecordStorageMutation() { if c.inStorageIteration { c.storageMutatedDuringIteration = true @@ -378,7 +417,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( @@ -395,7 +438,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) @@ -414,9 +457,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 a38bf499d8..2f90c0cd3c 100644 --- a/bbq/vm/test/vm_test.go +++ b/bbq/vm/test/vm_test.go @@ -8382,6 +8382,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, @@ -8414,6 +8419,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, @@ -9814,6 +9825,103 @@ 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. + _, err := CompileAndInvoke(t, ` + entitlement X + entitlement Y + + struct S { + access(X) fun base() {} + } + + access(all) attachment A for S { + access(X) fun foo() { + self as! auth(X, Y) &A + } + } + + fun test() { + let s = attach A() to S() + let ref = &s as auth(X, Y) &S + ref[A]!.foo() + } + `, "test") + RequireError(t, err) + + 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/bbq/vm/value_array.go b/bbq/vm/value_array.go index f9a3cbee49..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, @@ -65,20 +64,14 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeReverseFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - arrayType := arrayTypeFromValue(receiver, context) - return sema.ArrayReverseFunctionType(arrayType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + arrayType := arrayTypeFromSemaType(accessedType) + return sema.ArrayReverseFunctionType(context, accessedType, arrayType) }, 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( @@ -117,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( @@ -150,11 +143,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeConcatFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - arrayType := arrayTypeFromValue(receiver, context) - return sema.ArrayConcatFunctionType(arrayType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + arrayType := arrayTypeFromSemaType(accessedType) + return sema.ArrayConcatFunctionType(context, accessedType, arrayType) }, interpreter.NativeArrayConcatFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -174,11 +168,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArrayRemoveFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -186,11 +181,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveFirstFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveFirstFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArrayRemoveFirstFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveFirstFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -198,11 +194,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeRemoveLastFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayRemoveLastFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArrayRemoveLastFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayRemoveLastFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -210,11 +207,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeSliceFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArraySliceFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArraySliceFunctionType(context, accessedType, elementType) }, interpreter.NativeArraySliceFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -222,15 +220,16 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeToConstantSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayToConstantSizedFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArrayToConstantSizedFunctionType(context, accessedType, elementType) }, interpreter.NativeArrayToConstantSizedFunction, - ), + ).WithDereferenceReceiver(false), ) // Methods available only for constant-sized arrays - // and references to them. + typeQualifier = commons.TypeQualifierArrayConstantSized registerBuiltinTypeBoundFunction( @@ -238,11 +237,16 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.ArrayTypeToVariableSizedFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - elementType := arrayElementTypeFromValue(receiver, context) - return sema.ArrayToVariableSizedFunctionType(elementType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + elementType := arrayTypeFromSemaType(accessedType).ElementType(false) + return sema.ArrayToVariableSizedFunctionType( + context, + accessedType, + elementType, + ) }, interpreter.NativeArrayToVariableSizedFunction, - ), + ).WithDereferenceReceiver(false), ) } diff --git a/bbq/vm/value_dictionary.go b/bbq/vm/value_dictionary.go index 139fc2c477..e438712d13 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" ) @@ -33,11 +34,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeRemoveFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - dictionaryType := dictionaryType(receiver, context) - return sema.DictionaryRemoveFunctionType(dictionaryType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + dictionaryType := dictionaryTypeFromSemaType(accessedType) + return sema.DictionaryRemoveFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryRemoveFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -45,11 +47,12 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeInsertFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - dictionaryType := dictionaryType(receiver, context) - return sema.DictionaryInsertFunctionType(dictionaryType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + dictionaryType := dictionaryTypeFromSemaType(accessedType) + return sema.DictionaryInsertFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryInsertFunction, - ), + ).WithDereferenceReceiver(false), ) registerBuiltinTypeBoundFunction( @@ -69,17 +72,27 @@ func init() { NewNativeFunctionValueWithDerivedType( sema.DictionaryTypeForEachKeyFunctionName, func(receiver Value, context interpreter.ValueStaticTypeContext) *sema.FunctionType { - dictionaryValue := receiver.(*interpreter.DictionaryValue) - dictionaryType := dictionaryValue.SemaType(context) - return sema.DictionaryForEachKeyFunctionType(dictionaryType) + accessedType := context.SemaTypeFromStaticType(receiver.StaticType(context)) + dictionaryType := dictionaryTypeFromSemaType(accessedType) + return sema.DictionaryForEachKeyFunctionType(context, accessedType, dictionaryType) }, interpreter.NativeDictionaryForEachKeyFunction, - ), + ).WithDereferenceReceiver(false), ) } 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 { + switch accessedType := accessedType.(type) { + case *sema.DictionaryType: + return accessedType + case *sema.ReferenceType: + return dictionaryTypeFromSemaType(accessedType.Type) + default: + panic(errors.NewUnreachableError()) + } } 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 24ab1a845c..27298ff584 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 } @@ -675,8 +675,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) { @@ -1055,17 +1073,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, @@ -1542,6 +1564,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) @@ -1927,8 +1951,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) @@ -2297,7 +2328,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/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/cmd/decode-state-values/main.go b/cmd/decode-state-values/main.go index 706e50c4e5..3c109627cf 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/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/go.mod b/go.mod index 7f261b5321..140a215acd 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,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 diff --git a/go.sum b/go.sum index 28dbf96f6e..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 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 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/interpreter/account_test.go b/interpreter/account_test.go index 3a969ae0b5..249a035938 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/arithmetic_test.go b/interpreter/arithmetic_test.go index 8cc773be25..cb4aed5d38 100644 --- a/interpreter/arithmetic_test.go +++ b/interpreter/arithmetic_test.go @@ -1031,3 +1031,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/array_test.go b/interpreter/array_test.go index 758721b85e..32d65d1a56 100644 --- a/interpreter/array_test.go +++ b/interpreter/array_test.go @@ -19,11 +19,20 @@ 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/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" ) func TestInterpretArrayFunctionEntitlements(t *testing.T) { @@ -155,3 +164,1164 @@ func TestCheckArrayReferenceTypeInferenceWithDowncasting(t *testing.T) { var forceCastTypeMismatchError *interpreter.ForceCastTypeMismatchError require.ErrorAs(t, err, &forceCastTypeMismatchError) } + +// TestInterpretArrayValueIDTracking is the ArrayValue counterpart to +// 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() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + // liveValueIDOf exposes the underlying atree array's current value ID so + // the Cadence code can confirm the slab split actually occurred. + // 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: "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(liveValueIDOfFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) + 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() { + // 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 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 see the same live atree value ID. + assert( + liveValueIDOf(ref) == liveValueIDOf(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + // 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 + } + + // 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), + message: "after split: refs must still observe the same live atree value ID" + ) + + // 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, including immortalRef. + var empty: @[Vault] <- [] + var extracted <- outer[0] <- empty + destroy extracted + + // 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 + } + `, + 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 + require.ErrorAs(t, err, &invalidatedResourceReferenceError) + assert.Equal(t, 54, invalidatedResourceReferenceError.StartPosition().Line) +} + +// 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 := 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 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 := 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 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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") + }) + } +} + +// 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 +// 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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) +} + +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 +// 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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) +} + +// 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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) +} diff --git a/interpreter/attachments_test.go b/interpreter/attachments_test.go index 0287f890ef..6e679a8047 100644 --- a/interpreter/attachments_test.go +++ b/interpreter/attachments_test.go @@ -2879,3 +2879,106 @@ func TestInterpretAttachmentBaseUnauthorizedInInit(t *testing.T) { require.ErrorAs(t, err, &forceCastTypeMismatchErr) }) } + +func TestInterpretAttachmentSelfAuth(t *testing.T) { + t.Parallel() + + t.Run("narrowed to function access", func(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() { + self as! auth(X, Y) &A + } + } + + 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 + } + } + + 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) + }) +} diff --git a/interpreter/composite_value_test.go b/interpreter/composite_value_test.go index 9c4991e7cc..9b1dd6b773 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" @@ -410,3 +411,592 @@ func TestInterpretSimpleCompositeTypeFunctionMember(t *testing.T) { require.ErrorAs(t, err, &dereferenceError) }) } + +// 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; +// reference invalidation tracking surfaces it as `InvalidatedResourceReferenceError` +// at the second use of `immortalRef`. +func TestInterpretCompositeValueIDTracking(t *testing.T) { + t.Parallel() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + // 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 a reference, generalized through `&AnyResource`. + 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, + }, + ), + }, + }, + sema.StringTypeAnnotation, + ), + "", + func( + _ 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(liveValueIDOfFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + inter, err := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 + + // The shared-state cache (ConvertStoredValue) deduplicates the Cadence + // wrappers, so both refs hold the same CompositeValue and observe the + // same underlying atree map. + assert( + liveValueIDOf(ref) == liveValueIDOf(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 + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + // 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), + message: "after split: refs must still observe the same live atree value ID" + ) + + // 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: all three are tracked under the same value ID. + var extracted <- arr[0] <- empty + + stash.deposit(from: <- extracted) + // 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 + 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) + + // 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 + require.ErrorAs(t, err, &invalidatedResourceReferenceError) + assert.Equal(t, 130, invalidatedResourceReferenceError.StartPosition().Line) +} + +// 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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/container_mutation_test.go b/interpreter/container_mutation_test.go index cf0c4151eb..64257bb99b 100644 --- a/interpreter/container_mutation_test.go +++ b/interpreter/container_mutation_test.go @@ -1260,3 +1260,718 @@ func TestInterpretInnerContainerMutationWhileIteratingOuter(t *testing.T) { assert.Equal(t, interpreter.NewUnmeteredStringValue("foo"), val) }) } + +// 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() + + 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), + ) + + _, 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() + + 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), + ) + + _, err := inter.InvokeUncheckedForTestingOnly("test", confusedArray) //nolint:staticcheck + RequireError(t, err) + + 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) { + + t.Parallel() + + t.Run("variable-sized 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("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() + + 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() + } + `, + }, + + // 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, + 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) + }) + } +} + +// 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) + }) +} diff --git a/interpreter/dictionary_test.go b/interpreter/dictionary_test.go index 6f371fa278..970ab94748 100644 --- a/interpreter/dictionary_test.go +++ b/interpreter/dictionary_test.go @@ -19,9 +19,20 @@ 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/common_utils" + . "github.com/onflow/cadence/test_utils/sema_utils" ) func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { @@ -112,3 +123,254 @@ func TestInterpretDictionaryFunctionEntitlements(t *testing.T) { require.NoError(t, err) }) } + +// TestInterpretDictionaryValueIDTracking is the DictionaryValue counterpart to +// 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() + + logFunction := stdlib.NewInterpreterLogFunction(stdlib.FunctionLogger(func(message string) error { + fmt.Fprintln(os.Stderr, message) + return nil + })) + + // liveValueIDOf exposes the underlying atree map's current value ID so the + // 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: "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(liveValueIDOfFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, logFunction) + interpreter.Declare(baseActivation, liveValueIDOfFunction) + 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() { + // 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 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 see the same live atree value ID. + assert( + liveValueIDOf(ref) == liveValueIDOf(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + // 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))) + destroy old + i = i + 1 + } + + // 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), + message: "after split: refs must still observe the same live atree value ID" + ) + + // 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 would + // invalidate a properly-tracked immortalRef. + var empty: @{String: Vault} <- {} + var extracted <- outer["a"] <- empty + destroy extracted + + // The exploit aims for this access to succeed. If the runtime defends + // correctly, execution never reaches this line. + 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 + require.ErrorAs(t, err, &invalidatedResourceReferenceError) + assert.Equal(t, 53, invalidatedResourceReferenceError.StartPosition().Line) +} + +// 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 := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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/domain_storagemap.go b/interpreter/domain_storagemap.go index 7fe66815f6..a632940dce 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` 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( - gauge, + context, common.ComputationUsage{ Kind: common.ComputationKindAtreeMapGet, Intensity: 1, @@ -187,7 +189,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(context, storedValue) } // WriteValue sets or removes a value in the storage map. @@ -229,6 +236,9 @@ func (s *DomainStorageMap) SetValue(context ValueTransferContext, key StorageMap existed = existingStorable != nil if existed { existingValue := StoredValue(context, existingStorable, context.Storage()) + // 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) } @@ -281,6 +291,9 @@ func (s *DomainStorageMap) RemoveValue(context ValueRemoveContext, key StorageMa existed = existingValueStorable != nil if existed { existingValue := StoredValue(context, existingValueStorable, context.Storage()) + // 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 34a4ce2990..969a9b0d2c 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" ) @@ -132,6 +133,8 @@ func TestDomainStorageMapReadValue(t *testing.T) { runtime.StorageConfig{}, ) + inter := NewTestInterpreterWithStorage(t, storage) + domainStorageMap := interpreter.NewDomainStorageMap( nil, nil, @@ -142,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() @@ -178,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) @@ -192,7 +195,7 @@ func TestDomainStorageMapReadValue(t *testing.T) { continue } - value := domainStorageMap.ReadValue(nil, key) + value := domainStorageMap.ReadValue(inter, key) require.Nil(t, value) } @@ -875,3 +878,197 @@ 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", + ) +} + +// 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. +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", + ) +} diff --git a/interpreter/dynamic_casting_test.go b/interpreter/dynamic_casting_test.go index e18904e6d5..eaa78bda74 100644 --- a/interpreter/dynamic_casting_test.go +++ b/interpreter/dynamic_casting_test.go @@ -4511,6 +4511,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() @@ -4565,4 +4570,191 @@ 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) + }) + + // 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(), + ) + }) } diff --git a/interpreter/errors.go b/interpreter/errors.go index 1277128b51..6dbc2c32aa 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, ) } @@ -400,6 +404,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/indexing_test.go b/interpreter/indexing_test.go index 78ca8ba6a6..02b47e9613 100644 --- a/interpreter/indexing_test.go +++ b/interpreter/indexing_test.go @@ -22,13 +22,16 @@ import ( "encoding/binary" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/onflow/atree" "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 @@ -231,6 +234,343 @@ 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) + }) +} + +// 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/interpreter/interface.go b/interpreter/interface.go index 90483a106f..e458fb58f8 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 @@ -569,6 +570,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.go b/interpreter/interpreter.go index c45fde8898..18b751a902 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -945,6 +945,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 @@ -965,6 +966,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 { @@ -2161,27 +2179,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 @@ -3133,6 +3150,7 @@ func (interpreter *Interpreter) functionConditionsWrapper( body, rewrittenPostConditions, functionType.ReturnTypeAnnotation.Type, + functionType.IsConstructor, ) }, ), @@ -6553,6 +6571,42 @@ 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 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) +} + +// 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/interpreter_expression.go b/interpreter/interpreter_expression.go index 440285892c..8781922b6f 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,19 @@ func (interpreter *Interpreter) evalExpression(expression ast.Expression) Value return result } -func CheckInvalidatedResourceOrResourceReference( +// CheckInvalidatedValueOrValueReference checks whether a value is either: +// - 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, ) { @@ -521,12 +533,25 @@ 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 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 { @@ -1282,6 +1307,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 { @@ -1576,7 +1616,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_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_statement.go b/interpreter/interpreter_statement.go index f0153404e5..c2a4d04b6b 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 @@ -268,26 +279,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() } @@ -338,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. @@ -347,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) @@ -501,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) @@ -512,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 } @@ -702,13 +711,23 @@ 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) + // 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/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/member_test.go b/interpreter/member_test.go index 49bbe03508..214031caf7 100644 --- a/interpreter/member_test.go +++ b/interpreter/member_test.go @@ -1282,29 +1282,6 @@ func TestInterpretMemberAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("anystruct swap on reference", 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("entitlement map access on field", func(t *testing.T) { t.Parallel() diff --git a/interpreter/misc_test.go b/interpreter/misc_test.go index d9fd2a6905..e194987d09 100644 --- a/interpreter/misc_test.go +++ b/interpreter/misc_test.go @@ -12506,6 +12506,741 @@ 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) + } + + // 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() + + // 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 }) + } + `) + }) + + // 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) { + + 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) + } + `) + }) + + // 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) + } + `) + }) + + 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() + } + `) + }) + + // 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 + } + `) + }) + + // 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 + } + `) + }) + }) +} + func TestInterpretCastingBoxing(t *testing.T) { t.Parallel() diff --git a/interpreter/sharedstate.go b/interpreter/sharedstate.go index f5a5c9f52c..3f88586380 100644 --- a/interpreter/sharedstate.go +++ b/interpreter/sharedstate.go @@ -45,6 +45,35 @@ 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 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: + // + // - 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 } func NewSharedState(config *Config) *SharedState { @@ -63,6 +92,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/stale_atree_view_test.go b/interpreter/stale_atree_view_test.go new file mode 100644 index 0000000000..f5f9717f13 --- /dev/null +++ b/interpreter/stale_atree_view_test.go @@ -0,0 +1,1507 @@ +/* + * 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 ( + "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/sema_utils" +) + +// 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() + + // 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: "ref", + TypeAnnotation: sema.NewTypeAnnotation(refType), + }, + }, + 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) + }, + ) + } + + 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, + }, + ) + + // liveInlinedOf{Resource,Struct} expose whether the underlying atree + // container of a reference 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. + // Two functions are needed for the same reason as the value-ID helpers: + // resource and struct references live in disjoint type hierarchies. + makeLiveInlinedOfFunction := func( + name string, + refType sema.Type, + ) stdlib.StandardLibraryValue { + return stdlib.NewInterpreterStandardLibraryStaticFunction( + name, + sema.NewSimpleFunctionType( + sema.FunctionPurityImpure, + []sema.Parameter{ + { + Label: sema.ArgumentLabelNotRequired, + Identifier: "ref", + TypeAnnotation: sema.NewTypeAnnotation(refType), + }, + }, + sema.BoolTypeAnnotation, + ), + "", + func( + _ interpreter.NativeFunctionContext, + _ interpreter.TypeArgumentsIterator, + _ interpreter.ArgumentTypesIterator, + _ interpreter.Value, + args []interpreter.Value, + ) interpreter.Value { + ref := args[0].(*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) + }, + ) + } + + liveInlinedOfResourceFunction := makeLiveInlinedOfFunction( + "liveInlinedOfResource", + &sema.ReferenceType{ + Type: sema.AnyResourceType, + Authorization: sema.UnauthorizedAccess, + }, + ) + liveInlinedOfStructFunction := makeLiveInlinedOfFunction( + "liveInlinedOfStruct", + &sema.ReferenceType{ + Type: sema.AnyStructType, + Authorization: sema.UnauthorizedAccess, + }, + ) + + baseValueActivation := sema.NewVariableActivation(sema.BaseValueActivation) + baseValueActivation.DeclareValue(liveValueIDOfResourceFunction) + baseValueActivation.DeclareValue(liveValueIDOfStructFunction) + baseValueActivation.DeclareValue(liveInlinedOfResourceFunction) + baseValueActivation.DeclareValue(liveInlinedOfStructFunction) + baseValueActivation.DeclareValue(stdlib.InterpreterAssertFunction) + + baseActivation := activations.NewActivation(nil, interpreter.BaseActivation) + interpreter.Declare(baseActivation, liveValueIDOfResourceFunction) + interpreter.Declare(baseActivation, liveValueIDOfStructFunction) + interpreter.Declare(baseActivation, liveInlinedOfResourceFunction) + interpreter.Declare(baseActivation, liveInlinedOfStructFunction) + interpreter.Declare(baseActivation, stdlib.InterpreterAssertFunction) + + return baseValueActivation, baseActivation + } + + // 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) + invokable, err := parseCheckAndPrepareWithAtreeValidationsDisabled( + 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 + }, + }, + HandleCheckerError: handleCheckerError, + }, + ) + require.NoError(t, err) + _, err = invokable.Invoke("main") + return err + } + runInvoke := func(t *testing.T, code string) error { + return runInvokeWithHandleCheckerError(t, code, nil) + } + + t.Run("ArrayValue: append via sibling wrapper after split propagates", func(t *testing.T) { + t.Parallel() + + // Two `auth(Mutate) &[Vault]` references project onto the same inner array. + // `ref` appends enough elements to trigger an atree slab split. + // 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 + 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( + liveValueIDOfResource(ref) == liveValueIDOfResource(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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split: refs must still observe the same live atree value ID" + ) + + // 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 + } + `) + require.NoError(t, err) + }) + + t.Run("ArrayValue: silent-corruption reproducer is now blocked", func(t *testing.T) { + t.Parallel() + + // Pre-canonicalization silent-corruption reproducer: + // the sibling `ref2.append` would park the new element on the + // demoted leaf slab of its stale Go-level *atree.Array. + // The canonical view's iteration would not see the parked element, + // but `removeLast` in reverse order would surface it — meaning + // the inner array had divergent length depending on which path + // you read it through. + // With canonicalization, `ref` and `ref2` resolve to the same Go + // wrapper, so `ref2.append` lands on the live tree and both + // traversals see the appended element. The post-append forensics + // (iteratedCount/removalCount agreement, element visibility + // agreement) now pass. + 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( + liveValueIDOfResource(ref) == liveValueIDOfResource(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 sibling ref2 lands on the canonical wrapper. + ref2.append(<- create Vault(balance: 123.456)) + + // Forensics: pre-canonicalization, a stale ref2.append would + // park the element on a demoted leaf, causing the iterator's + // count to diverge from the reverse-removeLast count, and the + // appended element to be visible to only one of the two walks. + // With canonicalization both walks agree. + 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, + message: "iteration must agree with reverse-removeLast count") + assert(elementFoundIterating == elementFoundRemoving, + message: "the appended element must be visible to both walks") + assert(iteratedCount == 202, + message: "1 initial + 200 + 1 appended via ref2") + assert(elementFoundIterating, + message: "ref2's append must be observable through the canonical wrapper") + destroy extracted + destroy outer + } + `) + require.NoError(t, err) + }) + + t.Run("ArrayValue: insert via sibling wrapper after split propagates", 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] + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split: refs must still observe the same live atree value ID" + ) + + 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 + } + `) + require.NoError(t, err) + }) + + t.Run("ArrayValue: remove via sibling wrapper after split propagates", 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] + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + 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 + } + `) + require.NoError(t, err) + }) + + t.Run("DictionaryValue: insert via sibling wrapper after split propagates", 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} + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(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))) + assert(old == nil, message: "dict insert should not collide") + destroy old + i = i + 1 + } + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split: refs must still observe the same live atree value ID" + ) + + 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 + } + `) + require.NoError(t, err) + }) + + t.Run("CompositeValue: field assignment via sibling wrapper after split propagates", func(t *testing.T) { + t.Parallel() + + // 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. 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 { + 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 + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref[A1]!.inflate() + ref[A2]!.inflate() + ref[A3]!.inflate() + ref[A4]!.inflate() + ref[A5]!.inflate() + ref[A6]!.inflate() + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split: refs must still observe the same live atree value ID" + ) + + // 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 + } + `) + require.NoError(t, err) + }) + + t.Run("DictionaryValue: remove via sibling wrapper after split propagates", 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} + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(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))) + destroy old + i = i + 1 + } + + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split: refs must still observe the same live atree value ID" + ) + + 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 + } + `) + require.NoError(t, err) + }) + + // 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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split, before promote: refs share the same live atree value ID" + ) + + // 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 + } + + // After promote, ValueID is preserved (atree assigns the new + // root the prior root's SlabID), so the canonical wrapper + // returns the same ID for both refs. + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "ValueID is preserved across promote" + ) + + // Mutation through ref2 lands on the canonical wrapper. + ref2.append(<- create Vault(balance: 123.456)) + + // After all 200 removeLast + 1 ref2.append: 1 (original) + 1 = 2. + assert(ref.length == 2, + message: "ref must observe ref2's post-promote append") + assert(ref[1].balance == 123.456, + message: "ref must read back the value appended via ref2") + + destroy outer + } + `) + require.NoError(t, err) + }) + + 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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "after split, before promote: refs share the same live atree value ID" + ) + + // 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( + liveValueIDOfResource(ref) == liveValueIDOfResource(ref2), + message: "ValueID is preserved across promote" + ) + + let old <- ref2.insert(key: 9999, <- create Vault(balance: 123.456)) + assert(old == nil, message: "key 9999 should not have collided") + destroy old + + // After 1 (original) + 299 inserts + 300 removals + 1 ref2.insert: 1. + assert(ref.length == 1, + message: "ref must observe ref2's post-promote insert") + assert(ref.containsKey(9999), + message: "ref must see the key inserted via ref2") + + destroy outer + } + `) + require.NoError(t, err) + }) + + t.Run("ArrayValue: map procedure mutates sibling wrapper into split mid-iteration", func(t *testing.T) { + t.Parallel() + + // 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. + // + // 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. + 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( + liveValueIDOfStruct(ref1) == liveValueIDOfStruct(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( + liveValueIDOfStruct(ref1) == liveValueIDOfStruct(ref2), + message: "after callback-induced split: refs must still observe the same live atree value ID" + ) + + // 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 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) + }) + + // 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. + 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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(ref2), + message: "after split: ValueID is preserved by atree, and both refs share the canonical wrapper" + ) + + // ref2 resolves to the same canonical wrapper as ref1. + // The attachment lookup and method call succeed. + let stashed = ref2[B]!.readBaseBalance() + assert(stashed == 42.0, + message: "method call through sibling wrapper observes live state") + + destroy arr + } + `) + require.NoError(t, err) + }) + + 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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(ref2), + message: "after split: ValueID is preserved by atree, and both refs share the canonical wrapper" + ) + + // bRef's base resolves to the canonical R wrapper; the method + // body reads its live balance. + let stashed = bRef.readBaseBalance() + assert(stashed == 42.0, + message: "captured attachment ref observes the canonical parent's live state") + + destroy arr + } + `) + require.NoError(t, err) + }) + + // 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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(ref2), + message: "after split: ValueID is preserved by atree, and both refs share the canonical wrapper" + ) + + // 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 removeFirst lands on the canonical wrapper, so the + // next element (the first one ref1 appended, balance 10.0) + // is removed cleanly — no phantom of the already-destroyed vault. + let next <- ref2.removeFirst() + assert(next.balance == 10.0, + message: "ref2 must see ref1's removeFirst, no phantom resurrection") + destroy next + + destroy outer + } + `) + require.NoError(t, err) + }) + + 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. + // + // 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( + liveInlinedOfResource(ref1), + message: "precondition: inner[0] should start inlined inside outer's slab" + ) + assert( + liveValueIDOfResource(ref1) == liveValueIDOfResource(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 + } + + // Confirm uninlining happened. + assert( + !liveInlinedOfResource(ref1), + message: "expected inner[0] to be uninlined after growth via ref1" + ) + + // Append through the sibling. With canonicalization, ref1 + // and ref2 share the canonical wrapper; the append lands on + // the live tree post-uninline. + ref2.append(<-create Vault(balance: 999.0)) + + // 1 (original) + 200 + 1 = 202. + assert(ref1.length == 202, + message: "ref1 must observe ref2's append after uninline") + assert(ref1[201].balance == 999.0, + message: "ref1 must read back the value appended via ref2") + + destroy outer + } + `) + require.NoError(t, err) + }) + + 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 callback mutates the parent + // indirectly through `ref2` by inflating each attachment, which can + // uninline an attachment slab and so restructure the parent dictionary. + // + // With canonicalization, `ref1` and `ref2` resolve to the same + // canonical wrapper. forEachAttachment re-checks the composite + // reference at the head of every iteration via + // CheckInvalidatedValueOrValueReference; with that check intact and + // both refs sharing one wrapper, the mutations land on the live tree + // and iteration completes safely. The post-iteration probe then + // asserts the inflates were observable through the canonical wrapper. + 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( + liveValueIDOfResource(ref1) == liveValueIDOfResource(ref2), + message: "before split: both refs should observe the same live atree value ID" + ) + + ref1.forEachAttachment(fun (a: &AnyResourceAttachment): Void { + ref2[A1]!.inflate() + ref2[A2]!.inflate() + ref2[A3]!.inflate() + ref2[A4]!.inflate() + ref2[A5]!.inflate() + ref2[A6]!.inflate() + }) + + // The inflates landed on the canonical wrapper; ref1 reads + // back the post-inflation state through the same wrapper. + assert( + ref1[A1]!.a1 == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + message: "ref1 must observe ref2's A1 inflate" + ) + assert( + ref1[A6]!.h1 == "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh", + message: "ref1 must observe ref2's A6 inflate" + ) + + destroy arr + } + `) + require.NoError(t, err) + }) + + 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( + liveInlinedOfResource(ref), + message: "precondition: inner[0] should start inlined" + ) + assert( + liveValueIDOfResource(ref) == liveValueIDOfResource(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( + !liveInlinedOfResource(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( + liveInlinedOfResource(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( + liveInlinedOfResource(ref1), + message: "precondition: inner[0] should start inlined" + ) + assert( + liveValueIDOfResource(ref1) == liveValueIDOfResource(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 liveInlinedOfResource(ref1) && i < 1000 { + ref1.append(<-create Vault(balance: UFix64(i))) + i = i + 1 + } + assert( + !liveInlinedOfResource(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) + }) +} diff --git a/interpreter/storage.go b/interpreter/storage.go index 2e18888732..7555dba5fc 100644 --- a/interpreter/storage.go +++ b/interpreter/storage.go @@ -59,6 +59,140 @@ 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) +} + +// ContainerElementContext is the typed parameter for reading and +// canonicalizing an atree container element: +// memory and computation metering, plus access to the canonical wrapper cache. +// Must be non-nil — canonicalization is load-bearing for correctness, see +// `MustConvertStoredContainerElement`. +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: +// 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 +} + +// 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 { + 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. +// +// 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 +// (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 { + 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 + } + 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.ClearCanonicalAtreeContainer(valueID) + } + cache.SetCanonicalAtreeContainer(valueID, fresh) + 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 +// 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 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. +// +// `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) + return canonicalizeContainerElement(context, 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/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/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/interpreter/switch_test.go b/interpreter/switch_test.go index f532897b55..7c4a36fc2d 100644 --- a/interpreter/switch_test.go +++ b/interpreter/switch_test.go @@ -390,3 +390,150 @@ func TestInterpretSwitchStatementControlFlowInLoop(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, + ) + }) + + 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, + ) + }) +} 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/unreachable_test.go b/interpreter/unreachable_test.go new file mode 100644 index 0000000000..f0928c01c6 --- /dev/null +++ b/interpreter/unreachable_test.go @@ -0,0 +1,436 @@ +/* + * 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/activations" + "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/stdlib" + . "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) +} + +// 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: +// 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) +} diff --git a/interpreter/value.go b/interpreter/value.go index d12d6af4dc..79e3269bae 100644 --- a/interpreter/value.go +++ b/interpreter/value.go @@ -283,13 +283,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 de8e5ee684..880a2357b1 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,12 +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 { @@ -295,9 +304,27 @@ 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) + // convert to high-level interpreter.Value. + // + // 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 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) + } else { + elementValue = MustConvertStoredContainerElement(context, element) + } + CheckInvalidatedValueOrValueReference(elementValue, context) if transferElements { // Each element must be transferred before passing onto the function. @@ -414,6 +441,8 @@ func (v *ArrayValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + context.ClearCanonicalAtreeContainer(valueID) + v.array = nil } @@ -421,24 +450,73 @@ func (v *ArrayValue) IsDestroyed() bool { return v.isDestroyed } -func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Value { +// 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 + } + 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, + accessedType sema.Type, +) Value { first := true - // Use ReadOnlyIterator here because new ArrayValue is created with elements copied (not removed) from original value. - firstIterator, err := v.array.ReadOnlyIterator() + // `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. + resultElementSemaType := v.SemaType(context).ElementType(false) + resultElementSemaType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, resultElementSemaType, false) + resultElementStaticType := ConvertSemaToStaticType(context, resultElementSemaType) + + firstIterator, err := v.iterator(asReference) 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() + secondIterator, err := other.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } - elementType := v.Type.ElementType() - newCount := v.array.Count() + other.array.Count() common.UseComputation( @@ -451,7 +529,7 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val return NewArrayValueWithIterator( context, - v.Type, + NewVariableSizedStaticType(context, resultElementStaticType), common.ZeroAddress, newCount, func() Value { @@ -468,6 +546,8 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val if atreeValue == nil { first = false + } else if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) } else { value = MustConvertStoredValue(context, atreeValue) } @@ -482,9 +562,13 @@ func (v *ArrayValue) Concat(context ValueTransferContext, other *ArrayValue) Val } if atreeValue != nil { - value = MustConvertStoredValue(context, atreeValue) + if asReference { + value = MustConvertStoredContainerElement(context, atreeValue) + } else { + value = MustConvertStoredValue(context, atreeValue) + } - checkContainerMutation(context, elementType, value) + checkContainerMutation(context, otherElementType, value) } } @@ -492,6 +576,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{}, @@ -546,9 +634,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) { @@ -968,6 +1054,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( @@ -997,10 +1090,17 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayConcatFunctionType( + context, + accessedType, 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( @@ -1019,10 +1119,22 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveFunction, - ) + ). + // 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: return NewBoundHostFunctionValue( @@ -1030,10 +1142,22 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveFirstFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveFirstFunction, - ) + ). + // 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: return NewBoundHostFunctionValue( @@ -1041,10 +1165,22 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayRemoveLastFunctionType( + context, + accessedType, arrayType.ElementType(false), ), NativeArrayRemoveLastFunction, - ) + ). + // 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: return NewBoundHostFunctionValue( @@ -1074,10 +1210,17 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArraySliceFunctionType( + context, + accessedType, 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( @@ -1085,19 +1228,19 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayReverseFunctionType( + context, + accessedType, 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: - var accessedType sema.Type - if accessedReference != nil { - accessedType = MustSemaTypeOfValue(accessedReference, context) - } else { - accessedType = arrayType - } - return NewBoundHostFunctionValue( context, v, @@ -1114,13 +1257,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, @@ -1142,10 +1278,18 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayToVariableSizedFunctionType( + context, + accessedType, 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( @@ -1153,10 +1297,18 @@ func (v *ArrayValue) GetMethod( v, accessedReference, sema.ArrayToConstantSizedFunctionType( + context, + accessedType, 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 @@ -1504,6 +1656,21 @@ func (v *ArrayValue) Transfer( InvalidateReferencedResources(context, v) + 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. 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. + // + // 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 } @@ -1582,6 +1749,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 @@ -1618,9 +1790,45 @@ func (v *ArrayValue) StorageAddress() atree.Address { } func (v *ArrayValue) ValueID() atree.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 +} + +// 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() } +// 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() +} + func (v *ArrayValue) GetOwner() common.Address { return common.Address(v.StorageAddress()) } @@ -1649,6 +1857,7 @@ func (v *ArrayValue) Slice( context ArrayCreationContext, from IntValue, to IntValue, + accessedType sema.Type, ) Value { fromIndex := from.ToInt() toIndex := to.ToInt() @@ -1664,8 +1873,16 @@ 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) + + iterator, err := v.rangeIterator(uint64(fromIndex), uint64(toIndex), asReference) if err != nil { var sliceOutOfBoundsError *atree.SliceOutOfBoundsError @@ -1700,7 +1917,7 @@ func (v *ArrayValue) Slice( return NewArrayValueWithIterator( context, - NewVariableSizedStaticType(context, v.Type.ElementType()), + NewVariableSizedStaticType(context, resultElementStaticType), common.ZeroAddress, newCount, func() Value { @@ -1714,13 +1931,21 @@ 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 { return nil } + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -1735,13 +1960,32 @@ 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. + 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. + 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 { @@ -1752,6 +1996,10 @@ func (v *ArrayValue) Reverse( value := v.Get(context, index) index-- + if asReference { + value = getReferenceValue(context, value, elementType) + } + return value.Transfer( context, atree.Address{}, @@ -1772,16 +2020,7 @@ func (v *ArrayValue) Filter( elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, _ := sema.MaybeReferenceType(accessedType) - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) argumentType := elementType argumentTypes := []sema.Type{argumentType} @@ -1797,81 +2036,95 @@ 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 - } + // 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 + } - 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( @@ -1883,16 +2136,7 @@ func (v *ArrayValue) Map( elementType := v.SemaType(context).ElementType(false) - asReference := sema.ShouldReturnReference(accessedType, elementType, false) - if asReference { - outerRef, _ := sema.MaybeReferenceType(accessedType) - elementType = sema.GetDescendantReferenceType( - context, - elementType, - sema.UnauthorizedAccess, - outerRef.Authorization, - ) - } + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) argumentType := elementType argumentTypes := []sema.Type{argumentType} @@ -1934,53 +2178,70 @@ 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( + // 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, + 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( @@ -1994,25 +2255,26 @@ 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. + elementType := v.SemaType(context).ElementType(false) + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) + resultElementStaticType := ConvertSemaToStaticType(context, elementType) + variableSizedType := NewVariableSizedStaticType(context, resultElementStaticType) // 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() + iterator, err := v.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } @@ -2043,7 +2305,16 @@ 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) + } return value.Transfer( context, @@ -2060,6 +2331,7 @@ func (v *ArrayValue) ToVariableSized( func (v *ArrayValue) ToConstantSized( context ArrayCreationContext, expectedConstantSizedArraySize int64, + accessedType sema.Type, ) OptionalValue { // Ensure the array has the expected size. @@ -2072,21 +2344,24 @@ 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. + elementType := v.SemaType(context).ElementType(false) + elementType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, elementType, false) + resultElementStaticType := ConvertSemaToStaticType(context, elementType) constantSizedType := NewConstantSizedStaticType( context, - variableSizedType.Type, + resultElementStaticType, expectedConstantSizedArraySize, ) // 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() + iterator, err := v.iterator(asReference) if err != nil { panic(errors.NewExternalError(err)) } @@ -2117,7 +2392,16 @@ 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) + } return value.Transfer( context, @@ -2233,9 +2517,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) { @@ -2283,10 +2568,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) }, ) @@ -2315,7 +2601,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()) @@ -2345,11 +2631,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) }, ) @@ -2361,8 +2648,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) }, ) @@ -2426,9 +2714,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) }, ) @@ -2440,13 +2729,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) }, ) @@ -2473,7 +2763,7 @@ var NativeArrayRemoveFirstFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) return thisArray.RemoveFirst(context) }, @@ -2487,7 +2777,7 @@ var NativeArrayRemoveLastFunction = NativeFunction( receiver Value, _ []Value, ) Value { - thisArray := AssertValueOfType[*ArrayValue](receiver) + thisArray := arrayValueFromReceiver(context, receiver) return thisArray.RemoveLast(context) }, diff --git a/interpreter/value_composite.go b/interpreter/value_composite.go index 3c9391d145..40a134ece7 100644 --- a/interpreter/value_composite.go +++ b/interpreter/value_composite.go @@ -62,6 +62,15 @@ type CompositeValue struct { QualifiedIdentifier string Kind common.CompositeKind 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.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 } type ComputedField func(ValueTransferContext, *CompositeValue) Value @@ -250,6 +259,7 @@ func NewCompositeValueFromAtreeMap( return &CompositeValue{ dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), Location: typeInfo.Location, QualifiedIdentifier: typeInfo.QualifiedIdentifier, Kind: typeInfo.Kind, @@ -264,12 +274,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 { @@ -352,6 +361,19 @@ func (v *CompositeValue) IsDestroyed() bool { return v.isDestroyed } +// 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 + } + return v.dictionary +} + func resourceDefaultDestroyEventName(t sema.ContainerType) string { return resourceDefaultDestroyEventPrefix + string(t.ID()) } @@ -450,6 +472,8 @@ func (v *CompositeValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + context.ClearCanonicalAtreeContainer(valueID) + v.dictionary = nil } @@ -683,6 +707,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. @@ -960,7 +995,13 @@ func formatComposite( return format.Composite(typeId, preparedFields) } -func (v *CompositeValue) GetField(gauge common.Gauge, 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( gauge, @@ -986,6 +1027,14 @@ func (v *CompositeValue) GetField(gauge common.Gauge, name string) Value { 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 { otherComposite, ok := other.(*CompositeValue) if !ok { @@ -1042,7 +1091,17 @@ 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 WITHOUT canonicalization: + // `GetField` requires a `ContainerElementContext` + // (so it can canonicalize container-element wrappers), + // but enum raw values are primitives, + // so canonicalization would be a no-op and the wider context is unnecessary. + // The `HashableValue.HashInput` contract only requires `common.Gauge`. + rawValue := v.getFieldWithoutCanonicalization(gauge, sema.EnumRawValueFieldName) + if rawValue == nil { + // Enums always have a raw value field + panic(errors.NewUnreachableError()) + } rawValueHashInput := rawValue.(HashableValue). HashInput(gauge, scratch) @@ -1532,6 +1591,21 @@ func (v *CompositeValue) Transfer( InvalidateReferencedResources(context, v) + 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. 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. + // + // 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 } @@ -1620,6 +1694,7 @@ func (v *CompositeValue) Clone(context ValueCloneContext) Value { return &CompositeValue{ dictionary: dictionary, + valueID: dictionary.ValueID(), Location: v.Location, QualifiedIdentifier: v.QualifiedIdentifier, Kind: v.Kind, @@ -1653,6 +1728,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 @@ -1772,8 +1852,12 @@ 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) - CheckInvalidatedResourceOrResourceReference(value, context) + // The field value is handed to `f` without transfer. + // 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) + CheckInvalidatedValueOrValueReference(value, context) resume = f( string(key.(StringAtreeValue)), @@ -1796,9 +1880,42 @@ func (v *CompositeValue) StorageAddress() atree.Address { } func (v *CompositeValue) ValueID() atree.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 *CompositeValue) isStaleAtreeView() bool { + if v.dictionary == nil { + return true + } + return v.dictionary.ValueID() != 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() } +// 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, @@ -2081,7 +2198,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)) @@ -2090,7 +2207,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 6320ac47b7..e9d2caad21 100644 --- a/interpreter/value_dictionary.go +++ b/interpreter/value_dictionary.go @@ -39,6 +39,15 @@ type DictionaryValue struct { dictionary *atree.OrderedMap isDestroyed bool elementSize uint + + // 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 } func NewDictionaryValue( @@ -293,6 +302,7 @@ func newDictionaryValueFromAtreeMap( return &DictionaryValue{ Type: staticType, dictionary: atreeOrderedMap, + valueID: atreeOrderedMap.ValueID(), elementSize: elementSize, } } @@ -304,13 +314,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 { @@ -363,9 +372,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 { @@ -446,13 +463,16 @@ func (v *DictionaryValue) iterate( ) // atree.OrderedMap iteration provides low-level atree.Value, - // convert to high-level interpreter.Value + // convert to high-level interpreter.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) - keyValue := MustConvertStoredValue(context, key) - valueValue := MustConvertStoredValue(context, value) - - CheckInvalidatedResourceOrResourceReference(keyValue, context) - CheckInvalidatedResourceOrResourceReference(valueValue, context) + CheckInvalidatedValueOrValueReference(keyValue, context) + CheckInvalidatedValueOrValueReference(valueValue, context) resume = f( keyValue, @@ -522,6 +542,19 @@ func (v *DictionaryValue) IsDestroyed() bool { return v.isDestroyed } +// 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 + } + return v.dictionary +} + func (v *DictionaryValue) isInvalidatedResource(context ValueStaticTypeContext) bool { return v.isDestroyed || (v.dictionary == nil && v.IsResourceKinded(context)) } @@ -577,14 +610,23 @@ func (v *DictionaryValue) Destroy(context ResourceDestructionContext) { InvalidateReferencedResources(context, v) + context.ClearCanonicalAtreeContainer(valueID) + v.dictionary = nil } 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. 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 + keyType, asReference := sema.GetDescendantTypeForAccess(context, accessedType, keyType, false) argumentTypes := []sema.Type{keyType} @@ -604,8 +646,24 @@ 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 { + key = getReferenceValue(context, key, keyType) + } + result := invokeFunctionValue( context, procedure, @@ -689,9 +747,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 { @@ -922,6 +978,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( @@ -929,10 +995,22 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryRemoveFunctionType( - v.SemaType(context), + context, + accessedType, + dictionaryType, ), NativeDictionaryRemoveFunction, - ) + ). + // 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: return NewBoundHostFunctionValue( @@ -940,10 +1018,22 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryInsertFunctionType( - v.SemaType(context), + context, + accessedType, + dictionaryType, ), NativeDictionaryInsertFunction, - ) + ). + // 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: return NewBoundHostFunctionValue( @@ -951,7 +1041,7 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryContainsKeyFunctionType( - v.SemaType(context), + dictionaryType, ), NativeDictionaryContainsKeyFunction, ) @@ -962,10 +1052,18 @@ func (v *DictionaryValue) GetMethod( v, accessedReference, sema.DictionaryForEachKeyFunctionType( - v.SemaType(context), + context, + accessedType, + 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 @@ -1636,6 +1734,21 @@ func (v *DictionaryValue) Transfer( InvalidateReferencedResources(context, v) + 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. 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. + // + // 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 } @@ -1724,6 +1837,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 @@ -1769,9 +1887,42 @@ func (v *DictionaryValue) StorageAddress() atree.Address { } func (v *DictionaryValue) ValueID() atree.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 *DictionaryValue) isStaleAtreeView() bool { + if v.dictionary == nil { + return true + } + return v.dictionary.ValueID() != 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() } +// 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() +} + func (v *DictionaryValue) SemaType(typeConverter TypeConverter) *sema.DictionaryType { if v.semaType == nil { // this function will panic already if this conversion fails @@ -1823,7 +1974,7 @@ var NativeDictionaryRemoveFunction = NativeFunction( args []Value, ) Value { keyValue := args[0] - dictionary := AssertValueOfType[*DictionaryValue](receiver) + dictionary := dictionaryValueFromReceiver(context, receiver) return dictionary.Remove(context, keyValue) }, ) @@ -1838,7 +1989,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) }, ) @@ -1866,12 +2017,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 { @@ -1957,8 +2127,22 @@ 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. + // 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. + // 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) } func (i *DictionaryKeyIterator) ValueID() (atree.ValueID, bool) { 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_fix128.go b/interpreter/value_fix128.go index c6992de444..e2e764e9d7 100644 --- a/interpreter/value_fix128.go +++ b/interpreter/value_fix128.go @@ -707,6 +707,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_function.go b/interpreter/value_function.go index e045918df5..bed8454e0f 100644 --- a/interpreter/value_function.go +++ b/interpreter/value_function.go @@ -498,7 +498,7 @@ func MaybeDereferenceReceiver( isNative bool, ) Value { - CheckInvalidatedResourceOrResourceReference(receiverReference, context) + 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_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/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 { 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/value_test.go b/interpreter/value_test.go index abcaa06edd..1b1308ea08 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()) @@ -657,6 +691,547 @@ func TestOwnerCompositeTransfer(t *testing.T) { assert.Equal(t, common.ZeroAddress, value.GetOwner()) } +// 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() + + 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", + ) + + defer func() { + err, ok := recover().(error) + require.True(t, ok) + + var invalidatedContainerViewError *InvalidatedContainerViewError + require.ErrorAs(t, err, &invalidatedContainerViewError) + }() + CheckInvalidatedValueOrValueReference(arr, inter) +} + +// 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() + + 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", + ) + + defer func() { + err, ok := recover().(error) + require.True(t, ok) + + var invalidatedContainerViewError *InvalidatedContainerViewError + require.ErrorAs(t, err, &invalidatedContainerViewError) + }() + CheckInvalidatedValueOrValueReference(dictionary, inter) +} + +// 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() + + 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", + ) + + 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 +// 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() + + 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", + ) +} + +// 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 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() + + 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 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() + + 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())) +} + +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 +// 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() + + 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 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() + + 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() diff --git a/interpreter/value_ufix128.go b/interpreter/value_ufix128.go index b41ff8296c..5fb3dd7842 100644 --- a/interpreter/value_ufix128.go +++ b/interpreter/value_ufix128.go @@ -683,6 +683,8 @@ func ufix128SaturationArithmaticResult( return fixedpoint.UFix128TypeMin case fix.UnderflowError: return fix.UFix128Zero + case fix.DivisionByZeroError: + panic(&DivisionByZeroError{}) default: panic(err) } diff --git a/interpreter/values_test.go b/interpreter/values_test.go index 3256896688..e84651b953 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() 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 } 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/runtime/account_test.go b/runtime/account_test.go index e4ff2a710d..ebc1f2dda8 100644 --- a/runtime/account_test.go +++ b/runtime/account_test.go @@ -1294,7 +1294,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.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 } 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 11b7172a08..fc4688aefc 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 e65e290149..1817c8fbc2 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 c232da8586..ff14f80697 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -2826,7 +2826,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 @@ -3391,6 +3391,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() @@ -3675,7 +3829,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 { @@ -3811,7 +3965,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 { @@ -3956,7 +4110,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 { @@ -4531,7 +4685,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) { @@ -4686,7 +4840,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 @@ -6699,7 +6853,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 }, @@ -6839,7 +6993,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 }, @@ -7053,7 +7207,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 }, @@ -11696,7 +11850,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/storage_test.go b/runtime/storage_test.go index a20fbf61b0..90a953546f 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)) @@ -7119,7 +7119,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)) } @@ -7948,7 +7948,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/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/sema/access.go b/sema/access.go index ffb78a820f..d25147e655 100644 --- a/sema/access.go +++ b/sema/access.go @@ -88,9 +88,42 @@ 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 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. +// - 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 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 { @@ -102,20 +135,36 @@ 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: 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 + } } func (EntitlementSetAccess) isAccess() {} diff --git a/sema/access_test.go b/sema/access_test.go index 2585823e46..ec1f5a168a 100644 --- a/sema/access_test.go +++ b/sema/access_test.go @@ -677,21 +677,29 @@ func TestIntersectAccess(t *testing.T) { t.Run("disjunction, identical", func(t *testing.T) { t.Parallel() + // 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), ) - assert.Equal(t, disjunctionAccess(MutateType, InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) }) t.Run("disjunction, partial overlap", func(t *testing.T) { t.Parallel() + // 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), ) - assert.Equal(t, disjunctionAccess(InsertType), result) + assert.Equal(t, UnauthorizedAccess, result) }) t.Run("disjunction, disjoint", func(t *testing.T) { @@ -704,40 +712,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 +789,22 @@ 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. + // 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), + ) + assert.Equal(t, UnauthorizedAccess, result) + }) + t.Run("entitlement map access", func(t *testing.T) { t.Parallel() @@ -752,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/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/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/check_expression.go b/sema/check_expression.go index 5c30c27c1e..704688ac84 100644 --- a/sema/check_expression.go +++ b/sema/check_expression.go @@ -400,22 +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 := false - if ShouldReturnReference(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. - outerRef, _ := MaybeReferenceType(valueIndexedType) - elementType = GetDescendantReferenceType( - checker.memoryGauge, - elementType, - UnauthorizedAccess, - outerRef.Authorization, - ) - - // Store the result in elaboration, so the interpreter can re-use this. - returnReference = true - } + // 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 b5d8109d0b..5d6c41dccd 100644 --- a/sema/check_for.go +++ b/sema/check_for.go @@ -146,39 +146,25 @@ 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. + 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_invocation_expression.go b/sema/check_invocation_expression.go index 3232bf8654..c9fbbb3393 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_member_expression.go b/sema/check_member_expression.go index de77a4653f..090c90d8df 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 @@ -210,6 +211,45 @@ 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`, 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, +// and the returned boolean is true. +// Otherwise (for owned access, for primitive descendants, or in assignment contexts): +// `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, bool) { + if !ShouldReturnReference(accessedType, descendantType, isAssignment) { + return descendantType, false + } + outerRef, isRef := MaybeReferenceType(accessedType) + if !isRef { + panic(errors.NewUnreachableError()) + } + return GetDescendantReferenceType( + memoryGauge, + descendantType, + UnauthorizedAccess, + outerRef.Authorization, + ), true +} + func (checker *Checker) visitMember(expression *ast.MemberExpression, isAssignment bool) ( accessedType Type, resultingType Type, @@ -463,7 +503,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, diff --git a/sema/check_return_statement.go b/sema/check_return_statement.go index b83fe2adc4..310dc8e1ed 100644 --- a/sema/check_return_statement.go +++ b/sema/check_return_statement.go @@ -24,13 +24,46 @@ 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. Three reasons: + // + // 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. + // + // 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 + // 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 the surrounding scope-leave to skip (via the + // `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_swap.go b/sema/check_swap.go index 9930e4be2b..a619197c03 100644 --- a/sema/check_swap.go +++ b/sema/check_swap.go @@ -31,42 +31,51 @@ 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. + for _, side := range []ast.Expression{swap.Left, swap.Right} { - if leftValueType.IsResourceType() { - checker.elaborateNestedResourceMoveExpression(swap.Left) - } + // 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 rightValueType.IsResourceType() { - checker.elaborateNestedResourceMoveExpression(swap.Right) - } + 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. + // 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) 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 8ddf2a3762..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,12 +71,36 @@ func (checker *Checker) VisitBreakStatement(statement *ast.BreakStatement) (_ st return case ControlKindLoop: - functionActivation.ReturnInfo.DefinitelyJumped = true + functionActivation.ReturnInfo.AddLoopJumpOffset(statement.StartPos.Offset) + functionActivation.ReturnInfo.MaybeJumpedLoop = true case ControlKindSwitch: - functionActivation.ReturnInfo.DefinitelyJumpedSwitch = true + functionActivation.ReturnInfo.AddSwitchJumpOffset(statement.StartPos.Offset) + functionActivation.ReturnInfo.MaybeJumpedSwitch = true } + // `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 } @@ -96,8 +123,19 @@ func (checker *Checker) VisitContinueStatement(statement *ast.ContinueStatement) return } - functionActivation.ReturnInfo.AddJumpOffset(statement.StartPos.Offset) - functionActivation.ReturnInfo.DefinitelyJumped = true + 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 + // 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 3364ba6d3b..8d7752045a 100644 --- a/sema/checker.go +++ b/sema/checker.go @@ -1486,11 +1486,50 @@ 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` 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 +// 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 - if returnInfo.IsUnreachable() { + + // 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. + // + // - 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`.) + // + // - 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.MaybeJumped() { return } @@ -1500,6 +1539,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( @@ -1853,7 +1898,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) } @@ -1932,6 +1977,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. @@ -1947,6 +1993,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{ @@ -2699,7 +2755,8 @@ func (checker *Checker) maybeAddResourceInvalidation(resource Resource, invalida var onlyPotential bool switch { case resource.Member != nil: - onlyPotential = returnInfo.MaybeReturned || returnInfo.MaybeJumped() + onlyPotential = returnInfo.MaybeReturned || + returnInfo.MaybeJumped() case resource.Variable != nil && resource.Variable.DeclarationKind != common.DeclarationKindSelf: @@ -2707,14 +2764,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/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/elaboration.go b/sema/elaboration.go index 7994faf08b..2d47187f0c 100644 --- a/sema/elaboration.go +++ b/sema/elaboration.go @@ -173,7 +173,12 @@ 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 + // 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 @@ -584,6 +589,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 diff --git a/sema/entitlements_test.go b/sema/entitlements_test.go index d603579007..3213d566d4 100644 --- a/sema/entitlements_test.go +++ b/sema/entitlements_test.go @@ -1045,6 +1045,134 @@ 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("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() @@ -2035,11 +2163,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 +3131,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 +6652,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 +6891,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 +6938,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 +7052,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 +7147,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]) }) } @@ -8648,90 +8783,1317 @@ func TestCheckNestedReferenceAuthorizationIntersection(t *testing.T) { typeMismatchError.ActualType.ID(), ) }) -} -func TestCheckMappingAccessFieldType(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 conservatively unauthorized, + // even in cases where preserving a disjunction would be sound + // (see the NOTE on IntersectAccess). - t.Parallel() + 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] + } + `) - expectInvalid := func(t *testing.T, err error) { errs := RequireCheckerErrors(t, err, 1) - assert.IsType(t, &sema.InvalidMappingAccessMemberTypeError{}, errs[0]) - } + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) - expectInvalidAndInvalidReferenceToOptional := func(t *testing.T, err error) { - errs := RequireCheckerErrors(t, err, 3) - assert.IsType(t, &sema.InvalidReferenceToOptionalTypeError{}, errs[0]) - assert.IsType(t, &sema.InvalidMappingAccessMemberTypeError{}, errs[1]) - assert.IsType(t, &sema.InvalidReferenceToOptionalTypeError{}, errs[2]) - } + assert.Equal(t, + common.TypeID("auth(S.test.F)&[Int]"), + typeMismatchError.ExpectedType.ID(), + ) + assert.Equal(t, + common.TypeID("&[Int]"), + typeMismatchError.ActualType.ID(), + ) + }) - test := func(t *testing.T, mapping, ty string, checkErr func(t *testing.T, err error)) { + t.Run("array, disjunction outer, disjunction inner, conservatively rejected", func(t *testing.T) { + t.Parallel() - testName := fmt.Sprintf("%s, %s", mapping, ty) + // 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 - t.Run(testName, func(t *testing.T) { + 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] - t.Parallel() + let ref: auth(E | F) &Int = arrayRef[0] + } + `) - _, err := ParseAndCheck(t, - fmt.Sprintf( - ` - entitlement X - entitlement Y + errs := RequireCheckerErrors(t, err, 1) + var typeMismatchError *sema.TypeMismatchError + require.ErrorAs(t, errs[0], &typeMismatchError) - entitlement mapping M { - X -> Y - } + 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(), + ) + }) - struct S { - access(mapping %[1]s) var x: %[2]s + t.Run("array, conjunction outer superset, disjunction inner preserved", func(t *testing.T) { + t.Parallel() - init(_ x: %[2]s) { - self.x = x - } - } + // 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 - struct T { - let ref: &Int + 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] - init(_ ref: &Int) { - self.ref = ref - } - } - `, - mapping, - ty, - ), - ) - checkErr(t, err) - }) - } + let ref: auth(E | F) &Int = arrayRef[0] + } + `) - for _, mapping := range []string{"Identity", "M"} { - // Primitive - test(t, mapping, "Int", expectSuccess) - test(t, mapping, "Int?", expectSuccess) - // Reference to primitive - test(t, mapping, "&Int", expectInvalid) - test(t, mapping, "(&Int)?", expectInvalid) - test(t, mapping, "&(Int?)", expectInvalidAndInvalidReferenceToOptional) + require.NoError(t, err) + }) - // AnyStruct - test(t, mapping, "AnyStruct", expectInvalid) - test(t, mapping, "AnyStruct?", expectInvalid) - // Reference to AnyStruct - test(t, mapping, "&AnyStruct", expectInvalid) - test(t, mapping, "(&AnyStruct)?", expectInvalid) - test(t, mapping, "&(AnyStruct?)", expectInvalidAndInvalidReferenceToOptional) + t.Run("array, conjunction outer superset, disjunction inner preserved, no upgrade to conjunction", func(t *testing.T) { + t.Parallel() - // Struct with reference field - test(t, mapping, "T", expectSuccess) - test(t, mapping, "T?", expectSuccess) - // Reference to struct with reference field - test(t, mapping, "&T", expectInvalid) - test(t, mapping, "(&T)?", expectInvalid) - test(t, mapping, "&(T?)", expectInvalidAndInvalidReferenceToOptional) + // 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. + // 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 + + 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 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]) + }) + + // 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) { + + 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) + }) + + // 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(), + ) + }) + + 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) + }) + + }) + +} + +func TestCheckMappingAccessFieldType(t *testing.T) { + + t.Parallel() + + expectInvalid := func(t *testing.T, err error) { + errs := RequireCheckerErrors(t, err, 1) + assert.IsType(t, &sema.InvalidMappingAccessMemberTypeError{}, errs[0]) + } + + expectInvalidAndInvalidReferenceToOptional := func(t *testing.T, err error) { + errs := RequireCheckerErrors(t, err, 3) + assert.IsType(t, &sema.InvalidReferenceToOptionalTypeError{}, errs[0]) + assert.IsType(t, &sema.InvalidMappingAccessMemberTypeError{}, errs[1]) + assert.IsType(t, &sema.InvalidReferenceToOptionalTypeError{}, errs[2]) + } + + test := func(t *testing.T, mapping, ty string, checkErr func(t *testing.T, err error)) { + + testName := fmt.Sprintf("%s, %s", mapping, ty) + + t.Run(testName, func(t *testing.T) { + + t.Parallel() + + _, err := ParseAndCheck(t, + fmt.Sprintf( + ` + entitlement X + entitlement Y + + entitlement mapping M { + X -> Y + } + + struct S { + access(mapping %[1]s) var x: %[2]s + + init(_ x: %[2]s) { + self.x = x + } + } + + struct T { + let ref: &Int + + init(_ ref: &Int) { + self.ref = ref + } + } + `, + mapping, + ty, + ), + ) + checkErr(t, err) + }) + } + + for _, mapping := range []string{"Identity", "M"} { + // Primitive + test(t, mapping, "Int", expectSuccess) + test(t, mapping, "Int?", expectSuccess) + // Reference to primitive + test(t, mapping, "&Int", expectInvalid) + test(t, mapping, "(&Int)?", expectInvalid) + test(t, mapping, "&(Int?)", expectInvalidAndInvalidReferenceToOptional) + + // AnyStruct + test(t, mapping, "AnyStruct", expectInvalid) + test(t, mapping, "AnyStruct?", expectInvalid) + // Reference to AnyStruct + test(t, mapping, "&AnyStruct", expectInvalid) + test(t, mapping, "(&AnyStruct)?", expectInvalid) + test(t, mapping, "&(AnyStruct?)", expectInvalidAndInvalidReferenceToOptional) + + // Struct with reference field + test(t, mapping, "T", expectSuccess) + test(t, mapping, "T?", expectSuccess) + // Reference to struct with reference field + test(t, mapping, "&T", expectInvalid) + test(t, mapping, "(&T)?", expectInvalid) + test(t, mapping, "&(T?)", expectInvalidAndInvalidReferenceToOptional) // Array test(t, mapping, "[Int]", expectSuccess) @@ -8863,3 +10225,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]) + }) +} 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/sema/for_test.go b/sema/for_test.go index 1d2937278d..e7e5f62786 100644 --- a/sema/for_test.go +++ b/sema/for_test.go @@ -788,3 +788,808 @@ 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 + // `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) +} + +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() + + 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/function_activations.go b/sema/function_activations.go index 6c90cd3a8c..fb0f9a2ffa 100644 --- a/sema/function_activations.go +++ b/sema/function_activations.go @@ -61,22 +61,44 @@ 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) - a.ReturnInfo.WithNewJumpTarget(f) + savedMaybeJumpedLoop := a.ReturnInfo.MaybeJumpedLoop + a.ReturnInfo.WithNewLoopJumpTarget(f) + if a.ReturnInfo.MaybeJumpedLoop { + 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 + // 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) - // 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. - savedDefinitelyJumpedSwitch := a.ReturnInfo.DefinitelyJumpedSwitch - f() - a.ReturnInfo.DefinitelyJumpedSwitch = savedDefinitelyJumpedSwitch + savedMaybeJumpedSwitch := a.ReturnInfo.MaybeJumpedSwitch + a.ReturnInfo.WithNewSwitchJumpTarget(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 3a66a91f2c..5039c69be8 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 @@ -41,74 +54,117 @@ 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`. + // + // This is the generic "every path terminated" flag and is the one + // observed by `IsUnreachable()` to decide whether subsequent + // statements are reachable. // - // NOTE: this is NOT the same DefinitelyReturned || DefinitelyHalted: - // For example, for the following program: + // 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: // // 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. + // + // 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 && !MaybeJumped()` — see `checkResourceLoss` + // for the rationale. + // + // 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 - // DefinitelyJumped 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 - // DefinitelyJumpedSwitch indicates that (the branch of) the function - // contains a definite `break` whose target is a switch. + // 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`). + // + // 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. + // + // 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. // - // Tracked separately from DefinitelyJumped 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 + // 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. + // + // 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 } + // return ... + MaybeJumpedSwitch bool } func NewReturnInfo() *ReturnInfo { return &ReturnInfo{ - JumpOffsets: persistent.NewOrderedSet[int](nil), + LoopJumpOffsets: persistent.NewOrderedSet[int](nil), + SwitchJumpOffsets: persistent.NewOrderedSet[int](nil), } } -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 || 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.DefinitelyJumpedSwitch = ri.DefinitelyJumpedSwitch || - (thenReturnInfo.DefinitelyJumpedSwitch && - elseReturnInfo.DefinitelyJumpedSwitch) - ri.DefinitelyHalted = ri.DefinitelyHalted || (thenReturnInfo.DefinitelyHalted && elseReturnInfo.DefinitelyHalted) @@ -116,35 +172,110 @@ func (ri *ReturnInfo) MergeBranches(thenReturnInfo *ReturnInfo, elseReturnInfo * ri.DefinitelyExited = ri.DefinitelyExited || (thenReturnInfo.DefinitelyExited && elseReturnInfo.DefinitelyExited) + + // Propagate jump offsets from both branches. Each branch's + // `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 + // are joining back into the parent, their jumps are potential + // from the perspective of subsequent code. + ri.addJumpOffsetsFrom(thenReturnInfo) + ri.addJumpOffsetsFrom(elseReturnInfo) +} + +func (ri *ReturnInfo) addJumpOffsetsFrom(other *ReturnInfo) { + _ = 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 + }) } func (ri *ReturnInfo) MergePotentiallyUnevaluated(temporaryReturnInfo *ReturnInfo) { 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 } func (ri *ReturnInfo) Clone() *ReturnInfo { result := NewReturnInfo() *result = *ri + // 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.LoopJumpOffsets = ri.LoopJumpOffsets.Clone() + result.SwitchJumpOffsets = ri.SwitchJumpOffsets.Clone() return result } func (ri *ReturnInfo) IsUnreachable() bool { // NOTE: intentionally NOT DefinitelyReturned || DefinitelyHalted, // see DefinitelyExited - return ri.DefinitelyExited || - ri.DefinitelyJumped || - ri.DefinitelyJumpedSwitch + 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 +} + +// 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) AddLoopJumpOffset(offset int) { + ri.LoopJumpOffsets.Add(offset) +} + +func (ri *ReturnInfo) AddSwitchJumpOffset(offset int) { + ri.SwitchJumpOffsets.Add(offset) } -func (ri *ReturnInfo) AddJumpOffset(offset int) { - ri.JumpOffsets.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 ffdd7acb26..0e27c6e5b4 100644 --- a/sema/switch_test.go +++ b/sema/switch_test.go @@ -712,3 +712,687 @@ func TestCheckSwitchResourceInvalidation(t *testing.T) { assert.IsType(t, &sema.ResourceUseAfterInvalidationError{}, errs[0]) }) } + +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]) +} + +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 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 { + 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]) +} + +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]) + }) + + 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/type.go b/sema/type.go index ef71280ecc..f0b0015d70 100644 --- a/sema/type.go +++ b/sema/type.go @@ -2739,34 +2739,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, @@ -2835,225 +2809,246 @@ 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), + }, + ) } - } - if _, ok := arrayType.(*ConstantSizedType); ok { + return NewPublicFunctionMember( + memoryGauge, + arrayType, + identifier, + ArraySliceFunctionType(memoryGauge, accessedType, elementType), + arrayTypeSliceFunctionDocString, + ) + } +} - members[ArrayTypeToVariableSizedFunctionName] = MemberResolver{ - Kind: common.DeclarationKindFunction, - Resolve: func( - memoryGauge common.MemoryGauge, - identifier string, - targetRange ast.HasPosition, - report func(error), - ) *Member { - elementType := arrayType.ElementType(false) +func ArrayInsertFunctionMemberFuncResolver(arrayType ArrayType) ResolveMemberFunc { + return func( + memoryGauge common.MemoryGauge, + identifier string, + _ ast.HasPosition, + _ func(error), + ) *Member { - if elementType.IsResourceType() { - report( - &InvalidResourceArrayMemberError{ - Name: identifier, - DeclarationKind: common.DeclarationKindFunction, - Range: ast.NewRangeFromPositioned(memoryGauge, targetRange), - }, - ) - } + elementType := arrayType.ElementType(false) - return NewPublicFunctionMember( - memoryGauge, - arrayType, - identifier, - ArrayToVariableSizedFunctionType(elementType), - arrayTypeToVariableSizedFunctionDocString, - ) - }, - } + return NewFunctionMember( + memoryGauge, + arrayType, + insertMutateEntitledAccess, + identifier, + ArrayInsertFunctionType(elementType), + arrayTypeInsertFunctionDocString, + ) } - - return withBuiltinMembers(arrayType, members) } -func ArrayMapFunctionMemberFuncResolver( +func ArrayReverseFunctionMemberFuncResolver( accessedType Type, arrayType ArrayType, ) ResolveMemberFunc { @@ -3063,9 +3058,10 @@ func ArrayMapFunctionMemberFuncResolver( targetRange ast.HasPosition, report func(error), ) *Member { + elementType := arrayType.ElementType(false) - // TODO: maybe allow for resource element type as a reference. + // It is impossible for a resource to be present in two arrays. if elementType.IsResourceType() { report( &InvalidResourceArrayMemberError{ @@ -3080,17 +3076,13 @@ func ArrayMapFunctionMemberFuncResolver( memoryGauge, arrayType, identifier, - ArrayMapFunctionType( - memoryGauge, - accessedType, - arrayType, - ), - arrayTypeMapFunctionDocString, + ArrayReverseFunctionType(memoryGauge, accessedType, arrayType), + arrayTypeReverseFunctionDocString, ) } } -func ArrayFilterFunctionMemberFuncResolver( +func ArrayToConstantSizedFunctionMemberFuncResolver( accessedType Type, arrayType ArrayType, ) ResolveMemberFunc { @@ -3117,17 +3109,94 @@ func ArrayFilterFunctionMemberFuncResolver( memoryGauge, arrayType, identifier, - ArrayFilterFunctionType( - memoryGauge, - accessedType, - elementType, - ), - arrayTypeFilterFunctionDocString, + 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 ArrayRemoveLastFunctionMemberFuncResolver( + 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, + ArrayRemoveLastFunctionType(memoryGauge, accessedType, elementType), + arrayTypeRemoveLastFunctionDocString, ) } } -func ArrayRemoveLastFunctionType(elementType Type) *FunctionType { +func ArrayRemoveLastFunctionType( + memoryGauge common.MemoryGauge, + 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, nil, @@ -3135,7 +3204,22 @@ func ArrayRemoveLastFunctionType(elementType Type) *FunctionType { ) } -func ArrayRemoveFirstFunctionType(elementType Type) *FunctionType { +func ArrayRemoveFirstFunctionType( + memoryGauge common.MemoryGauge, + 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, nil, @@ -3143,7 +3227,22 @@ func ArrayRemoveFirstFunctionType(elementType Type) *FunctionType { ) } -func ArrayRemoveFunctionType(elementType Type) *FunctionType { +func ArrayRemoveFunctionType( + memoryGauge common.MemoryGauge, + 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, []Parameter{ @@ -3156,6 +3255,42 @@ func ArrayRemoveFunctionType(elementType Type) *FunctionType { ) } +// 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. +// +// 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 intersectContainerElementReferences( + 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, @@ -3174,18 +3309,59 @@ func ArrayInsertFunctionType(elementType Type) *FunctionType { ) } -func ArrayConcatFunctionType(arrayType Type) *FunctionType { - typeAnnotation := NewTypeAnnotation(arrayType) +// 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 + // 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) + + // 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, + ) + + returnArrayType := NewVariableSizedType(memoryGauge, returnElementType) + return NewSimpleFunctionType( FunctionPurityView, []Parameter{ { Label: ArgumentLabelNotRequired, Identifier: "other", - TypeAnnotation: typeAnnotation, + TypeAnnotation: paramTypeAnnotation, }, }, - typeAnnotation, + NewTypeAnnotation(returnArrayType), ) } @@ -3246,7 +3422,26 @@ func ArrayAppendFunctionType(elementType Type) *FunctionType { ) } -func ArraySliceFunctionType(elementType Type) *FunctionType { +func ArraySliceFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + + // 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{ @@ -3265,7 +3460,27 @@ func ArraySliceFunctionType(elementType Type) *FunctionType { ) } -func ArrayToVariableSizedFunctionType(elementType Type) *FunctionType { +func ArrayToVariableSizedFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + + // 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{}, @@ -3275,7 +3490,27 @@ func ArrayToVariableSizedFunctionType(elementType Type) *FunctionType { ) } -func ArrayToConstantSizedFunctionType(elementType Type) *FunctionType { +func ArrayToConstantSizedFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + elementType Type, +) *FunctionType { + + // 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. typeParameter := &TypeParameter{ @@ -3312,7 +3547,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] @@ -3332,10 +3567,40 @@ func ArrayToConstantSizedFunctionType(elementType Type) *FunctionType { } } -func ArrayReverseFunctionType(arrayType ArrayType) *FunctionType { +func ArrayReverseFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + arrayType ArrayType, +) *FunctionType { + + // 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: + 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, } } @@ -3349,15 +3614,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{ @@ -3415,17 +3685,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{ @@ -7089,67 +7362,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), }, }, ) @@ -7171,7 +7393,22 @@ func DictionaryContainsKeyFunctionType(t *DictionaryType) *FunctionType { ) } -func DictionaryInsertFunctionType(t *DictionaryType) *FunctionType { +func DictionaryInsertFunctionType( + memoryGauge common.MemoryGauge, + accessedType Type, + t *DictionaryType, +) *FunctionType { + // 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, []Parameter{ @@ -7187,13 +7424,28 @@ 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 { + // 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, []Parameter{ @@ -7204,22 +7456,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. 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( functionPurity, []Parameter{ { Identifier: "key", - TypeAnnotation: NewTypeAnnotation(t.KeyType), + TypeAnnotation: NewTypeAnnotation(keyType), }, }, BoolTypeAnnotation, @@ -7239,6 +7500,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 } @@ -8047,6 +8380,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) } } @@ -8061,6 +8396,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" diff --git a/sema/while_test.go b/sema/while_test.go index 3922b94315..2a6396a7fb 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,889 @@ 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 + // `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) +} + +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() + + 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) + }) + + 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) { + + 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) + }) +} diff --git a/stdlib/account.go b/stdlib/account.go index 27b0d460d5..f473461a42 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/interpreter_utils/values.go b/test_utils/interpreter_utils/values.go index 927535450b..da93a39c63 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" @@ -181,11 +182,39 @@ type testValueCreationContext struct { var _ interpreter.MemberAccessibleContext = &testValueCreationContext{} var _ interpreter.ValueCreationContext = &testValueCreationContext{} +// 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{ - // Have a separate storage for creating values inside the test Go-code. - // Then it'll not interfere with cadence program's storage. - storage: NewUnmeteredInMemoryStorage(), + storage: actual.(interpreter.Storage), Invokable: invokable, } } 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 { diff --git a/test_utils/test_utils.go b/test_utils/test_utils.go index 9f086ffc67..37de8c943e 100644 --- a/test_utils/test_utils.go +++ b/test_utils/test_utils.go @@ -63,6 +63,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 { @@ -596,6 +600,7 @@ func ParseCheckAndPrepareWithOptions( ParseAndCheckOptions: options.ParseAndCheckOptions, CompilerConfig: compilerConfig, CheckerErrorHandler: options.HandleCheckerError, + CheckerHandler: options.HandleChecker, }, Programs: programs, }, @@ -880,6 +885,10 @@ func parseCheckAndInterpretWithOptionsAndAtreeValidations( return nil, err } + if options.HandleChecker != nil { + options.HandleChecker(checker) + } + var uuid uint64 = 0 var config interpreter.Config diff --git a/tools/compatibility-check/go.mod b/tools/compatibility-check/go.mod index 1bad99116c..36665b6e78 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 diff --git a/tools/compatibility-check/go.sum b/tools/compatibility-check/go.sum index a9873b55a0..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 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 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/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) diff --git a/version.go b/version.go index e7327ca767..f056e3c074 100644 --- a/version.go +++ b/version.go @@ -21,4 +21,4 @@ package cadence -const Version = "v1.10.3" +const Version = "v1.10.4-rc.6"