From bd53dade20d90c55de4ba278dea8f6beff717177 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 28 Aug 2026 23:13:46 +0300 Subject: [PATCH 01/18] [TS Calls] Add guarded semantic model registry and execution --- .../src/main/kotlin/org/usvm/api/TsMock.kt | 46 ++-- .../main/kotlin/org/usvm/machine/TsMachine.kt | 16 +- .../main/kotlin/org/usvm/machine/TsOptions.kt | 2 + .../call/TsIntrinsicUnknownCallModels.kt | 158 +++++++++++++ .../org/usvm/machine/call/TsUnknownCall.kt | 8 + .../usvm/machine/call/TsUnknownCallModel.kt | 116 ++++++++++ .../call/TsUnknownCallModelRegistry.kt | 157 +++++++++++++ .../usvm/machine/call/TsUnknownCallProfile.kt | 189 +++++++++++---- .../usvm/machine/expr/CallApproximations.kt | 33 ++- .../call/TsArrayPopIntrinsicModelTest.kt | 154 +++++++++++++ .../call/TsUnknownCallDispatcherTest.kt | 217 ++++++++++++++++-- .../call/TsUnknownCallModelRegistryTest.kt | 119 ++++++++++ .../baseline/CallFallbackBaseline.ts | 16 ++ .../resources/models/ArrayPopIntrinsic.ts | 24 ++ 14 files changed, 1167 insertions(+), 88 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt create mode 100644 usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt index af7236837..237e0f412 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt @@ -8,6 +8,7 @@ import org.usvm.UExpr import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.machine.types.mkFakeValue fun mockMethodCall( @@ -16,27 +17,34 @@ fun mockMethodCall( resultType: EtsType = method.returnType, ) { scope.doWithState { - val result: UExpr<*> - if (resultType is EtsVoidType) { - result = ctx.mkUndefinedValue() - } else { - val sort = ctx.typeToSort(resultType) - result = when (sort) { - is UAddressSort -> makeSymbolicRefUntyped() + mockMethodCall(method = method, resultType = resultType) + } +} + +/** Creates a fresh opaque result directly on this state without applying callee effects or exceptions. */ +fun TsState.mockMethodCall( + method: EtsMethodSignature, + resultType: EtsType = method.returnType, +) { + val result = freshUnknownCallResult(resultType) + methodResult = TsMethodResult.Success.MockedCall(result, method) +} + +private fun TsState.freshUnknownCallResult(resultType: EtsType): UExpr<*> { + if (resultType is EtsVoidType) { + return ctx.mkUndefinedValue() + } - is TsUnresolvedSort -> scope.calcOnState { - mkFakeValue( - scope = scope, - boolValue = makeSymbolicPrimitive(ctx.boolSort), - fpValue = makeSymbolicPrimitive(ctx.fp64Sort), - refValue = makeSymbolicRefUntyped(), - ) - } + return when (val sort = ctx.typeToSort(resultType)) { + is UAddressSort -> makeSymbolicRefUntyped() - else -> makeSymbolicPrimitive(sort) - } - } + is TsUnresolvedSort -> mkFakeValue( + scope = null, + boolValue = makeSymbolicPrimitive(ctx.boolSort), + fpValue = makeSymbolicPrimitive(ctx.fp64Sort), + refValue = makeSymbolicRefUntyped(), + ) - methodResult = TsMethodResult.Success.MockedCall(result, method) + else -> makeSymbolicPrimitive(sort) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 3d6b394f3..f08a486a4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -9,6 +9,7 @@ import org.usvm.StateCollectionStrategy import org.usvm.UMachine import org.usvm.UMachineOptions import org.usvm.api.targets.TsTarget +import org.usvm.machine.call.TsBuiltInUnknownCallModels import org.usvm.machine.call.TsNoUnknownCallModels import org.usvm.machine.call.TsProfileUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallDispatcher @@ -45,15 +46,26 @@ class TsMachine( private val machineObserver: UMachineObserver? = null, observer: TsInterpreterObserver? = null, unknownCallDispatcher: TsUnknownCallDispatcher? = null, - unknownCallModelProvider: TsUnknownCallModelProvider = TsNoUnknownCallModels, + unknownCallModelProvider: TsUnknownCallModelProvider? = null, ) : UMachine() { private val graph = TsGraph(scene) private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, graph.hierarchy) private val components = TsComponents(typeSystem, options) private val ctx = TsContext(scene, components) + private val frozenUnknownCallModels = when { + unknownCallDispatcher != null || unknownCallModelProvider != null -> null + else -> TsBuiltInUnknownCallModels.registry.freeze(tsOptions.unknownCallModels.enabledModelIds) + } + + /** Fingerprint of the frozen built-in catalog, or `null` when custom dispatch/model wiring is used. */ + val unknownCallModelCatalogFingerprint: String? + get() = frozenUnknownCallModels?.fingerprint + + private val resolvedUnknownCallModelProvider = + unknownCallModelProvider ?: frozenUnknownCallModels ?: TsNoUnknownCallModels private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsProfileUnknownCallDispatcher( profile = tsOptions.unknownCallProfile, - modelProvider = unknownCallModelProvider, + modelProvider = resolvedUnknownCallModelProvider, observer = observer, ) private val interpreter = TsInterpreter( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index 09c3e6659..6b22c8831 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -1,5 +1,6 @@ package org.usvm.machine +import org.usvm.machine.call.TsUnknownCallModelSelection import org.usvm.machine.call.TsUnknownCallProfile import org.usvm.machine.call.TsUnknownCallProfiles @@ -8,4 +9,5 @@ data class TsOptions( val enableVisualization: Boolean = false, val maxArraySize: Int = 1_000, val unknownCallProfile: TsUnknownCallProfile = TsUnknownCallProfiles.MODELS_THEN_STOP, + val unknownCallModels: TsUnknownCallModelSelection = TsUnknownCallModelSelection(), ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt new file mode 100644 index 000000000..d2899c1a5 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt @@ -0,0 +1,158 @@ +package org.usvm.machine.call + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.usvm.UAddressSort +import org.usvm.UExpr +import org.usvm.USort +import org.usvm.api.typeStreamOf +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsState +import org.usvm.types.firstOrNull +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue + +/** Builds constraint-level execution plans directly from a TypeScript symbolic state. */ +fun interface TsIntrinsicUnknownCallModel { + fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution +} + +/** Opaque registry handle for a Kotlin intrinsic semantic model. */ +class TsIntrinsicUnknownCallModelImplementation( + val model: TsIntrinsicUnknownCallModel, +) : TsUnknownCallModelImplementation { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC +} + +/** Executes intrinsic model handles without exposing them to the common registry or dispatcher contract. */ +object TsIntrinsicUnknownCallModelBackend : TsUnknownCallModelBackend { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC + + override fun execute( + implementation: TsUnknownCallModelImplementation, + state: TsState, + call: TsUnknownCall, + ): TsUnknownCallModelExecution { + val intrinsic = requireNotNull(implementation as? TsIntrinsicUnknownCallModelImplementation) { + "INTRINSIC backend requires TsIntrinsicUnknownCallModelImplementation, got ${implementation::class}" + } + + return intrinsic.model.execute(state = state, call = call) + } +} + +/** The intentionally small built-in catalog enabled by default for profile-based unknown-call dispatch. */ +object TsBuiltInUnknownCallModels { + const val ARRAY_POP_MODEL_ID: String = "ts.array.pop" + + private val arrayPopDescriptor = TsUnknownCallModelDescriptor( + id = ARRAY_POP_MODEL_ID, + matcher = TsUnknownCallModelMatcher { call -> + call.failureReason == TsUnknownCallFailureReason.PARTIAL_APPROXIMATION && + call.callee.name == "pop" + }, + supportedDomain = TsUnknownCallModelSupportedDomain( + id = "native-array-pop", + description = "Resolved one-dimensional native arrays with no arguments and a resolved element sort", + ), + precision = TsUnknownCallModelPrecision.PARTIAL, + implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, + ) + + val registry = TsUnknownCallModelRegistry( + registrations = listOf( + TsUnknownCallModelRegistration( + descriptor = arrayPopDescriptor, + implementation = TsIntrinsicUnknownCallModelImplementation(TsArrayPopIntrinsicModel), + ), + ), + backends = listOf(TsIntrinsicUnknownCallModelBackend), + ) +} + +private object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { + override fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { + val input = resolveInput(state = state, call = call) + ?: return unsupportedExecution(state) + + val lengthLValue = mkArrayLengthLValue(input.array, input.arrayType) + val length = state.memory.read(lengthLValue) + val zero = state.ctx.mkBv(0) + val emptyGuard = state.ctx.mkEq(length, zero) + val nonEmptyGuard = state.ctx.mkBvSignedLessExpr(zero, length) + val residualGuard = state.ctx.mkNot(state.ctx.mkOr(emptyGuard, nonEmptyGuard)) + val newLength = state.ctx.mkBvSubExpr(length, state.ctx.mkBv(1)) + val lastElementLValue = mkArrayIndexLValue( + sort = input.elementSort, + ref = input.array, + index = newLength, + type = input.arrayType, + ) + + val emptySuccessor = TsUnknownCallModelSuccessor( + guard = emptyGuard, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + val nonEmptySuccessor = TsUnknownCallModelSuccessor( + guard = nonEmptyGuard, + completion = TsUnknownCallModelCompletion.Normal { memory.read(lastElementLValue) }, + applyStateChanges = { + memory.write(lengthLValue, newLength, guard = ctx.trueExpr) + }, + ) + + return TsUnknownCallModelExecution( + successors = listOf(emptySuccessor, nonEmptySuccessor), + residualGuard = residualGuard, + ) + } + + private fun resolveInput(state: TsState, call: TsUnknownCall): ArrayPopInput? { + if (call.arguments.isNotEmpty()) { + return null + } + + val receiverValue = call.receiver?.resolved ?: return null + if (receiverValue.sort != state.ctx.addressSort) { + return null + } + + val array = receiverValue.asExpr(state.ctx.addressSort) + val sourceType = requireNotNull(call.receiver).source.type + val memoryType = state.memory.typeStreamOf(array).firstOrNull() + val arrayType = sequenceOf(memoryType, sourceType) + .mapNotNull { it as? EtsArrayType } + .firstOrNull { candidate -> + candidate.dimensions == 1 && state.ctx.typeToSort(candidate.elementType) !is TsUnresolvedSort + } + ?: return null + + val elementSort = state.ctx.typeToSort(arrayType.elementType) + + return ArrayPopInput( + array = array, + arrayType = arrayType, + elementSort = elementSort, + ) + } + + private fun unsupportedExecution(state: TsState): TsUnknownCallModelExecution { + val unreachableSuccessor = TsUnknownCallModelSuccessor( + guard = state.ctx.falseExpr, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelExecution( + successors = listOf(unreachableSuccessor), + residualGuard = state.ctx.trueExpr, + ) + } + + private class ArrayPopInput( + val array: UExpr, + val arrayType: EtsArrayType, + val elementSort: USort, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index bb99b6ec9..1497688f8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -60,6 +60,7 @@ enum class TsUnknownCallFailureReason { METHOD_BODY_UNAVAILABLE, INTERPROCEDURAL_ANALYSIS_DISABLED, LOGGING_CALL, + PARTIAL_APPROXIMATION, } /** Handles TypeScript calls that could not be executed by the regular call pipeline. */ @@ -67,6 +68,9 @@ fun interface TsUnknownCallDispatcher { fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome } +/** Marks profile dispatchers that replace migrated compatibility approximations with registered models. */ +interface TsUnknownCallModelDispatcher : TsUnknownCallDispatcher + /** Preserves the pruning and opaque-return behavior that existed before the common dispatch boundary. */ object TsCompatibilityUnknownCallDispatcher : TsUnknownCallDispatcher { override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { @@ -109,6 +113,10 @@ object TsCompatibilityUnknownCallDispatcher : TsUnknownCallDispatcher { scope.assert(falseExpr) return TsUnknownCallOutcome.PATH_STOPPED } + + TsUnknownCallFailureReason.PARTIAL_APPROXIMATION -> { + error("Migrated approximations must not be sent to the compatibility dispatcher") + } } } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt new file mode 100644 index 000000000..f1aab3a9c --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -0,0 +1,116 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsType +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.machine.state.TsState + +/** Identifies the backend that executes a semantic model implementation. */ +enum class TsUnknownCallModelImplementationKind { + INTRINSIC, +} + +/** Describes the semantic precision of a model within its declared supported domain. */ +enum class TsUnknownCallModelPrecision { + EXACT, + PARTIAL, +} + +/** Documents the inputs for which a semantic model provides its declared precision. */ +data class TsUnknownCallModelSupportedDomain( + val id: String, + val description: String, +) { + init { + require(id.isNotBlank()) { "Semantic model supported-domain ID must not be blank" } + require(description.isNotBlank()) { "Semantic model supported-domain description must not be blank" } + } +} + +/** Selects calls that are candidates for one semantic model without depending on its implementation backend. */ +fun interface TsUnknownCallModelMatcher { + fun matches(call: TsUnknownCall): Boolean +} + +/** Backend-neutral metadata used to select and audit one semantic model. */ +class TsUnknownCallModelDescriptor( + val id: String, + val matcher: TsUnknownCallModelMatcher, + val supportedDomain: TsUnknownCallModelSupportedDomain, + val precision: TsUnknownCallModelPrecision, + val implementationKind: TsUnknownCallModelImplementationKind, +) { + init { + require(id.isNotBlank()) { "Semantic model ID must not be blank" } + } +} + +/** Describes how a guarded model successor completes the original call. */ +sealed interface TsUnknownCallModelCompletion { + /** Produces a normal result on the selected successor state. */ + class Normal( + val result: TsState.() -> UExpr<*>, + ) : TsUnknownCallModelCompletion + + /** Produces an exceptional result and its TypeScript type on the selected successor state. */ + class Exceptional( + val exception: TsState.() -> Pair, EtsType>, + ) : TsUnknownCallModelCompletion +} + +/** + * One guarded model successor. + * + * Successor guards within one execution must be pairwise disjoint. State changes and completion values are evaluated + * only after the dispatcher has selected the corresponding successor state. + */ +class TsUnknownCallModelSuccessor( + val guard: UBoolExpr, + val completion: TsUnknownCallModelCompletion, + val applyStateChanges: TsState.() -> Unit = {}, +) + +/** + * A backend-neutral semantic-model execution plan. + * + * [residualGuard] denotes the unsupported part of a partial model's domain. Together, successor guards and the + * residual guard must partition the current call domain. + */ +class TsUnknownCallModelExecution( + successors: List, + val residualGuard: UBoolExpr?, +) { + val successors: List = successors.toList() + + init { + require(this.successors.isNotEmpty()) { "A semantic model must declare at least one guarded successor" } + } +} + +/** The result of selecting and executing a semantic model for one call. */ +sealed interface TsUnknownCallModelApplication { + /** A structured guarded plan produced by the selected model. */ + class Applied( + val modelId: String, + val precision: TsUnknownCallModelPrecision, + val execution: TsUnknownCallModelExecution, + ) : TsUnknownCallModelApplication { + init { + require(modelId.isNotBlank()) { "Applied model ID must not be blank" } + require(precision != TsUnknownCallModelPrecision.EXACT || execution.residualGuard == null) { + "Exact semantic model $modelId must not produce a residual guard" + } + require(precision != TsUnknownCallModelPrecision.PARTIAL || execution.residualGuard != null) { + "Partial semantic model $modelId must produce a residual guard" + } + } + } + + /** Indicates that no enabled model matched the call. */ + data object NotApplicable : TsUnknownCallModelApplication +} + +/** Selects and executes models without exposing registry or backend details to the dispatcher. */ +fun interface TsUnknownCallModelProvider { + fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt new file mode 100644 index 000000000..e80ed4cca --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt @@ -0,0 +1,157 @@ +package org.usvm.machine.call + +import org.usvm.machine.state.TsState +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +private const val BYTE_MASK = 0xff + +/** Opaque semantic-model implementation selected by its [kind]. */ +interface TsUnknownCallModelImplementation { + val kind: TsUnknownCallModelImplementationKind +} + +/** Executes opaque model implementations of one [kind]. */ +interface TsUnknownCallModelBackend { + val kind: TsUnknownCallModelImplementationKind + + fun execute( + implementation: TsUnknownCallModelImplementation, + state: TsState, + call: TsUnknownCall, + ): TsUnknownCallModelExecution +} + +/** Binds backend-neutral model metadata to an opaque backend implementation. */ +data class TsUnknownCallModelRegistration( + val descriptor: TsUnknownCallModelDescriptor, + val implementation: TsUnknownCallModelImplementation, +) { + init { + require(descriptor.implementationKind == implementation.kind) { + "Semantic model ${descriptor.id} declares ${descriptor.implementationKind} " + + "but provides ${implementation.kind}" + } + } +} + +/** Validates semantic-model registrations and freezes deterministic per-run subsets. */ +class TsUnknownCallModelRegistry( + registrations: Collection, + backends: Collection = emptyList(), +) { + private val registrations = registrations.sortedBy { it.descriptor.id } + private val backends = backends.associateBackendKinds() + + init { + val duplicateIds = this.registrations + .groupingBy { it.descriptor.id } + .eachCount() + .filterValues { count -> count > 1 } + .keys + .sorted() + + require(duplicateIds.isEmpty()) { + "Duplicate semantic model IDs: ${duplicateIds.joinToString()}" + } + } + + /** Freezes an immutable enabled subset; `null` enables the complete registered catalog. */ + fun freeze(enabledModelIds: Set? = null): TsFrozenUnknownCallModelRegistry { + val enabledIds = enabledModelIds?.toSet() + val knownIds = registrations.mapTo(mutableSetOf()) { it.descriptor.id } + val unknownIds = enabledIds.orEmpty().subtract(knownIds).sorted() + + require(unknownIds.isEmpty()) { + "Unknown semantic model IDs: ${unknownIds.joinToString()}" + } + + val enabledRegistrations = when (enabledIds) { + null -> registrations + else -> registrations.filter { it.descriptor.id in enabledIds } + } + + return TsFrozenUnknownCallModelRegistry(enabledRegistrations, backends) + } + + private fun Collection.associateBackendKinds(): + Map { + val duplicateKinds = groupingBy { it.kind } + .eachCount() + .filterValues { count -> count > 1 } + .keys + .sortedBy { it.name } + + require(duplicateKinds.isEmpty()) { + "Duplicate semantic model backends: ${duplicateKinds.joinToString()}" + } + + return associateBy { it.kind } + } +} + +/** Selects all registered models or a defensively copied explicit subset for one machine run. */ +class TsUnknownCallModelSelection( + enabledModelIds: Set? = null, +) { + val enabledModelIds: Set? = enabledModelIds?.toSet() +} + +/** An immutable deterministic semantic-model catalog used by one machine run. */ +class TsFrozenUnknownCallModelRegistry internal constructor( + private val registrations: List, + private val backends: Map, +) : TsUnknownCallModelProvider { + val descriptors: List = registrations.map { it.descriptor } + val fingerprint: String = computeFingerprint(registrations) + + internal fun select(call: TsUnknownCall): TsUnknownCallModelRegistration? { + val matches = registrations.filter { it.descriptor.matcher.matches(call) } + + check(matches.size <= 1) { + val modelIds = matches.map { it.descriptor.id }.sorted() + "Ambiguous semantic models matched: ${modelIds.joinToString()}" + } + + return matches.singleOrNull() + } + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val registration = select(call) ?: return TsUnknownCallModelApplication.NotApplicable + val implementationKind = registration.descriptor.implementationKind + val backend = checkNotNull(backends[implementationKind]) { + "No semantic model backend configured for $implementationKind" + } + val execution = backend.execute( + implementation = registration.implementation, + state = state, + call = call, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = registration.descriptor.id, + precision = registration.descriptor.precision, + execution = execution, + ) + } +} + +private fun computeFingerprint(registrations: List): String { + val digest = MessageDigest.getInstance("SHA-256") + + registrations.forEach { registration -> + digest.updateLengthPrefixed(registration.descriptor.id) + digest.updateLengthPrefixed(registration.descriptor.implementationKind.name) + } + + return digest.digest().joinToString(separator = "") { byte -> + "%02x".format(byte.toInt() and BYTE_MASK) + } +} + +private fun MessageDigest.updateLengthPrefixed(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) + update(bytes) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt index 66e4eeb1f..825cd8726 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt @@ -4,6 +4,8 @@ import org.jacodb.ets.model.EtsClassSignature import org.usvm.api.mockMethodCall import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.machine.state.newStmt /** The externally observable decision made for a call that could not be executed normally. */ @@ -61,34 +63,9 @@ object TsUnknownCallProfiles { ) } -/** The result of asking a model provider to handle one unknown call. */ -sealed interface TsUnknownCallModelApplication { - /** Identifies the semantic model that produced the successor states. */ - data class Applied( - val modelId: String, - ) : TsUnknownCallModelApplication { - init { - require(modelId.isNotBlank()) { "Applied model ID must not be blank" } - } - } - - /** Indicates that the provider has no semantic model for this call. */ - data object NotApplicable : TsUnknownCallModelApplication -} - -/** - * Applies semantic models without exposing their lookup or registry implementation to the dispatcher. - * - * A provider returning [TsUnknownCallModelApplication.Applied] must update the supplied scope with the model's - * successor states. The deterministic registry and concrete model implementations are introduced separately. - */ -fun interface TsUnknownCallModelProvider { - fun apply(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallModelApplication -} - /** Empty provider used until an explicit model registry is configured. */ object TsNoUnknownCallModels : TsUnknownCallModelProvider { - override fun apply(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallModelApplication = + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication = TsUnknownCallModelApplication.NotApplicable } @@ -97,32 +74,39 @@ class TsProfileUnknownCallDispatcher( private val profile: TsUnknownCallProfile, private val modelProvider: TsUnknownCallModelProvider, private val observer: TsInterpreterObserver? = null, -) : TsUnknownCallDispatcher { +) : TsUnknownCallModelDispatcher { override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { - val residualReason = when (profile.modelLookup) { - TsUnknownCallModelLookup.DISABLED -> { - TsUnknownCallResidualReason.MODEL_LOOKUP_DISABLED - } + if (profile.modelLookup == TsUnknownCallModelLookup.DISABLED) { + return applyResidualFallback( + scope = scope, + call = call, + reason = TsUnknownCallResidualReason.MODEL_LOOKUP_DISABLED, + ) + } - TsUnknownCallModelLookup.ENABLED -> { - when (val application = modelProvider.apply(scope, call)) { - is TsUnknownCallModelApplication.Applied -> { - val event = event( - call = call, - outcome = TsUnknownCallOutcome.MODEL_APPLIED, - decision = TsUnknownCallDecision.ModelApplied(modelId = application.modelId), - ) - observer?.onUnknownCallSafely(event) - return TsUnknownCallOutcome.MODEL_APPLIED - } + val application = scope.calcOnState { + modelProvider.apply(state = this, call = call) + } + return when (application) { + is TsUnknownCallModelApplication.Applied -> applyModel( + scope = scope, + call = call, + application = application, + ) - TsUnknownCallModelApplication.NotApplicable -> { - TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE - } - } - } + TsUnknownCallModelApplication.NotApplicable -> applyResidualFallback( + scope = scope, + call = call, + reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, + ) } + } + private fun applyResidualFallback( + scope: TsStepScope, + call: TsUnknownCall, + reason: TsUnknownCallResidualReason, + ): TsUnknownCallOutcome { val residualPolicy = profile.residualPolicyFor(call) val outcome = when (residualPolicy) { TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED @@ -133,7 +117,7 @@ class TsProfileUnknownCallDispatcher( outcome = outcome, decision = TsUnknownCallDecision.ResidualFallback( policy = residualPolicy, - reason = residualReason, + reason = reason, ), ) when (residualPolicy) { @@ -152,6 +136,115 @@ class TsProfileUnknownCallDispatcher( return outcome } + private fun applyModel( + scope: TsStepScope, + call: TsUnknownCall, + application: TsUnknownCallModelApplication.Applied, + ): TsUnknownCallOutcome { + val residualGuard = application.execution.residualGuard + val residualPolicy = profile.residualPolicyFor(call) + val stoppedResidualIsSatisfiable = residualGuard != null && + residualPolicy == TsResidualCallPolicy.STOP_PATH && + scope.checkSat(residualGuard) != null + + var modelApplied = false + var modelEventReported = false + var freshResidualApplied = false + val guardedStateChanges = application.execution.successors.map { successor -> + successor.guard to modelStateChange( + call = call, + application = application, + successor = successor, + onApplied = { + modelApplied = true + if (modelEventReported) { + false + } else { + modelEventReported = true + true + } + }, + ) + }.toMutableList() + + if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { + guardedStateChanges += residualGuard to { + mockMethodCall(method = call.callee, resultType = call.resultType) + newStmt(call.callSite) + freshResidualApplied = true + + val event = residualEvent( + call = call, + policy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ) + observer?.onUnknownCallSafely(event) + } + } + + scope.forkMulti(guardedStateChanges) + + if (stoppedResidualIsSatisfiable) { + val event = residualEvent( + call = call, + policy = TsResidualCallPolicy.STOP_PATH, + ) + observer?.onUnknownCallSafely(event) + } + + return when { + modelApplied -> TsUnknownCallOutcome.MODEL_APPLIED + freshResidualApplied -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN + stoppedResidualIsSatisfiable -> TsUnknownCallOutcome.PATH_STOPPED + else -> error("Semantic model ${application.modelId} produced no satisfiable successor or residual state") + } + } + + private fun modelStateChange( + call: TsUnknownCall, + application: TsUnknownCallModelApplication.Applied, + successor: TsUnknownCallModelSuccessor, + onApplied: () -> Boolean, + ): TsState.() -> Unit = { + successor.applyStateChanges(this) + + when (val completion = successor.completion) { + is TsUnknownCallModelCompletion.Normal -> { + val result = completion.result(this) + methodResult = TsMethodResult.Success.MockedCall(result, call.callee) + newStmt(call.callSite) + } + + is TsUnknownCallModelCompletion.Exceptional -> { + val (exception, type) = completion.exception(this) + methodResult = TsMethodResult.TsException(exception, type) + } + } + + if (onApplied()) { + val event = event( + call = call, + outcome = TsUnknownCallOutcome.MODEL_APPLIED, + decision = TsUnknownCallDecision.ModelApplied(modelId = application.modelId), + ) + observer?.onUnknownCallSafely(event) + } + } + + private fun residualEvent( + call: TsUnknownCall, + policy: TsResidualCallPolicy, + ) = event( + call = call, + outcome = when (policy) { + TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED + TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN + }, + decision = TsUnknownCallDecision.ResidualFallback( + policy = policy, + reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, + ), + ) + private fun event( call: TsUnknownCall, outcome: TsUnknownCallOutcome, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 0060b4762..9aa5a437c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -19,10 +19,14 @@ import org.usvm.api.memcpy import org.usvm.api.typeStreamOf import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsSizeSort +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModelDispatcher +import org.usvm.machine.call.dispatch import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue +import org.usvm.machine.state.lastStmt import org.usvm.sizeSort import org.usvm.types.first import org.usvm.types.firstOrNull @@ -107,7 +111,12 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.pop() method calls if (expr.callee.name == "pop") { - return from(handleArrayPop(expr, instanceType, elementSort)) + return handleArrayPopCall( + expr = expr, + instanceType = instanceType, + elementSort = elementSort, + resolvedReceiver = instance, + ) } // Handle `Array.fill() method calls @@ -159,6 +168,28 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.handleArrayPopCall( + expr: EtsInstanceCallExpr, + instanceType: EtsArrayType, + elementSort: USort, + resolvedReceiver: UExpr<*>, +): TsExprApproximationResult { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return from(handleArrayPop(expr, instanceType, elementSort)) + } + + dispatcher.dispatch( + scope = scope, + call = expr, + callSite = scope.calcOnState { lastStmt }, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + resolvedReceiver = resolvedReceiver, + ) + + return TsExprApproximationResult.ResolveFailure +} + private fun TsExprResolver.handleValueOf(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { if (expr.args.isNotEmpty()) { logger.warn { "valueOf() should have no arguments, but got ${expr.args.size}" } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt new file mode 100644 index 000000000..279f0bfbf --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt @@ -0,0 +1,154 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayPopIntrinsicModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayPopIntrinsic.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `empty array pop returns undefined through intrinsic model`() { + val result = analyze(methodName = "emptyArray") + + assertIs(result.values.single()) + assertEquals(listOf("ts.array.pop"), result.modelIds) + assertTrue(assertNotNull(result.catalogFingerprint).matches(Regex("[0-9a-f]{64}"))) + } + + @Test + fun `non empty array pop returns last element and shrinks array`() { + val result = analyze(methodName = "nonEmptyArray") + + assertEquals(32.0, assertIs(result.values.single()).number) + assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) + } + + @Test + fun `array pop preserves a returned reference alias`() { + val result = analyze(methodName = "aliasedElement") + + assertTrue(result.values.isNotEmpty(), "No final states; events=${result.events}") + assertEquals(42.0, assertIs(result.values.single()).number) + assertEquals(listOf("ts.array.pop"), result.modelIds) + } + + @Test + fun `disabled model sends pop to configured residual fallback`() { + val enabledModelIds = mutableSetOf("ts.array.pop") + val selection = TsUnknownCallModelSelection(enabledModelIds = enabledModelIds) + enabledModelIds.clear() + val result = analyze( + methodName = "nonEmptyArray", + tsOptions = TsOptions( + unknownCallProfile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, + unknownCallModels = TsUnknownCallModelSelection(enabledModelIds = emptySet()), + ), + ) + val selectedResult = analyze( + methodName = "nonEmptyArray", + tsOptions = TsOptions(unknownCallModels = selection), + ) + + assertEquals(listOf(TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), result.events.map { it.outcome }) + assertIs(result.events.single().decision) + assertEquals(listOf("ts.array.pop"), selectedResult.modelIds) + } + + @Test + fun `compatibility dispatcher keeps the legacy pop approximation`() { + val result = analyze( + methodName = "nonEmptyArray", + dispatcher = TsCompatibilityUnknownCallDispatcher, + ) + + assertEquals(32.0, assertIs(result.values.single()).number) + assertTrue(result.events.isEmpty()) + assertNull(result.catalogFingerprint) + } + + private fun analyze( + methodName: String, + tsOptions: TsOptions = TsOptions(), + dispatcher: TsUnknownCallDispatcher? = null, + ): AnalysisResult { + val method = method(methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + observer = observer, + unknownCallDispatcher = dispatcher, + ).use { machine -> + val states = machine.analyze(listOf(method)) + val values = states.map { state -> TsTestResolver().resolve(method, state).returnValue } + + AnalysisResult( + values = values, + events = observer.events.toList(), + catalogFingerprint = machine.unknownCallModelCatalogFingerprint, + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "ArrayPopIntrinsic" } + .methods + .single { it.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: List, + val events: List, + val catalogFingerprint: String?, + ) { + val modelIds: List + get() = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 5233e5280..e06985687 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -1,6 +1,7 @@ package org.usvm.machine.call import io.ksmt.utils.asExpr +import io.mockk.mockk import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod @@ -9,6 +10,7 @@ import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsVoidType import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr @@ -17,9 +19,10 @@ import org.junit.jupiter.api.Test import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy +import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef +import org.usvm.UExpr import org.usvm.UMachineOptions -import org.usvm.api.mockMethodCall import org.usvm.api.targets.ReachabilityObserver import org.usvm.api.targets.TsReachabilityTarget import org.usvm.machine.TsInterpreterObserver @@ -28,7 +31,6 @@ import org.usvm.machine.TsOptions import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState -import org.usvm.machine.state.newStmt import org.usvm.util.getResourcePath import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -144,13 +146,98 @@ class TsUnknownCallDispatcherTest { @Test fun `applied model decisions require non blank identifiers`() { assertFailsWith { - TsUnknownCallModelApplication.Applied(modelId = " ") + TsUnknownCallModelApplication.Applied( + modelId = " ", + precision = TsUnknownCallModelPrecision.EXACT, + execution = exactExecution(), + ) } assertFailsWith { TsUnknownCallDecision.ModelApplied(modelId = "") } } + @Test + fun `model applications enforce exact and partial residual contracts`() { + assertFailsWith { + TsUnknownCallModelApplication.Applied( + modelId = "invalid-exact", + precision = TsUnknownCallModelPrecision.EXACT, + execution = execution(residualGuard = mockk()), + ) + } + assertFailsWith { + TsUnknownCallModelApplication.Applied( + modelId = "invalid-partial", + precision = TsUnknownCallModelPrecision.PARTIAL, + execution = execution(residualGuard = null), + ) + } + } + + @Test + fun `partial model sends only residual domain to fresh fallback`() { + val observer = RecordingUnknownCallObserver() + val states = analyzeAllStates( + methodName = "modeledUnknownCallForks", + profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, + modelProvider = SupportedTrueResidualFalseProvider, + observer = observer, + ) + + assertEquals(2, states.size) + assertEquals( + listOf(TsUnknownCallOutcome.MODEL_APPLIED, TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), + observer.events.map { it.outcome }, + ) + } + + @Test + fun `partial model sends residual domain to stop fallback`() { + val observer = RecordingUnknownCallObserver() + val states = analyzeAllStates( + methodName = "modeledUnknownCallForks", + profile = TsUnknownCallProfiles.MODELS_THEN_STOP, + modelProvider = SupportedTrueResidualFalseProvider, + observer = observer, + ) + + assertEquals(1, states.size) + assertEquals( + listOf(TsUnknownCallOutcome.MODEL_APPLIED, TsUnknownCallOutcome.PATH_STOPPED), + observer.events.map { it.outcome }, + ) + } + + @Test + fun `exceptional model successor preserves exception state`() { + val states = analyzeAllStates( + methodName = "modeledUnknownCallThrows", + profile = TsUnknownCallProfiles.MODELS_THEN_STOP, + modelProvider = ExceptionalModelProvider, + ) + + assertIs(states.single().methodResult) + } + + @Test + fun `stateful model can return an existing reference alias`() { + val states = analyzeAllStates( + methodName = "modeledUnknownCallReturnsAlias", + profile = TsUnknownCallProfiles.MODELS_THEN_STOP, + modelProvider = StatefulAliasModelProvider, + ) + val aliasReturn = method(fullScene, "modeledUnknownCallReturnsAlias") + .cfg + .stmts + .filterIsInstance() + .first() + + val state = states.single() + assertTrue(aliasReturn in state.pathNode.allStatements) + assertTrue(STATE_CHANGE_MARKER in state.addedArtificialLocals) + } + @Test fun `profiles select model lookup independently from residual fallback`() { val cases = listOf( @@ -577,27 +664,106 @@ class TsUnknownCallDispatcherTest { } private object ApplyingModelProvider : TsUnknownCallModelProvider { - override fun apply(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallModelApplication { - mockMethodCall(scope, call.callee, call.resultType) - scope.doWithState { newStmt(call.callSite) } - return TsUnknownCallModelApplication.Applied(modelId = "applying-model") + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val successor = TsUnknownCallModelSuccessor( + guard = state.ctx.trueExpr, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "applying-model", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = null, + ), + ) } } private object ForkingModelProvider : TsUnknownCallModelProvider { - override fun apply(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallModelApplication { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { val result = requireNotNull(call.arguments.single().resolved) - val condition = scope.calcOnState { result.asExpr(ctx.boolSort) } - val completeCall: TsState.() -> Unit = { - methodResult = TsMethodResult.Success.MockedCall(result, call.callee) - newStmt(call.callSite) - } - scope.fork( - condition = condition, - blockOnTrueState = completeCall, - blockOnFalseState = completeCall, + val condition = result.asExpr(state.ctx.boolSort) + val completion = TsUnknownCallModelCompletion.Normal { result } + + return TsUnknownCallModelApplication.Applied( + modelId = "forking-model", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = condition, + completion = completion, + ), + TsUnknownCallModelSuccessor( + guard = state.ctx.mkNot(condition), + completion = completion, + ), + ), + residualGuard = null, + ), + ) + } + } + + private object SupportedTrueResidualFalseProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val result = requireNotNull(call.arguments.single().resolved) + val condition = result.asExpr(state.ctx.boolSort) + val successor = TsUnknownCallModelSuccessor( + guard = condition, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "partial-model", + precision = TsUnknownCallModelPrecision.PARTIAL, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.mkNot(condition), + ), + ) + } + } + + private object ExceptionalModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val successor = TsUnknownCallModelSuccessor( + guard = state.ctx.trueExpr, + completion = TsUnknownCallModelCompletion.Exceptional { + ctx.mkUndefinedValue() to EtsStringType + }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "exceptional-model", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = null, + ), + ) + } + } + + private object StatefulAliasModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val argument = requireNotNull(call.arguments.single().resolved) + val successor = TsUnknownCallModelSuccessor( + guard = state.ctx.trueExpr, + completion = TsUnknownCallModelCompletion.Normal { argument }, + applyStateChanges = { addedArtificialLocals += STATE_CHANGE_MARKER }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "stateful-alias-model", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = null, + ), ) - return TsUnknownCallModelApplication.Applied(modelId = "forking-model") } } @@ -658,6 +824,21 @@ class TsUnknownCallDispatcherTest { } private companion object { + const val STATE_CHANGE_MARKER = "semantic-model-state-change" + + fun exactExecution(): TsUnknownCallModelExecution = execution(residualGuard = null) + + fun execution(residualGuard: UBoolExpr?): TsUnknownCallModelExecution = + TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = mockk(), + completion = TsUnknownCallModelCompletion.Normal { mockk>() }, + ), + ), + residualGuard = residualGuard, + ) + val machineOptions = UMachineOptions( pathSelectionStrategies = listOf(PathSelectionStrategy.TARGETED), exceptionsPropagation = true, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt new file mode 100644 index 000000000..6cc5be150 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt @@ -0,0 +1,119 @@ +package org.usvm.machine.call + +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TsUnknownCallModelRegistryTest { + @Test + fun `descriptor IDs and supported domains must be non blank`() { + assertFailsWith { + descriptor(id = " ") + } + assertFailsWith { + descriptor(id = "model", domainId = "") + } + assertFailsWith { + descriptor(id = "model", domainDescription = " ") + } + } + + @Test + fun `duplicate IDs are rejected`() { + val error = assertFailsWith { + TsUnknownCallModelRegistry( + registrations = listOf(registration("duplicate"), registration("duplicate")), + ) + } + + assertEquals("Duplicate semantic model IDs: duplicate", error.message) + } + + @Test + fun `ambiguous matches report stable sorted IDs`() { + val registry = TsUnknownCallModelRegistry( + registrations = listOf(registration("z-model"), registration("a-model")), + ).freeze() + + val error = assertFailsWith { + registry.select(mockk()) + } + + assertEquals("Ambiguous semantic models matched: a-model, z-model", error.message) + } + + @Test + fun `unknown enabled IDs are rejected`() { + val registry = TsUnknownCallModelRegistry(listOf(registration("known"))) + + val error = assertFailsWith { + registry.freeze(enabledModelIds = setOf("missing")) + } + + assertEquals("Unknown semantic model IDs: missing", error.message) + } + + @Test + fun `selection and fingerprint do not depend on registration order`() { + val forward = listOf( + registration(id = "a", matches = false), + registration(id = "b", matches = true), + ) + val call = mockk() + + val first = TsUnknownCallModelRegistry(forward).freeze() + val second = TsUnknownCallModelRegistry(forward.reversed()).freeze() + + assertEquals("b", first.select(call)?.descriptor?.id) + assertEquals("b", second.select(call)?.descriptor?.id) + assertEquals(first.fingerprint, second.fingerprint) + } + + @Test + fun `frozen subset is detached and changes fingerprint`() { + val mutableIds = mutableSetOf("a") + val registry = TsUnknownCallModelRegistry( + listOf(registration("a"), registration("b")), + ) + + val onlyA = registry.freeze(enabledModelIds = mutableIds) + mutableIds += "b" + val both = registry.freeze() + + assertEquals(listOf("a"), onlyA.descriptors.map { it.id }) + assertNotEquals(onlyA.fingerprint, both.fingerprint) + assertTrue(onlyA.fingerprint.matches(Regex("[0-9a-f]{64}"))) + } + + private fun registration( + id: String, + matches: Boolean = true, + ) = TsUnknownCallModelRegistration( + descriptor = descriptor(id = id, matches = matches), + implementation = FakeImplementation, + ) + + private fun descriptor( + id: String, + domainId: String = "test-domain", + domainDescription: String = "Test-only supported domain", + matches: Boolean = true, + ) = TsUnknownCallModelDescriptor( + id = id, + matcher = TsUnknownCallModelMatcher { matches }, + supportedDomain = TsUnknownCallModelSupportedDomain( + id = domainId, + description = domainDescription, + ), + precision = TsUnknownCallModelPrecision.EXACT, + implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, + ) + + private object FakeImplementation : TsUnknownCallModelImplementation { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC + } +} diff --git a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts index c78d4027f..ee46e3e9f 100644 --- a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts +++ b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts @@ -18,6 +18,11 @@ declare class ExternalBoolean { static convert(value: boolean): boolean; } +declare class ExternalModeledCall { + static identity(value: ExternalReceiver): ExternalReceiver; + static fail(): number; +} + class KnownReceiver { known(): number { return 1; @@ -68,6 +73,17 @@ class CallFallbackBaseline { return ExternalBoolean.convert(value); } + modeledUnknownCallReturnsAlias(receiver: ExternalReceiver): number { + if (ExternalModeledCall.identity(receiver) === receiver) { + return 122; + } + return 0; + } + + modeledUnknownCallThrows(): number { + return ExternalModeledCall.fail(); + } + anyReceiverWithKnownMethodContinues(receiver: any): number { receiver.known(); return 102; diff --git a/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts new file mode 100644 index 000000000..418576799 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts @@ -0,0 +1,24 @@ +// noinspection JSUnusedGlobalSymbols + +class ArrayElement {} + +export class ArrayPopIntrinsic { + emptyArray(): number | undefined { + const values: number[] = []; + return values.pop(); + } + + nonEmptyArray(): number { + const values = [10, 20, 30]; + return values.pop()! + values.length; + } + + aliasedElement(): number { + const element = new ArrayElement(); + const values: ArrayElement[] = [element]; + if (values.pop() === element) { + return 42; + } + return 0; + } +} From 7fb29a74d39b75a20ef3196e45cce6b25151c1de Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 28 Aug 2026 23:58:36 +0300 Subject: [PATCH 02/18] [TS] Document fake value representation invariants --- .../main/kotlin/org/usvm/machine/TsContext.kt | 22 +++++++++++++++++++ .../org/usvm/machine/types/EtsFakeType.kt | 16 ++++++++++++++ .../org/usvm/machine/types/FakeExprUtil.kt | 15 +++++++++++++ 3 files changed, 53 insertions(+) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 9ae27cb06..52a9f0a9d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -183,6 +183,12 @@ class TsContext( fun UConcreteHeapRef.getFakeType(scope: TsStepScope): EtsFakeType = scope.calcOnState { getFakeType(memory) } + /** + * Returns whether this expression is the storage identity of a synthetic fake-value wrapper. + * + * A positive result says nothing about the wrapper's active runtime kind. In particular, the expression must not + * be used as the represented object reference; inspect [EtsFakeType.refTypeExpr] and extract the reference payload. + */ @OptIn(ExperimentalContracts::class) fun UExpr<*>.isFakeObject(): Boolean { contract { @@ -238,6 +244,12 @@ class TsContext( } } + /** + * Returns the reference payload of a fake-value wrapper without adding a reference-kind constraint. + * + * Use this only when [EtsFakeType.refTypeExpr] is already known or the caller guards the result equivalently. + * Otherwise use [unwrapRefWithPathConstraint]. + */ fun UHeapRef.unwrapRef(scope: TsStepScope): UHeapRef { if (isFakeObject()) { return extractRef(scope) @@ -245,6 +257,9 @@ class TsContext( return this } + /** + * Extracts the reference payload from a fake-value wrapper and constrains that wrapper to the reference kind. + */ fun UHeapRef.unwrapRefWithPathConstraint(scope: TsStepScope): UHeapRef { if (isFakeObject()) { scope.assert(getFakeType(scope).refTypeExpr) @@ -285,6 +300,12 @@ class TsContext( return memory.read(lValue) } + /** + * Reads the reference payload without constraining [EtsFakeType.refTypeExpr]. + * + * This operation alone does not prove that the wrapped value is a reference. The caller must either assert the + * discriminator through a live [TsStepScope] or use the payload only under an equivalent guard. + */ fun UConcreteHeapRef.extractRef(memory: UReadOnlyMemory<*>): UHeapRef { check(isFakeObject()) val lValue = getIntermediateRefLValue(address) @@ -299,6 +320,7 @@ class TsContext( return scope.calcOnState { extractFp(memory) } } + /** Reads the reference payload through [scope] without adding a reference-kind constraint. */ fun UConcreteHeapRef.extractRef(scope: TsStepScope): UHeapRef { return scope.calcOnState { extractRef(memory) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/EtsFakeType.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/EtsFakeType.kt index 151b5d911..a4b7c4e88 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/EtsFakeType.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/EtsFakeType.kt @@ -6,6 +6,22 @@ import org.usvm.UExpr import org.usvm.USort import org.usvm.machine.TsContext +/** + * Type metadata for a synthetic wrapper representing a TypeScript value whose runtime kind is not known. + * + * The wrapper is identified by a special concrete heap reference, but that reference is only the wrapper's storage + * identity. It is not the object reference represented by the value. The possible boolean, number, and reference + * payloads are stored separately in the wrapper's intermediate fields. + * + * [boolTypeExpr], [fpTypeExpr], and [refTypeExpr] are symbolic discriminators. Exactly one of them must be true for + * every feasible state. Consumers should therefore keep the wrapper intact until the runtime kind is proven. In + * particular, using the reference payload requires constraining [refTypeExpr] and then extracting that payload; + * treating the wrapper reference itself as the payload or narrowing solely from a static TypeScript type is unsound. + * + * If narrowing establishes that the represented value is a particular object, the corresponding discriminator + * constraints must also be propagated to previously materialized fake values that may refer to the same object. + * Constraining only the extracted address breaks alias consistency. + */ class EtsFakeType( val boolTypeExpr: UBoolExpr, val fpTypeExpr: UBoolExpr, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 2dcd2bfb8..59fa51a6b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -15,6 +15,21 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsState import org.usvm.memory.ULValue +/** + * Creates a fresh synthetic wrapper for a TypeScript value with a not necessarily known runtime kind. + * + * Non-null arguments initialize the corresponding boolean, number, and reference payload fields. When exactly one + * payload is supplied, the wrapper is constrained to that runtime kind. When multiple payloads are supplied, all + * three kind discriminators remain symbolic and [EtsFakeType.mkExactlyOneTypeConstraint] selects exactly one active + * representation. Callers that model a completely unknown value should therefore supply all three payloads. + * + * The returned concrete heap reference identifies the wrapper, not its reference payload. Consumers must preserve + * the wrapper or explicitly constrain the appropriate discriminator before extracting a payload. + * + * [scope] may be `null` only while constructing the initial state, before solver models exist. During symbolic + * execution a live scope is required so that adding the exactly-one constraint also checks satisfiability and updates + * the state's models. + */ fun TsState.mkFakeValue( scope: TsStepScope?, // pass `null` only in the initial state, where `scope` is not available! boolValue: UBoolExpr? = null, From 67ed3251337ab5190f04b5d8c95bd17cd6858e94 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 01:58:41 +0300 Subject: [PATCH 03/18] [TS Calls] Harden guarded semantic model execution --- .../src/main/kotlin/org/usvm/api/TsMock.kt | 25 ++- .../main/kotlin/org/usvm/machine/TsMachine.kt | 1 + .../call/TsIntrinsicUnknownCallModels.kt | 5 +- .../usvm/machine/call/TsUnknownCallModel.kt | 3 +- .../call/TsUnknownCallModelRegistry.kt | 11 +- .../usvm/machine/call/TsUnknownCallProfile.kt | 99 ++++++++- .../usvm/machine/interpreter/TsInterpreter.kt | 5 + .../call/TsArrayPopIntrinsicModelTest.kt | 76 ++++++- .../call/TsUnknownCallDispatcherTest.kt | 52 +++++ ...UnknownCallExecutionGuardValidationTest.kt | 210 ++++++++++++++++++ .../call/TsUnknownCallModelRegistryTest.kt | 39 +++- .../baseline/CallFallbackBaseline.ts | 8 + .../resources/models/ArrayPopIntrinsic.ts | 51 +++++ 13 files changed, 562 insertions(+), 23 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt index 237e0f412..1ef5e5f59 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt @@ -16,30 +16,33 @@ fun mockMethodCall( method: EtsMethodSignature, resultType: EtsType = method.returnType, ) { + val result = makeFreshUnknownCallResult(scope = scope, resultType = resultType) + scope.doWithState { - mockMethodCall(method = method, resultType = resultType) + setMockMethodCallResult(method = method, result = result) } } -/** Creates a fresh opaque result directly on this state without applying callee effects or exceptions. */ -fun TsState.mockMethodCall( +/** Stores a prepared opaque result on this state without applying callee effects or exceptions. */ +internal fun TsState.setMockMethodCallResult( method: EtsMethodSignature, - resultType: EtsType = method.returnType, + result: UExpr<*>, ) { - val result = freshUnknownCallResult(resultType) methodResult = TsMethodResult.Success.MockedCall(result, method) } -private fun TsState.freshUnknownCallResult(resultType: EtsType): UExpr<*> { - if (resultType is EtsVoidType) { - return ctx.mkUndefinedValue() - } +/** Creates a fresh opaque result through [scope], keeping solver models consistent with new constraints. */ +internal fun makeFreshUnknownCallResult( + scope: TsStepScope, + resultType: EtsType, +): UExpr<*> = scope.calcOnState { + if (resultType is EtsVoidType) return@calcOnState ctx.mkUndefinedValue() - return when (val sort = ctx.typeToSort(resultType)) { + when (val sort = ctx.typeToSort(resultType)) { is UAddressSort -> makeSymbolicRefUntyped() is TsUnresolvedSort -> mkFakeValue( - scope = null, + scope = scope, boolValue = makeSymbolicPrimitive(ctx.boolSort), fpValue = makeSymbolicPrimitive(ctx.fp64Sort), refValue = makeSymbolicRefUntyped(), diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index f08a486a4..eca353f53 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -74,6 +74,7 @@ class TsMachine( options = tsOptions, observer = observer, unknownCallDispatcher = resolvedUnknownCallDispatcher, + throwExceptionOnStepFailure = options.throwExceptionOnStepFailure, ) private val cfgStatistics = CfgStatisticsImpl(graph) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt index d2899c1a5..b0a9838e3 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt @@ -55,7 +55,7 @@ object TsBuiltInUnknownCallModels { }, supportedDomain = TsUnknownCallModelSupportedDomain( id = "native-array-pop", - description = "Resolved one-dimensional native arrays with no arguments and a resolved element sort", + description = "Resolved one-dimensional native arrays with no arguments and a primitive element sort", ), precision = TsUnknownCallModelPrecision.PARTIAL, implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, @@ -130,6 +130,9 @@ private object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { ?: return null val elementSort = state.ctx.typeToSort(arrayType.elementType) + if (elementSort == state.ctx.addressSort) { + return null + } return ArrayPopInput( array = array, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt index f1aab3a9c..9566b54a0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -74,7 +74,8 @@ class TsUnknownCallModelSuccessor( * A backend-neutral semantic-model execution plan. * * [residualGuard] denotes the unsupported part of a partial model's domain. Together, successor guards and the - * residual guard must partition the current call domain. + * residual guard must partition the current call domain. The dispatcher validates disjointness and coverage before + * applying any successor. */ class TsUnknownCallModelExecution( successors: List, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt index e80ed4cca..c30456c62 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt @@ -57,7 +57,7 @@ class TsUnknownCallModelRegistry( } } - /** Freezes an immutable enabled subset; `null` enables the complete registered catalog. */ + /** Freezes an immutable enabled subset and validates its backends; `null` enables the complete catalog. */ fun freeze(enabledModelIds: Set? = null): TsFrozenUnknownCallModelRegistry { val enabledIds = enabledModelIds?.toSet() val knownIds = registrations.mapTo(mutableSetOf()) { it.descriptor.id } @@ -71,6 +71,15 @@ class TsUnknownCallModelRegistry( null -> registrations else -> registrations.filter { it.descriptor.id in enabledIds } } + val missingBackendKinds = enabledRegistrations + .map { it.descriptor.implementationKind } + .distinct() + .filterNot(backends::containsKey) + .sortedBy { it.name } + + require(missingBackendKinds.isEmpty()) { + "Missing semantic model backends: ${missingBackendKinds.joinToString()}" + } return TsFrozenUnknownCallModelRegistry(enabledRegistrations, backends) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt index 825cd8726..d24b6164c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt @@ -1,12 +1,21 @@ package org.usvm.machine.call import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsType +import org.usvm.UBoolExpr +import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall +import org.usvm.api.setMockMethodCallResult +import org.usvm.isTrue import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.state.newStmt +import org.usvm.solver.USatResult +import org.usvm.solver.USolverResult +import org.usvm.solver.UUnknownResult +import org.usvm.solver.UUnsatResult /** The externally observable decision made for a call that could not be executed normally. */ enum class TsUnknownCallOutcome { @@ -141,8 +150,17 @@ class TsProfileUnknownCallDispatcher( call: TsUnknownCall, application: TsUnknownCallModelApplication.Applied, ): TsUnknownCallOutcome { + validateExecutionGuards(scope = scope, application = application) + val residualGuard = application.execution.residualGuard val residualPolicy = profile.residualPolicyFor(call) + val freshResidualResult = if ( + residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN + ) { + makeFreshUnknownCallResult(scope = scope, resultType = call.resultType) + } else { + null + } val stoppedResidualIsSatisfiable = residualGuard != null && residualPolicy == TsResidualCallPolicy.STOP_PATH && scope.checkSat(residualGuard) != null @@ -169,7 +187,10 @@ class TsProfileUnknownCallDispatcher( if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { guardedStateChanges += residualGuard to { - mockMethodCall(method = call.callee, resultType = call.resultType) + setMockMethodCallResult( + method = call.callee, + result = requireNotNull(freshResidualResult), + ) newStmt(call.callSite) freshResidualApplied = true @@ -199,6 +220,65 @@ class TsProfileUnknownCallDispatcher( } } + private fun validateExecutionGuards( + scope: TsStepScope, + application: TsUnknownCallModelApplication.Applied, + ) = scope.doWithState { + val namedGuards = buildList { + application.execution.successors.forEachIndexed { index, successor -> + add(NamedGuard(name = "successor[$index]", guard = successor.guard)) + } + application.execution.residualGuard?.let { residualGuard -> + add(NamedGuard(name = "residual", guard = residualGuard)) + } + } + val overlaps = buildList { + namedGuards.forEachIndexed { firstIndex, first -> + namedGuards.drop(firstIndex + 1).forEach { second -> + add( + GuardOverlap( + firstName = first.name, + secondName = second.name, + condition = ctx.mkAnd(first.guard, second.guard), + ) + ) + } + } + } + val coveredDomain = ctx.mkOr(namedGuards.map(NamedGuard::guard)) + val uncoveredDomain = ctx.mkNot(coveredDomain) + val invalidity = ctx.mkOr(overlaps.map(GuardOverlap::condition) + uncoveredDomain) + val validationConstraints = pathConstraints.clone() + validationConstraints += invalidity + + val solverResult = ctx.solver().check(validationConstraints) + solverResult.requireConclusiveGuardValidation(modelId = application.modelId) + + when (solverResult) { + is UUnsatResult -> { + // The invalidity condition is unreachable, so the guards form a partition. + } + + is USatResult -> { + val witnessedOverlap = overlaps.firstOrNull { overlap -> + solverResult.model.eval(overlap.condition).isTrue + } + if (witnessedOverlap != null) { + error( + "Semantic model ${application.modelId} produced overlapping guards: " + + "${witnessedOverlap.firstName}, ${witnessedOverlap.secondName}" + ) + } + + error("Semantic model ${application.modelId} guards do not cover the current call domain") + } + + is UUnknownResult -> { + error("Unreachable after conclusive guard validation") + } + } + } + private fun modelStateChange( call: TsUnknownCall, application: TsUnknownCallModelApplication.Applied, @@ -258,3 +338,20 @@ class TsProfileUnknownCallDispatcher( decision = decision, ) } + +internal fun USolverResult<*>.requireConclusiveGuardValidation(modelId: String) { + check(this !is UUnknownResult) { + "Semantic model $modelId guards could not be validated: solver returned UNKNOWN" + } +} + +private data class NamedGuard( + val name: String, + val guard: UBoolExpr, +) + +private data class GuardOverlap( + val firstName: String, + val secondName: String, + val condition: UBoolExpr, +) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 0bf9f180b..cc8e915f6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -96,6 +96,7 @@ class TsInterpreter( private val options: TsOptions, private val observer: TsInterpreterObserver? = null, private val unknownCallDispatcher: TsUnknownCallDispatcher, + private val throwExceptionOnStepFailure: Boolean = false, ) : UInterpreter() { private val forkBlackList: UForkBlackList = UForkBlackList.createDefault() @@ -146,6 +147,10 @@ class TsInterpreter( } } } catch (e: Exception) { + if (throwExceptionOnStepFailure) { + throw e + } + logger.error { "Exception: $e\n${e.stackTrace.take(5).joinToString("\n") { " $it" }}" } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt index 279f0bfbf..67ab848ce 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Disabled import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy @@ -12,6 +13,8 @@ import org.usvm.api.TsTestValue import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.util.TsTestResolver import org.usvm.util.getResourcePath import kotlin.test.Test @@ -47,12 +50,54 @@ class TsArrayPopIntrinsicModelTest { } @Test - fun `array pop preserves a returned reference alias`() { - val result = analyze(methodName = "aliasedElement") + fun `allocated reference array uses residual fallback`() { + assertUsesResidualFallback(methodName = "aliasedElement") + } - assertTrue(result.values.isNotEmpty(), "No final states; events=${result.events}") - assertEquals(42.0, assertIs(result.values.single()).number) - assertEquals(listOf("ts.array.pop"), result.modelIds) + @Test + fun `symbolic reference array uses residual fallback`() { + assertUsesResidualFallback(methodName = "symbolicReferenceArray") + } + + @Test + fun `symbolic primitive array remains in the supported domain`() { + val result = analyze(methodName = "symbolicNumberArray") + + assertTrue(result.values.isNotEmpty()) + assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) + } + + @Test + fun `symbolic unknown array uses residual fallback`() { + assertUsesResidualFallback(methodName = "symbolicUnknownArray") + } + + @Test + fun `allocated reference array with symbolic write uses residual fallback`() { + val result = analyze(methodName = "allocatedReferenceArrayWithSymbolicWrite") + + val event = result.events.single() + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, event.outcome) + assertIs(event.decision) + } + + @Test + fun `array pop with arguments uses residual fallback`() { + assertUsesResidualFallback(methodName = "popWithArguments") + } + + @Disabled("Tracked by https://github.com/UnitTestBot/usvm/issues/379") + @Test + fun `symbolic reference array pop preserves fake value representations`() { + val states = analyzeStates(methodName = "symbolicReferenceArrayPreservesFakeValue") + + assertTrue( + states.any { state -> + val result = (state.methodResult as? TsMethodResult.Success)?.value + result == state.ctx.mkFp(44.0, state.ctx.fp64Sort) + }, + "Expected the number representation to reach return 44", + ) } @Test @@ -115,6 +160,27 @@ class TsArrayPopIntrinsicModelTest { } } + private fun assertUsesResidualFallback(methodName: String) { + val result = analyze(methodName = methodName) + + assertTrue(result.values.isEmpty()) + val event = result.events.single() + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, event.outcome) + assertIs(event.decision) + } + + private fun analyzeStates(methodName: String): List { + val method = method(methodName) + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyze(listOf(method)) + } + } + private fun method(name: String): EtsMethod = scene.projectClasses .single { it.name == "ArrayPopIntrinsic" } .methods diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index e06985687..1ed384901 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -25,6 +25,7 @@ import org.usvm.UExpr import org.usvm.UMachineOptions import org.usvm.api.targets.ReachabilityObserver import org.usvm.api.targets.TsReachabilityTarget +import org.usvm.isTrue import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions @@ -175,6 +176,27 @@ class TsUnknownCallDispatcherTest { } } + @Test + fun `fresh fallback keeps fake type constraints in state models`() { + val states = analyzeAllStates( + methodName = "freshUnknownCallResult", + profile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, + ) + + assertFreshResultModelSatisfiesFakeType(states.single()) + } + + @Test + fun `partial residual fallback keeps fake type constraints in state models`() { + val states = analyzeAllStates( + methodName = "freshUnknownCallResult", + profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, + modelProvider = UnsupportedPartialModelProvider, + ) + + assertFreshResultModelSatisfiesFakeType(states.single()) + } + @Test fun `partial model sends only residual domain to fresh fallback`() { val observer = RecordingUnknownCallObserver() @@ -611,6 +633,18 @@ class TsUnknownCallDispatcherTest { } } + private fun assertFreshResultModelSatisfiesFakeType(state: TsState) { + val result = assertIs(state.methodResult).value + val fakeValue = assertIs(result) + val exactlyOneType = state.ctx.run { + assertTrue(fakeValue.isFakeObject()) + fakeValue.getFakeType(state.memory).mkExactlyOneTypeConstraint(this) + } + + assertTrue(state.models.isNotEmpty()) + assertTrue(state.models.all { model -> model.eval(exactlyOneType).isTrue }) + } + private class RecordingUnknownCallDispatcher : TsUnknownCallDispatcher { val calls = mutableListOf() val receiverIsAssociatedFunction = mutableListOf() @@ -747,6 +781,24 @@ class TsUnknownCallDispatcherTest { } } + private object UnsupportedPartialModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val successor = TsUnknownCallModelSuccessor( + guard = state.ctx.falseExpr, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "unsupported-partial-model", + precision = TsUnknownCallModelPrecision.PARTIAL, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.trueExpr, + ), + ) + } + } + private object StatefulAliasModelProvider : TsUnknownCallModelProvider { override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { val argument = requireNotNull(call.arguments.single().resolved) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt new file mode 100644 index 000000000..a61370b4b --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt @@ -0,0 +1,210 @@ +package org.usvm.machine.call + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsState +import org.usvm.solver.UUnknownResult +import org.usvm.util.getResourcePath +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration + +class TsUnknownCallExecutionGuardValidationTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/baseline/CallFallbackBaseline.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `overlapping model successor guards are rejected`() { + assertInvalidModel( + methodName = "declaredMethodWithoutBodyContinues", + profile = TsUnknownCallProfiles.MODELS_THEN_STOP, + modelProvider = OverlappingSuccessorsModelProvider, + expectedMessage = "Semantic model overlapping-successors produced overlapping guards: " + + "successor[0], successor[1]", + ) + } + + @Test + fun `overlapping model successor and residual guards are rejected`() { + assertInvalidModel( + methodName = "declaredMethodWithoutBodyContinues", + profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, + modelProvider = OverlappingResidualModelProvider, + expectedMessage = "Semantic model overlapping-residual produced overlapping guards: successor[0], residual", + ) + } + + @Test + fun `exact model successor guards must cover the current call domain`() { + assertInvalidModel( + methodName = "modeledUnknownCallForks", + profile = TsUnknownCallProfiles.MODELS_THEN_STOP, + modelProvider = IncompleteExactModelProvider, + expectedMessage = "Semantic model incomplete-exact guards do not cover the current call domain", + ) + } + + @Test + fun `partial model successor and residual guards must cover the current call domain`() { + assertInvalidModel( + methodName = "modeledUnknownCallForks", + profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, + modelProvider = IncompletePartialModelProvider, + expectedMessage = "Semantic model incomplete-partial guards do not cover the current call domain", + ) + } + + @Test + fun `unknown solver result cannot validate execution guards`() { + val exception = assertFailsWith { + UUnknownResult().requireConclusiveGuardValidation(modelId = "unknown-guards") + } + + assertEquals( + "Semantic model unknown-guards guards could not be validated: solver returned UNKNOWN", + exception.message, + ) + } + + private fun assertInvalidModel( + methodName: String, + profile: TsUnknownCallProfile, + modelProvider: TsUnknownCallModelProvider, + expectedMessage: String, + ) { + val exception = assertFailsWith { + analyzeAllStates( + methodName = methodName, + profile = profile, + modelProvider = modelProvider, + ) + } + + assertEquals(expectedMessage, exception.message) + } + + private fun analyzeAllStates( + methodName: String, + profile: TsUnknownCallProfile, + modelProvider: TsUnknownCallModelProvider, + ): List { + val method = method(methodName) + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(unknownCallProfile = profile), + unknownCallModelProvider = modelProvider, + ).use { machine -> + machine.analyze(listOf(method)) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "CallFallbackBaseline" } + .methods + .single { it.name == name } + + private object OverlappingSuccessorsModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() } + + return TsUnknownCallModelApplication.Applied( + modelId = "overlapping-successors", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor(guard = state.ctx.trueExpr, completion = completion), + TsUnknownCallModelSuccessor(guard = state.ctx.trueExpr, completion = completion), + ), + residualGuard = null, + ), + ) + } + } + + private object OverlappingResidualModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val successor = TsUnknownCallModelSuccessor( + guard = state.ctx.trueExpr, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "overlapping-residual", + precision = TsUnknownCallModelPrecision.PARTIAL, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.trueExpr, + ), + ) + } + } + + private object IncompleteExactModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val condition = requireNotNull(call.arguments.single().resolved).asExpr(state.ctx.boolSort) + val successor = TsUnknownCallModelSuccessor( + guard = condition, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "incomplete-exact", + precision = TsUnknownCallModelPrecision.EXACT, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = null, + ), + ) + } + } + + private object IncompletePartialModelProvider : TsUnknownCallModelProvider { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val condition = requireNotNull(call.arguments.single().resolved).asExpr(state.ctx.boolSort) + val successor = TsUnknownCallModelSuccessor( + guard = condition, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + + return TsUnknownCallModelApplication.Applied( + modelId = "incomplete-partial", + precision = TsUnknownCallModelPrecision.PARTIAL, + execution = TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.falseExpr, + ), + ) + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + stopOnCoverage = 0, + stopOnTargetsReached = false, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + throwExceptionOnStepFailure = true, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt index 6cc5be150..6684cce0f 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt @@ -1,6 +1,7 @@ package org.usvm.machine.call import io.mockk.mockk +import org.usvm.machine.state.TsState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -36,6 +37,7 @@ class TsUnknownCallModelRegistryTest { fun `ambiguous matches report stable sorted IDs`() { val registry = TsUnknownCallModelRegistry( registrations = listOf(registration("z-model"), registration("a-model")), + backends = listOf(FakeBackend), ).freeze() val error = assertFailsWith { @@ -56,6 +58,19 @@ class TsUnknownCallModelRegistryTest { assertEquals("Unknown semantic model IDs: missing", error.message) } + @Test + fun `enabled implementation kinds require configured backends`() { + val registry = TsUnknownCallModelRegistry( + registrations = listOf(registration("model-without-backend")), + ) + + val error = assertFailsWith { + registry.freeze() + } + + assertEquals("Missing semantic model backends: INTRINSIC", error.message) + } + @Test fun `selection and fingerprint do not depend on registration order`() { val forward = listOf( @@ -64,8 +79,14 @@ class TsUnknownCallModelRegistryTest { ) val call = mockk() - val first = TsUnknownCallModelRegistry(forward).freeze() - val second = TsUnknownCallModelRegistry(forward.reversed()).freeze() + val first = TsUnknownCallModelRegistry( + registrations = forward, + backends = listOf(FakeBackend), + ).freeze() + val second = TsUnknownCallModelRegistry( + registrations = forward.reversed(), + backends = listOf(FakeBackend), + ).freeze() assertEquals("b", first.select(call)?.descriptor?.id) assertEquals("b", second.select(call)?.descriptor?.id) @@ -76,7 +97,8 @@ class TsUnknownCallModelRegistryTest { fun `frozen subset is detached and changes fingerprint`() { val mutableIds = mutableSetOf("a") val registry = TsUnknownCallModelRegistry( - listOf(registration("a"), registration("b")), + registrations = listOf(registration("a"), registration("b")), + backends = listOf(FakeBackend), ) val onlyA = registry.freeze(enabledModelIds = mutableIds) @@ -116,4 +138,15 @@ class TsUnknownCallModelRegistryTest { override val kind: TsUnknownCallModelImplementationKind = TsUnknownCallModelImplementationKind.INTRINSIC } + + private object FakeBackend : TsUnknownCallModelBackend { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC + + override fun execute( + implementation: TsUnknownCallModelImplementation, + state: TsState, + call: TsUnknownCall, + ): TsUnknownCallModelExecution = error("Fake backend must not execute in registry metadata tests") + } } diff --git a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts index ee46e3e9f..7b80de0eb 100644 --- a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts +++ b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts @@ -18,6 +18,10 @@ declare class ExternalBoolean { static convert(value: boolean): boolean; } +declare class ExternalAny { + static value(): any; +} + declare class ExternalModeledCall { static identity(value: ExternalReceiver): ExternalReceiver; static fail(): number; @@ -84,6 +88,10 @@ class CallFallbackBaseline { return ExternalModeledCall.fail(); } + freshUnknownCallResult(): any { + return ExternalAny.value(); + } + anyReceiverWithKnownMethodContinues(receiver: any): number { receiver.known(); return 102; diff --git a/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts index 418576799..0258b3974 100644 --- a/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts +++ b/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // noinspection JSUnusedGlobalSymbols class ArrayElement {} @@ -21,4 +22,54 @@ export class ArrayPopIntrinsic { } return 0; } + + symbolicReferenceArray(values: ArrayElement[]): number { + values.pop(); + return 45; + } + + symbolicNumberArray(values: number[]): number { + values.pop(); + return 46; + } + + symbolicUnknownArray(values: any[]): number { + values.pop(); + return 47; + } + + allocatedReferenceArrayWithSymbolicWrite(index: number, value: any): number { + if (index !== 1) { + return 0; + } + + const values: ArrayElement[] = [new ArrayElement(), new ArrayElement()]; + values[index] = value; + const popped: any = values.pop(); + if (typeof popped === "number") { + return 45; + } + + return 0; + } + + popWithArguments(): number { + const values = [1]; + values.pop(0); + return 48; + } + + symbolicReferenceArrayPreservesFakeValue(values: ArrayElement[], value: any): number { + if (values.length !== 1) { + return 0; + } + + values[0] = value; + const popped: any = values.pop(); + if (typeof popped === "number") { + return 44; + } + + return 0; + } } From 160e43eaf88a51c7e8e29a520bdf6cd82c2e05b3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 29 Aug 2026 19:25:29 +0300 Subject: [PATCH 04/18] [TS Calls] Remove redundant named arguments --- .../src/main/kotlin/org/usvm/api/TsMock.kt | 6 +-- .../call/TsIntrinsicUnknownCallModels.kt | 10 ++-- .../org/usvm/machine/call/TsUnknownCall.kt | 18 +++---- .../call/TsUnknownCallModelRegistry.kt | 6 +-- .../usvm/machine/call/TsUnknownCallProfile.kt | 51 ++++++++----------- .../usvm/machine/expr/CallApproximations.kt | 13 ++--- .../call/TsArrayPopIntrinsicModelTest.kt | 4 +- ...UnknownCallExecutionGuardValidationTest.kt | 8 +-- .../call/TsUnknownCallModelRegistryTest.kt | 2 +- 9 files changed, 47 insertions(+), 71 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt index 1ef5e5f59..7ab3d97d9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt @@ -16,10 +16,10 @@ fun mockMethodCall( method: EtsMethodSignature, resultType: EtsType = method.returnType, ) { - val result = makeFreshUnknownCallResult(scope = scope, resultType = resultType) + val result = makeFreshUnknownCallResult(scope, resultType) scope.doWithState { - setMockMethodCallResult(method = method, result = result) + setMockMethodCallResult(method, result) } } @@ -42,7 +42,7 @@ internal fun makeFreshUnknownCallResult( is UAddressSort -> makeSymbolicRefUntyped() is TsUnresolvedSort -> mkFakeValue( - scope = scope, + scope, boolValue = makeSymbolicPrimitive(ctx.boolSort), fpValue = makeSymbolicPrimitive(ctx.fp64Sort), refValue = makeSymbolicRefUntyped(), diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt index b0a9838e3..8e52fa342 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt @@ -39,7 +39,7 @@ object TsIntrinsicUnknownCallModelBackend : TsUnknownCallModelBackend { "INTRINSIC backend requires TsIntrinsicUnknownCallModelImplementation, got ${implementation::class}" } - return intrinsic.model.execute(state = state, call = call) + return intrinsic.model.execute(state, call) } } @@ -74,7 +74,7 @@ object TsBuiltInUnknownCallModels { private object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { override fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { - val input = resolveInput(state = state, call = call) + val input = resolveInput(state, call) ?: return unsupportedExecution(state) val lengthLValue = mkArrayLengthLValue(input.array, input.arrayType) @@ -134,11 +134,7 @@ private object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { return null } - return ArrayPopInput( - array = array, - arrayType = arrayType, - elementSort = elementSort, - ) + return ArrayPopInput(array, arrayType, elementSort) } private fun unsupportedExecution(state: TsState): TsUnknownCallModelExecution { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index 1497688f8..238667afd 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -160,10 +160,10 @@ internal fun TsUnknownCallDispatcher.dispatch( failureReason: TsUnknownCallFailureReason, resolvedReceiver: UExpr<*>, ) = dispatch( - scope = scope, - call = call.call, - callSite = call.returnSite, - failureReason = failureReason, + scope, + call.call, + call.returnSite, + failureReason, resolvedReceiver = resolvedReceiver, resolvedArguments = call.args, ) @@ -174,11 +174,11 @@ internal fun TsUnknownCallDispatcher.dispatch( failureReason: TsUnknownCallFailureReason, callee: EtsMethodSignature, ) = dispatch( - scope = scope, - call = call.call, - callSite = call.returnSite, - failureReason = failureReason, - callee = callee, + scope, + call.call, + call.returnSite, + failureReason, + callee, resolvedReceiver = call.resolvedReceiver, resolvedArguments = call.args.takeLast(call.call.args.size), ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt index c30456c62..45471e1e8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt @@ -132,11 +132,7 @@ class TsFrozenUnknownCallModelRegistry internal constructor( val backend = checkNotNull(backends[implementationKind]) { "No semantic model backend configured for $implementationKind" } - val execution = backend.execute( - implementation = registration.implementation, - state = state, - call = call, - ) + val execution = backend.execute(registration.implementation, state, call) return TsUnknownCallModelApplication.Applied( modelId = registration.descriptor.id, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt index d24b6164c..1c763c6a1 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt @@ -87,25 +87,21 @@ class TsProfileUnknownCallDispatcher( override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { if (profile.modelLookup == TsUnknownCallModelLookup.DISABLED) { return applyResidualFallback( - scope = scope, - call = call, + scope, + call, reason = TsUnknownCallResidualReason.MODEL_LOOKUP_DISABLED, ) } val application = scope.calcOnState { - modelProvider.apply(state = this, call = call) + modelProvider.apply(this, call) } return when (application) { - is TsUnknownCallModelApplication.Applied -> applyModel( - scope = scope, - call = call, - application = application, - ) + is TsUnknownCallModelApplication.Applied -> applyModel(scope, call, application) TsUnknownCallModelApplication.NotApplicable -> applyResidualFallback( - scope = scope, - call = call, + scope, + call, reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, ) } @@ -122,11 +118,11 @@ class TsProfileUnknownCallDispatcher( TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN } val event = event( - call = call, - outcome = outcome, + call, + outcome, decision = TsUnknownCallDecision.ResidualFallback( - policy = residualPolicy, - reason = reason, + residualPolicy, + reason, ), ) when (residualPolicy) { @@ -150,14 +146,14 @@ class TsProfileUnknownCallDispatcher( call: TsUnknownCall, application: TsUnknownCallModelApplication.Applied, ): TsUnknownCallOutcome { - validateExecutionGuards(scope = scope, application = application) + validateExecutionGuards(scope, application) val residualGuard = application.execution.residualGuard val residualPolicy = profile.residualPolicyFor(call) val freshResidualResult = if ( residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN ) { - makeFreshUnknownCallResult(scope = scope, resultType = call.resultType) + makeFreshUnknownCallResult(scope, call.resultType) } else { null } @@ -170,9 +166,9 @@ class TsProfileUnknownCallDispatcher( var freshResidualApplied = false val guardedStateChanges = application.execution.successors.map { successor -> successor.guard to modelStateChange( - call = call, - application = application, - successor = successor, + call, + application, + successor, onApplied = { modelApplied = true if (modelEventReported) { @@ -187,15 +183,12 @@ class TsProfileUnknownCallDispatcher( if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { guardedStateChanges += residualGuard to { - setMockMethodCallResult( - method = call.callee, - result = requireNotNull(freshResidualResult), - ) + setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) newStmt(call.callSite) freshResidualApplied = true val event = residualEvent( - call = call, + call, policy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, ) observer?.onUnknownCallSafely(event) @@ -206,7 +199,7 @@ class TsProfileUnknownCallDispatcher( if (stoppedResidualIsSatisfiable) { val event = residualEvent( - call = call, + call, policy = TsResidualCallPolicy.STOP_PATH, ) observer?.onUnknownCallSafely(event) @@ -252,7 +245,7 @@ class TsProfileUnknownCallDispatcher( validationConstraints += invalidity val solverResult = ctx.solver().check(validationConstraints) - solverResult.requireConclusiveGuardValidation(modelId = application.modelId) + solverResult.requireConclusiveGuardValidation(application.modelId) when (solverResult) { is UUnsatResult -> { @@ -302,7 +295,7 @@ class TsProfileUnknownCallDispatcher( if (onApplied()) { val event = event( - call = call, + call, outcome = TsUnknownCallOutcome.MODEL_APPLIED, decision = TsUnknownCallDecision.ModelApplied(modelId = application.modelId), ) @@ -314,13 +307,13 @@ class TsProfileUnknownCallDispatcher( call: TsUnknownCall, policy: TsResidualCallPolicy, ) = event( - call = call, + call, outcome = when (policy) { TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN }, decision = TsUnknownCallDecision.ResidualFallback( - policy = policy, + policy, reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, ), ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 9aa5a437c..8e7c845eb 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -111,12 +111,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.pop() method calls if (expr.callee.name == "pop") { - return handleArrayPopCall( - expr = expr, - instanceType = instanceType, - elementSort = elementSort, - resolvedReceiver = instance, - ) + return handleArrayPopCall(expr, instanceType, elementSort, instance) } // Handle `Array.fill() method calls @@ -180,9 +175,9 @@ private fun TsExprResolver.handleArrayPopCall( } dispatcher.dispatch( - scope = scope, - call = expr, - callSite = scope.calcOnState { lastStmt }, + scope, + expr, + scope.calcOnState { lastStmt }, failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, resolvedReceiver = resolvedReceiver, ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt index 67ab848ce..ea1dd94d4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt @@ -103,7 +103,7 @@ class TsArrayPopIntrinsicModelTest { @Test fun `disabled model sends pop to configured residual fallback`() { val enabledModelIds = mutableSetOf("ts.array.pop") - val selection = TsUnknownCallModelSelection(enabledModelIds = enabledModelIds) + val selection = TsUnknownCallModelSelection(enabledModelIds) enabledModelIds.clear() val result = analyze( methodName = "nonEmptyArray", @@ -161,7 +161,7 @@ class TsArrayPopIntrinsicModelTest { } private fun assertUsesResidualFallback(methodName: String) { - val result = analyze(methodName = methodName) + val result = analyze(methodName) assertTrue(result.values.isEmpty()) val event = result.events.single() diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt index a61370b4b..f27312644 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt @@ -70,7 +70,7 @@ class TsUnknownCallExecutionGuardValidationTest { @Test fun `unknown solver result cannot validate execution guards`() { val exception = assertFailsWith { - UUnknownResult().requireConclusiveGuardValidation(modelId = "unknown-guards") + UUnknownResult().requireConclusiveGuardValidation("unknown-guards") } assertEquals( @@ -86,11 +86,7 @@ class TsUnknownCallExecutionGuardValidationTest { expectedMessage: String, ) { val exception = assertFailsWith { - analyzeAllStates( - methodName = methodName, - profile = profile, - modelProvider = modelProvider, - ) + analyzeAllStates(methodName, profile, modelProvider) } assertEquals(expectedMessage, exception.message) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt index 6684cce0f..f45624784 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt @@ -114,7 +114,7 @@ class TsUnknownCallModelRegistryTest { id: String, matches: Boolean = true, ) = TsUnknownCallModelRegistration( - descriptor = descriptor(id = id, matches = matches), + descriptor = descriptor(id, matches = matches), implementation = FakeImplementation, ) From fad3d155bac070f5c37215f479285a4e9f012681 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 31 Aug 2026 12:19:24 +0300 Subject: [PATCH 05/18] [TS Calls] Separate intrinsic model implementations --- .../call/TsBuiltInUnknownCallModels.kt | 14 ++++ .../TsArrayPopIntrinsicModel.kt} | 67 ++++++------------- .../intrinsic/TsIntrinsicUnknownCallModel.kt | 39 +++++++++++ .../TsIntrinsicUnknownCallModelTest.kt | 20 ++++++ 4 files changed, 93 insertions(+), 47 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt rename usvm-ts/src/main/kotlin/org/usvm/machine/call/{TsIntrinsicUnknownCallModels.kt => intrinsic/TsArrayPopIntrinsicModel.kt} (66%) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt new file mode 100644 index 000000000..31fe6a3fc --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt @@ -0,0 +1,14 @@ +package org.usvm.machine.call + +import org.usvm.machine.call.intrinsic.TsArrayPopIntrinsicModel +import org.usvm.machine.call.intrinsic.TsIntrinsicUnknownCallModelBackend + +/** The intentionally small built-in catalog enabled by default for profile-based unknown-call dispatch. */ +object TsBuiltInUnknownCallModels { + const val ARRAY_POP_MODEL_ID: String = TsArrayPopIntrinsicModel.MODEL_ID + + val registry = TsUnknownCallModelRegistry( + registrations = listOf(TsArrayPopIntrinsicModel.registration), + backends = listOf(TsIntrinsicUnknownCallModelBackend), + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt similarity index 66% rename from usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt rename to usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt index 8e52fa342..e82be0e6b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsIntrinsicUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt @@ -1,4 +1,4 @@ -package org.usvm.machine.call +package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType @@ -6,49 +6,29 @@ import org.usvm.UAddressSort import org.usvm.UExpr import org.usvm.USort import org.usvm.api.typeStreamOf +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelDescriptor +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelImplementationKind +import org.usvm.machine.call.TsUnknownCallModelMatcher +import org.usvm.machine.call.TsUnknownCallModelPrecision +import org.usvm.machine.call.TsUnknownCallModelRegistration +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallModelSupportedDomain import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.state.TsState import org.usvm.types.firstOrNull import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue -/** Builds constraint-level execution plans directly from a TypeScript symbolic state. */ -fun interface TsIntrinsicUnknownCallModel { - fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution -} - -/** Opaque registry handle for a Kotlin intrinsic semantic model. */ -class TsIntrinsicUnknownCallModelImplementation( - val model: TsIntrinsicUnknownCallModel, -) : TsUnknownCallModelImplementation { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC -} +/** Partial intrinsic model for `Array.pop` on resolved one-dimensional primitive arrays. */ +internal object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { + const val MODEL_ID: String = "ts.array.pop" -/** Executes intrinsic model handles without exposing them to the common registry or dispatcher contract. */ -object TsIntrinsicUnknownCallModelBackend : TsUnknownCallModelBackend { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC - - override fun execute( - implementation: TsUnknownCallModelImplementation, - state: TsState, - call: TsUnknownCall, - ): TsUnknownCallModelExecution { - val intrinsic = requireNotNull(implementation as? TsIntrinsicUnknownCallModelImplementation) { - "INTRINSIC backend requires TsIntrinsicUnknownCallModelImplementation, got ${implementation::class}" - } - - return intrinsic.model.execute(state, call) - } -} - -/** The intentionally small built-in catalog enabled by default for profile-based unknown-call dispatch. */ -object TsBuiltInUnknownCallModels { - const val ARRAY_POP_MODEL_ID: String = "ts.array.pop" - - private val arrayPopDescriptor = TsUnknownCallModelDescriptor( - id = ARRAY_POP_MODEL_ID, + private val descriptor = TsUnknownCallModelDescriptor( + id = MODEL_ID, matcher = TsUnknownCallModelMatcher { call -> call.failureReason == TsUnknownCallFailureReason.PARTIAL_APPROXIMATION && call.callee.name == "pop" @@ -61,18 +41,11 @@ object TsBuiltInUnknownCallModels { implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, ) - val registry = TsUnknownCallModelRegistry( - registrations = listOf( - TsUnknownCallModelRegistration( - descriptor = arrayPopDescriptor, - implementation = TsIntrinsicUnknownCallModelImplementation(TsArrayPopIntrinsicModel), - ), - ), - backends = listOf(TsIntrinsicUnknownCallModelBackend), + val registration = TsUnknownCallModelRegistration( + descriptor = descriptor, + implementation = TsIntrinsicUnknownCallModelImplementation(this), ) -} -private object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { override fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val input = resolveInput(state, call) ?: return unsupportedExecution(state) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt new file mode 100644 index 000000000..0870cb410 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt @@ -0,0 +1,39 @@ +package org.usvm.machine.call.intrinsic + +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallModelBackend +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelImplementation +import org.usvm.machine.call.TsUnknownCallModelImplementationKind +import org.usvm.machine.state.TsState + +/** Builds constraint-level execution plans directly from a TypeScript symbolic state. */ +fun interface TsIntrinsicUnknownCallModel { + fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution +} + +/** Opaque registry handle for a Kotlin intrinsic semantic model. */ +class TsIntrinsicUnknownCallModelImplementation( + val model: TsIntrinsicUnknownCallModel, +) : TsUnknownCallModelImplementation { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC +} + +/** Executes intrinsic model handles without exposing them to the common registry or dispatcher contract. */ +object TsIntrinsicUnknownCallModelBackend : TsUnknownCallModelBackend { + override val kind: TsUnknownCallModelImplementationKind = + TsUnknownCallModelImplementationKind.INTRINSIC + + override fun execute( + implementation: TsUnknownCallModelImplementation, + state: TsState, + call: TsUnknownCall, + ): TsUnknownCallModelExecution { + val intrinsic = requireNotNull(implementation as? TsIntrinsicUnknownCallModelImplementation) { + "INTRINSIC backend requires TsIntrinsicUnknownCallModelImplementation, got ${implementation::class}" + } + + return intrinsic.model.execute(state, call) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt new file mode 100644 index 000000000..30ce632c5 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt @@ -0,0 +1,20 @@ +package org.usvm.machine.call.intrinsic + +import org.usvm.machine.call.TsUnknownCallModelImplementationKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class TsIntrinsicUnknownCallModelTest { + @Test + fun `array pop registration binds the intrinsic backend`() { + val registration = TsArrayPopIntrinsicModel.registration + + assertEquals(expected = "ts.array.pop", actual = registration.descriptor.id) + assertEquals( + expected = TsUnknownCallModelImplementationKind.INTRINSIC, + actual = registration.descriptor.implementationKind, + ) + assertIs(registration.implementation) + } +} From 34597f57ef03429c9ff190fc2d8e90740f19f3b8 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 7 Sep 2026 22:14:09 +0300 Subject: [PATCH 06/18] [TS Calls] Simplify guarded semantic models --- usvm-ts/UNKNOWN_CALL_MODELS.md | 237 ++++++++++ .../main/kotlin/org/usvm/machine/TsContext.kt | 10 +- .../main/kotlin/org/usvm/machine/TsMachine.kt | 35 +- .../main/kotlin/org/usvm/machine/TsOptions.kt | 11 +- .../call/TsBuiltInUnknownCallModels.kt | 13 +- .../org/usvm/machine/call/TsUnknownCall.kt | 2 +- .../usvm/machine/call/TsUnknownCallModel.kt | 100 ++--- .../machine/call/TsUnknownCallModelCatalog.kt | 92 ++++ .../call/TsUnknownCallModelDispatcher.kt | 175 ++++++++ .../call/TsUnknownCallModelRegistry.kt | 162 ------- .../machine/call/TsUnknownCallObservation.kt | 23 +- .../usvm/machine/call/TsUnknownCallProfile.kt | 350 --------------- .../intrinsic/TsArrayPopIntrinsicModel.kt | 130 ------ .../intrinsic/TsArrayShiftIntrinsicModel.kt | 108 +++++ .../intrinsic/TsIntrinsicUnknownCallModel.kt | 39 -- .../usvm/machine/expr/CallApproximations.kt | 12 +- ...t.kt => TsArrayShiftIntrinsicModelTest.kt} | 116 ++--- .../call/TsUnknownCallDispatcherTest.kt | 407 +++++++----------- ...UnknownCallExecutionGuardValidationTest.kt | 206 --------- .../call/TsUnknownCallModelCatalogTest.kt | 118 +++++ .../call/TsUnknownCallModelRegistryTest.kt | 152 ------- .../TsIntrinsicUnknownCallModelTest.kt | 20 - .../resources/models/ArrayPopIntrinsic.ts | 75 ---- .../resources/models/ArrayShiftIntrinsic.ts | 46 ++ 24 files changed, 1092 insertions(+), 1547 deletions(-) create mode 100644 usvm-ts/UNKNOWN_CALL_MODELS.md create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt delete mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt delete mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt delete mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt delete mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt rename usvm-ts/src/test/kotlin/org/usvm/machine/call/{TsArrayPopIntrinsicModelTest.kt => TsArrayShiftIntrinsicModelTest.kt} (60%) delete mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt delete mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt delete mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt delete mode 100644 usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts create mode 100644 usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md new file mode 100644 index 000000000..578e3fe5b --- /dev/null +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -0,0 +1,237 @@ +# TypeScript unknown-call models + +This document describes the semantic-model path used when the normal TypeScript interpreter cannot execute a call. + +## Mental model + +There are only three stages: + +1. The regular interpreter and existing compatibility approximations try to execute the call. +2. If execution cannot continue, `TsUnknownCallModelCatalog` selects one enabled semantic model by its target. +3. If no model handles the call or a model leaves a residual state, the configured fallback is applied. + +```text +normal execution + | + | cannot execute + v +enabled model with matching target? -- no --> fallback + | + yes + v +model accepts these inputs? -------- no --> fallback + | + yes + v +model successors + optional residual ----> residual uses fallback +``` + +The catalog contains model objects directly. There are no implementation-kind values, backend registrations, or +separate descriptor and implementation IDs. + +## Configuration + +Unknown-call behavior is configured directly in `TsOptions`: + +```kotlin +TsOptions( + enabledUnknownCallModelIds = setOf("ts.array.shift"), + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, +) +``` + +### `enabledUnknownCallModelIds` + +This is the only model-selection setting. + +| Value | Meaning | +| --- | --- | +| `null` | Enable every built-in model. This is the default. | +| `emptySet()` | Disable every built-in model. | +| `setOf("id", ...)` | Enable exactly the listed built-in model IDs. | + +Unknown IDs are rejected when the machine creates its immutable per-run catalog. The input set is copied at that +point, so later mutations cannot change an active run. + +Use the model's `id`, for example `ts.array.shift`. A target method name, class name, source filename, or fingerprint is +not a model ID. + +The built-in catalog currently contains one model: + +| ID | Implementation | Accepted calls | +| --- | --- | --- | +| `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array whose element sort is known. | + +An `any`/unknown receiver, a fake-value wrapper, a non-array receiver, and an array whose element sort is unresolved do +not become applicable merely because the method is named `shift`; they use fallback. + +### `unknownCallFallback` + +The fallback is applied when: + +- no enabled model target matches the call; +- the selected model returns `null` because it cannot safely handle the concrete inputs; +- a model returns a satisfiable `residualGuard`. + +The available policies are: + +| Policy | Behavior | +| --- | --- | +| `STOP_PATH` | Prune the unsupported state. This is the default. | +| `FRESH_SYMBOLIC_RETURN` | Continue with a fresh symbolic result and ignore unknown side effects and exceptions. | + +`FRESH_SYMBOLIC_RETURN` is deliberately imprecise. Use it only when opaque continuation is preferable to pruning. + +### Per-family fallback overrides + +`unknownCallFallbackOverrides` changes the fallback for calls whose callee has a particular +`EtsClassSignature`: + +```kotlin +TsOptions( + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, + unknownCallFallbackOverrides = mapOf( + externalApiSignature to TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), +) +``` + +An override applies both when no model accepts the call and to a residual state returned by a model. Prefer the global +fallback unless one call family has a concrete reason to differ. + +## Model identity and target + +Every model implements `TsUnknownCallModel`: + +```kotlin +interface TsUnknownCallModel { + val id: String + val target: TsUnknownCallTarget + + fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? +} +``` + +### Choosing an ID + +Use a stable semantic name: + +```text +..[.] +``` + +Examples: + +- `ts.array.shift` +- `ts.array.pop` +- `node.buffer.copy` + +The ID is used for configuration, observer events, and catalog fingerprints. Do not include: + +- an implementation mechanism such as `intrinsic`; +- a hash; +- a version number; +- a supported-domain label. + +Keep the same ID if an equivalent model is later reimplemented by another mechanism. + +### Choosing a target + +`TsUnknownCallTarget` matches stable call metadata declaratively: + +```kotlin +TsUnknownCallTarget( + methodName = "shift", + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, +) +``` + +Only `methodName` is required. Add `enclosingClassName` or `failureReason` when the method name alone is too broad. +The catalog rejects overlapping enabled targets before execution, so catalog order is never a priority rule. + +The target identifies a call family. State-dependent checks, such as the receiver's symbolic runtime type, belong in +`apply`. + +The built-in array target intentionally combines the method name with `PARTIAL_APPROXIMATION` instead of a class name. +That failure reason is emitted only after the regular approximation path has classified the receiver as an +`EtsArrayType`. Calls on `any`/unknown receivers reach another failure reason and cannot match this target. The model +still validates the resolved receiver and its element sort before changing memory. + +## Applicability and residual states + +There is no separate `EXACT` or `PARTIAL` flag. + +- `apply(...) == null` means the model rejects the complete call. The dispatcher uses fallback. +- `residualGuard == null` means the returned execution completely handles the accepted state. +- A non-null `residualGuard` sends precisely that symbolic subdomain to fallback. + +For example, a model may handle an array receiver under `isArray` and leave `!isArray` as residual: + +```kotlin +TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = isArray, + completion = completion, + ), + ), + residualGuard = ctx.mkNot(isArray), +) +``` + +Model authors are responsible for making successor guards and the residual guard disjoint and exhaustive. This +property belongs in focused model tests; the dispatcher does not invoke the solver a second time merely to validate a +model on every call. + +## When to write an intrinsic + +An intrinsic directly builds guarded successors and symbolic-memory operations in Kotlin. Use it only for an operation +that TypeScript cannot express without losing symbolic efficiency or correctness. + +`Array.shift` is the built-in example because shifting a symbolic array is naturally represented by one +`memory.memcpy` operation. + +Good intrinsic candidates include: + +- bulk symbolic-memory copy or fill; +- symbolic collection primitives; +- solver operations unavailable in the modeled language; +- type-system operations that cannot be represented faithfully by ordinary code. + +Do not write an intrinsic merely because a library method is stateful. + +## Dynamic receivers + +A method name does not prove the receiver type. In particular, `value.shift()` may call a user-defined property rather +than `Array.prototype.shift`. + +Use this decision rule: + +| Receiver knowledge | Action | +| --- | --- | +| Definitely the modeled built-in receiver type | Apply the model. | +| Definitely another type | Return `null`; use fallback. | +| Possibly the modeled type, with a trustworthy built-in target | Use a type guard and residual complement. | +| `any`/unknown without proof of the built-in target | Return `null`; use fallback. | + +Never choose `typeStreamOf(receiver).firstOrNull()` as proof. It returns one possible type, not necessarily the only +possible type. Use a statically proven type, `singleOrNull()` where uniqueness is guaranteed, or an explicit symbolic +type guard. + +## Fingerprints + +The catalog sorts enabled models by ID and hashes their length-prefixed IDs. Therefore model registration order does +not affect the fingerprint and ambiguous concatenations cannot collide merely because of ID boundaries. + +The fingerprint identifies the frozen enabled model set for one run. It is not a version and must not be used as a +manually maintained configuration value. + +## Observation + +Every applied model or fallback produces `TsUnknownCallEvent` through `TsInterpreterObserver.onUnknownCall`. + +- `ModelApplied(modelId)` identifies the semantic model. +- `ResidualFallback(policy)` records the effective fallback. +- `event.outcome` is derived from the decision and is not stored as a second independent value. + +Observer failures are logged and cannot alter symbolic exploration. diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 52a9f0a9d..0248715fa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -9,8 +9,8 @@ import org.jacodb.ets.model.EtsBooleanLiteralType import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsEnumValueType import org.jacodb.ets.model.EtsGenericType -import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsNullType import org.jacodb.ets.model.EtsNumberLiteralType @@ -34,6 +34,7 @@ import org.usvm.UConcreteHeapRef import org.usvm.UContext import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.UIteExpr import org.usvm.USort import org.usvm.api.allocateConcreteRef import org.usvm.api.allocateStaticRef @@ -198,6 +199,13 @@ class TsContext( return sort == addressSort && this is UConcreteHeapRef && address > MAGIC_OFFSET } + /** Returns whether this expression contains a fake-value wrapper as itself or as a conditional branch. */ + fun UExpr<*>.containsFakeObject(): Boolean = when { + isFakeObject() -> true + this is UIteExpr<*> -> trueBranch.containsFakeObject() || falseBranch.containsFakeObject() + else -> false + } + fun UExpr<*>.toFakeObject(scope: TsStepScope): UConcreteHeapRef { if (isFakeObject()) { return this diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index eca353f53..edc5c5b5e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -10,10 +10,9 @@ import org.usvm.UMachine import org.usvm.UMachineOptions import org.usvm.api.targets.TsTarget import org.usvm.machine.call.TsBuiltInUnknownCallModels -import org.usvm.machine.call.TsNoUnknownCallModels -import org.usvm.machine.call.TsProfileUnknownCallDispatcher +import org.usvm.machine.call.TsModelUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallDispatcher -import org.usvm.machine.call.TsUnknownCallModelProvider +import org.usvm.machine.call.TsUnknownCallModelCatalog import org.usvm.machine.interpreter.TsInterpreter import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState @@ -46,26 +45,26 @@ class TsMachine( private val machineObserver: UMachineObserver? = null, observer: TsInterpreterObserver? = null, unknownCallDispatcher: TsUnknownCallDispatcher? = null, - unknownCallModelProvider: TsUnknownCallModelProvider? = null, + unknownCallModels: TsUnknownCallModelCatalog? = null, ) : UMachine() { - private val graph = TsGraph(scene) - private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, graph.hierarchy) - private val components = TsComponents(typeSystem, options) - private val ctx = TsContext(scene, components) - private val frozenUnknownCallModels = when { - unknownCallDispatcher != null || unknownCallModelProvider != null -> null - else -> TsBuiltInUnknownCallModels.registry.freeze(tsOptions.unknownCallModels.enabledModelIds) + private val resolvedUnknownCallModels = when { + unknownCallDispatcher != null -> null + unknownCallModels != null -> unknownCallModels + else -> TsBuiltInUnknownCallModels.catalog(tsOptions.enabledUnknownCallModelIds) } - /** Fingerprint of the frozen built-in catalog, or `null` when custom dispatch/model wiring is used. */ + /** Fingerprint of the model catalog used by this machine, or `null` for a custom dispatcher. */ val unknownCallModelCatalogFingerprint: String? - get() = frozenUnknownCallModels?.fingerprint + get() = resolvedUnknownCallModels?.fingerprint - private val resolvedUnknownCallModelProvider = - unknownCallModelProvider ?: frozenUnknownCallModels ?: TsNoUnknownCallModels - private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsProfileUnknownCallDispatcher( - profile = tsOptions.unknownCallProfile, - modelProvider = resolvedUnknownCallModelProvider, + private val graph = TsGraph(scene) + private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, graph.hierarchy) + private val components = TsComponents(typeSystem, options) + private val ctx = TsContext(scene, components) + private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsModelUnknownCallDispatcher( + models = requireNotNull(resolvedUnknownCallModels), + fallback = tsOptions.unknownCallFallback, + fallbackOverrides = tsOptions.unknownCallFallbackOverrides, observer = observer, ) private val interpreter = TsInterpreter( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index 6b22c8831..fedf989c0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -1,13 +1,14 @@ package org.usvm.machine -import org.usvm.machine.call.TsUnknownCallModelSelection -import org.usvm.machine.call.TsUnknownCallProfile -import org.usvm.machine.call.TsUnknownCallProfiles +import org.jacodb.ets.model.EtsClassSignature +import org.usvm.machine.call.TsResidualCallPolicy data class TsOptions( val interproceduralAnalysis: Boolean = true, val enableVisualization: Boolean = false, val maxArraySize: Int = 1_000, - val unknownCallProfile: TsUnknownCallProfile = TsUnknownCallProfiles.MODELS_THEN_STOP, - val unknownCallModels: TsUnknownCallModelSelection = TsUnknownCallModelSelection(), + /** `null` enables every built-in model; an empty set disables all models. */ + val enabledUnknownCallModelIds: Set? = null, + val unknownCallFallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, + val unknownCallFallbackOverrides: Map = emptyMap(), ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt index 31fe6a3fc..51fd7c34e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt @@ -1,14 +1,13 @@ package org.usvm.machine.call -import org.usvm.machine.call.intrinsic.TsArrayPopIntrinsicModel -import org.usvm.machine.call.intrinsic.TsIntrinsicUnknownCallModelBackend +import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel -/** The intentionally small built-in catalog enabled by default for profile-based unknown-call dispatch. */ +/** The intentionally small built-in semantic-model catalog. */ object TsBuiltInUnknownCallModels { - const val ARRAY_POP_MODEL_ID: String = TsArrayPopIntrinsicModel.MODEL_ID + const val ARRAY_SHIFT_MODEL_ID: String = TsArrayShiftIntrinsicModel.MODEL_ID - val registry = TsUnknownCallModelRegistry( - registrations = listOf(TsArrayPopIntrinsicModel.registration), - backends = listOf(TsIntrinsicUnknownCallModelBackend), + fun catalog(enabledModelIds: Set? = null) = TsUnknownCallModelCatalog( + models = listOf(TsArrayShiftIntrinsicModel), + enabledModelIds = enabledModelIds, ) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index 238667afd..48bffe05a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -68,7 +68,7 @@ fun interface TsUnknownCallDispatcher { fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome } -/** Marks profile dispatchers that replace migrated compatibility approximations with registered models. */ +/** Marks dispatchers that replace migrated compatibility approximations with semantic models. */ interface TsUnknownCallModelDispatcher : TsUnknownCallDispatcher /** Preserves the pruning and opaque-return behavior that existed before the common dispatch boundary. */ diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt index 9566b54a0..923236737 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -5,46 +5,49 @@ import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.machine.state.TsState -/** Identifies the backend that executes a semantic model implementation. */ -enum class TsUnknownCallModelImplementationKind { - INTRINSIC, -} - -/** Describes the semantic precision of a model within its declared supported domain. */ -enum class TsUnknownCallModelPrecision { - EXACT, - PARTIAL, -} - -/** Documents the inputs for which a semantic model provides its declared precision. */ -data class TsUnknownCallModelSupportedDomain( - val id: String, - val description: String, +/** Declaratively identifies the calls handled by one semantic model. */ +data class TsUnknownCallTarget( + val methodName: String, + val enclosingClassName: String? = null, + val failureReason: TsUnknownCallFailureReason? = null, ) { init { - require(id.isNotBlank()) { "Semantic model supported-domain ID must not be blank" } - require(description.isNotBlank()) { "Semantic model supported-domain description must not be blank" } + require(methodName.isNotBlank()) { "Semantic model target method name must not be blank" } + require(enclosingClassName == null || enclosingClassName.isNotBlank()) { + "Semantic model target class name must not be blank" + } } -} -/** Selects calls that are candidates for one semantic model without depending on its implementation backend. */ -fun interface TsUnknownCallModelMatcher { - fun matches(call: TsUnknownCall): Boolean -} + internal fun matches(call: TsUnknownCall): Boolean = + call.callee.name == methodName && + (enclosingClassName == null || call.callee.enclosingClass.name == enclosingClassName) && + (failureReason == null || call.failureReason == failureReason) -/** Backend-neutral metadata used to select and audit one semantic model. */ -class TsUnknownCallModelDescriptor( - val id: String, - val matcher: TsUnknownCallModelMatcher, - val supportedDomain: TsUnknownCallModelSupportedDomain, - val precision: TsUnknownCallModelPrecision, - val implementationKind: TsUnknownCallModelImplementationKind, -) { - init { - require(id.isNotBlank()) { "Semantic model ID must not be blank" } + internal fun overlaps(other: TsUnknownCallTarget): Boolean { + val classNamesOverlap = enclosingClassName == null || + other.enclosingClassName == null || + enclosingClassName == other.enclosingClassName + val failureReasonsOverlap = failureReason == null || + other.failureReason == null || + failureReason == other.failureReason + + return methodName == other.methodName && classNamesOverlap && failureReasonsOverlap } } +/** + * A semantic model selected by a stable [id] and a declarative [target]. + * + * Returning `null` from [apply] means that the call is outside the model's supported input domain. The dispatcher + * then applies the configured fallback. A non-null execution may additionally contain a guarded residual domain. + */ +interface TsUnknownCallModel { + val id: String + val target: TsUnknownCallTarget + + fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? +} + /** Describes how a guarded model successor completes the original call. */ sealed interface TsUnknownCallModelCompletion { /** Produces a normal result on the selected successor state. */ @@ -58,12 +61,7 @@ sealed interface TsUnknownCallModelCompletion { ) : TsUnknownCallModelCompletion } -/** - * One guarded model successor. - * - * Successor guards within one execution must be pairwise disjoint. State changes and completion values are evaluated - * only after the dispatcher has selected the corresponding successor state. - */ +/** One guarded model successor. */ class TsUnknownCallModelSuccessor( val guard: UBoolExpr, val completion: TsUnknownCallModelCompletion, @@ -71,15 +69,14 @@ class TsUnknownCallModelSuccessor( ) /** - * A backend-neutral semantic-model execution plan. + * A semantic-model execution plan. * - * [residualGuard] denotes the unsupported part of a partial model's domain. Together, successor guards and the - * residual guard must partition the current call domain. The dispatcher validates disjointness and coverage before - * applying any successor. + * [residualGuard] is the input domain not covered by the model. `null` means that the model completely handles every + * state accepted by [TsUnknownCallModel.apply]. */ class TsUnknownCallModelExecution( successors: List, - val residualGuard: UBoolExpr?, + val residualGuard: UBoolExpr? = null, ) { val successors: List = successors.toList() @@ -88,30 +85,17 @@ class TsUnknownCallModelExecution( } } -/** The result of selecting and executing a semantic model for one call. */ +/** The result of model lookup for one call. */ sealed interface TsUnknownCallModelApplication { - /** A structured guarded plan produced by the selected model. */ class Applied( val modelId: String, - val precision: TsUnknownCallModelPrecision, val execution: TsUnknownCallModelExecution, ) : TsUnknownCallModelApplication { init { require(modelId.isNotBlank()) { "Applied model ID must not be blank" } - require(precision != TsUnknownCallModelPrecision.EXACT || execution.residualGuard == null) { - "Exact semantic model $modelId must not produce a residual guard" - } - require(precision != TsUnknownCallModelPrecision.PARTIAL || execution.residualGuard != null) { - "Partial semantic model $modelId must produce a residual guard" - } } } - /** Indicates that no enabled model matched the call. */ + /** No enabled model accepted the call. */ data object NotApplicable : TsUnknownCallModelApplication } - -/** Selects and executes models without exposing registry or backend details to the dispatcher. */ -fun interface TsUnknownCallModelProvider { - fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt new file mode 100644 index 000000000..dd74e9343 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt @@ -0,0 +1,92 @@ +package org.usvm.machine.call + +import org.usvm.machine.state.TsState +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +private const val BYTE_MASK = 0xff + +/** An immutable deterministic set of semantic models used by one machine run. */ +class TsUnknownCallModelCatalog( + models: Collection, + enabledModelIds: Set? = null, +) { + private val models: List + + val modelIds: List + get() = models.map(TsUnknownCallModel::id) + + val fingerprint: String + + init { + val allModels = models.sortedBy(TsUnknownCallModel::id) + val duplicateIds = allModels + .groupingBy(TsUnknownCallModel::id) + .eachCount() + .filterValues { count -> count > 1 } + .keys + .sorted() + + require(allModels.none { model -> model.id.isBlank() }) { "Semantic model ID must not be blank" } + require(duplicateIds.isEmpty()) { "Duplicate semantic model IDs: ${duplicateIds.joinToString()}" } + + val selectedIds = enabledModelIds?.toSet() + val knownIds = allModels.mapTo(mutableSetOf(), TsUnknownCallModel::id) + val unknownIds = selectedIds.orEmpty().subtract(knownIds).sorted() + + require(unknownIds.isEmpty()) { "Unknown semantic model IDs: ${unknownIds.joinToString()}" } + + this.models = when (selectedIds) { + null -> allModels + else -> allModels.filter { model -> model.id in selectedIds } + } + + validateUnambiguousTargets(this.models) + fingerprint = computeFingerprint(this.models) + } + + internal fun select(call: TsUnknownCall): TsUnknownCallModel? = + models.singleOrNull { model -> model.target.matches(call) } + + fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + val model = select(call) ?: return TsUnknownCallModelApplication.NotApplicable + val execution = model.apply(state, call) ?: return TsUnknownCallModelApplication.NotApplicable + + return TsUnknownCallModelApplication.Applied( + modelId = model.id, + execution = execution, + ) + } +} + +private fun validateUnambiguousTargets(models: List) { + models.forEachIndexed { index, model -> + val conflictingModel = models.drop(index + 1).firstOrNull { other -> + model.target.overlaps(other.target) + } ?: return@forEachIndexed + + error( + "Ambiguous semantic model targets: " + + listOf(model.id, conflictingModel.id).sorted().joinToString() + ) + } +} + +private fun computeFingerprint(models: List): String { + val digest = MessageDigest.getInstance("SHA-256") + + models.forEach { model -> + digest.updateLengthPrefixed(model.id) + } + + return digest.digest().joinToString(separator = "") { byte -> + "%02x".format(byte.toInt() and BYTE_MASK) + } +} + +private fun MessageDigest.updateLengthPrefixed(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) + update(bytes) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt new file mode 100644 index 000000000..c8933340f --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -0,0 +1,175 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsClassSignature +import org.usvm.api.makeFreshUnknownCallResult +import org.usvm.api.mockMethodCall +import org.usvm.api.setMockMethodCallResult +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState +import org.usvm.machine.state.newStmt + +/** The externally observable effect of an unknown-call decision. */ +enum class TsUnknownCallOutcome { + MODEL_APPLIED, + FRESH_SYMBOLIC_RETURN, + PATH_STOPPED, +} + +/** Selects what happens when no semantic model handles an unknown call. */ +enum class TsResidualCallPolicy { + STOP_PATH, + FRESH_SYMBOLIC_RETURN, +} + +/** Selects a semantic model and sends unsupported states to one configured fallback. */ +class TsModelUnknownCallDispatcher( + private val models: TsUnknownCallModelCatalog, + private val fallback: TsResidualCallPolicy, + fallbackOverrides: Map = emptyMap(), + private val observer: TsInterpreterObserver? = null, +) : TsUnknownCallModelDispatcher { + private val fallbackOverrides = fallbackOverrides.toMap() + + override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { + val application = scope.calcOnState { + this@TsModelUnknownCallDispatcher.models.apply(this, call) + } + + return when (application) { + is TsUnknownCallModelApplication.Applied -> applyModel(scope, call, application) + TsUnknownCallModelApplication.NotApplicable -> applyFallback(scope, call) + } + } + + private fun applyFallback( + scope: TsStepScope, + call: TsUnknownCall, + ): TsUnknownCallOutcome { + val policy = fallbackFor(call) + val decision = TsUnknownCallDecision.ResidualFallback(policy) + + when (policy) { + TsResidualCallPolicy.STOP_PATH -> { + val falseExpr = scope.calcOnState { ctx.falseExpr } + scope.assert(falseExpr) + } + + TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> { + mockMethodCall(scope, call.callee, call.resultType) + scope.doWithState { newStmt(call.callSite) } + } + } + + observer?.onUnknownCallSafely(event(call, decision)) + return decision.outcome + } + + private fun applyModel( + scope: TsStepScope, + call: TsUnknownCall, + application: TsUnknownCallModelApplication.Applied, + ): TsUnknownCallOutcome { + val residualGuard = application.execution.residualGuard + val residualPolicy = fallbackFor(call) + // Creating an unresolved value may add fake-value constraints. Do it before forking so the residual clone + // inherits both the constraints and their solver models. + val freshResidualResult = if ( + residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN + ) { + makeFreshUnknownCallResult(scope, call.resultType) + } else { + null + } + val stoppedResidualIsSatisfiable = residualGuard != null && + residualPolicy == TsResidualCallPolicy.STOP_PATH && + scope.checkSat(residualGuard) != null + + var modelApplied = false + var modelEventReported = false + var freshResidualApplied = false + val guardedStateChanges = application.execution.successors.map { successor -> + successor.guard to modelStateChange( + call = call, + modelId = application.modelId, + successor = successor, + onApplied = { + modelApplied = true + if (modelEventReported) { + false + } else { + modelEventReported = true + true + } + }, + ) + }.toMutableList() + + if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { + guardedStateChanges += residualGuard to { + setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) + newStmt(call.callSite) + freshResidualApplied = true + + observer?.onUnknownCallSafely( + event(call, TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN)) + ) + } + } + + scope.forkMulti(guardedStateChanges) + + if (stoppedResidualIsSatisfiable) { + observer?.onUnknownCallSafely( + event(call, TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH)) + ) + } + + return when { + modelApplied -> TsUnknownCallOutcome.MODEL_APPLIED + freshResidualApplied -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN + stoppedResidualIsSatisfiable -> TsUnknownCallOutcome.PATH_STOPPED + else -> error("Semantic model ${application.modelId} produced no satisfiable successor or residual state") + } + } + + private fun modelStateChange( + call: TsUnknownCall, + modelId: String, + successor: TsUnknownCallModelSuccessor, + onApplied: () -> Boolean, + ): TsState.() -> Unit = { + successor.applyStateChanges(this) + + when (val completion = successor.completion) { + is TsUnknownCallModelCompletion.Normal -> { + val result = completion.result(this) + methodResult = TsMethodResult.Success.MockedCall(result, call.callee) + newStmt(call.callSite) + } + + is TsUnknownCallModelCompletion.Exceptional -> { + val (exception, type) = completion.exception(this) + methodResult = TsMethodResult.TsException(exception, type) + } + } + + if (onApplied()) { + observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ModelApplied(modelId))) + } + } + + private fun fallbackFor(call: TsUnknownCall): TsResidualCallPolicy = + fallbackOverrides[call.callee.enclosingClass] ?: fallback + + private fun event( + call: TsUnknownCall, + decision: TsUnknownCallDecision, + ) = TsUnknownCallEvent( + callSite = call.callSite, + callee = call.callee, + failureReason = call.failureReason, + decision = decision, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt deleted file mode 100644 index 45471e1e8..000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistry.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.usvm.machine.call - -import org.usvm.machine.state.TsState -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import java.security.MessageDigest - -private const val BYTE_MASK = 0xff - -/** Opaque semantic-model implementation selected by its [kind]. */ -interface TsUnknownCallModelImplementation { - val kind: TsUnknownCallModelImplementationKind -} - -/** Executes opaque model implementations of one [kind]. */ -interface TsUnknownCallModelBackend { - val kind: TsUnknownCallModelImplementationKind - - fun execute( - implementation: TsUnknownCallModelImplementation, - state: TsState, - call: TsUnknownCall, - ): TsUnknownCallModelExecution -} - -/** Binds backend-neutral model metadata to an opaque backend implementation. */ -data class TsUnknownCallModelRegistration( - val descriptor: TsUnknownCallModelDescriptor, - val implementation: TsUnknownCallModelImplementation, -) { - init { - require(descriptor.implementationKind == implementation.kind) { - "Semantic model ${descriptor.id} declares ${descriptor.implementationKind} " + - "but provides ${implementation.kind}" - } - } -} - -/** Validates semantic-model registrations and freezes deterministic per-run subsets. */ -class TsUnknownCallModelRegistry( - registrations: Collection, - backends: Collection = emptyList(), -) { - private val registrations = registrations.sortedBy { it.descriptor.id } - private val backends = backends.associateBackendKinds() - - init { - val duplicateIds = this.registrations - .groupingBy { it.descriptor.id } - .eachCount() - .filterValues { count -> count > 1 } - .keys - .sorted() - - require(duplicateIds.isEmpty()) { - "Duplicate semantic model IDs: ${duplicateIds.joinToString()}" - } - } - - /** Freezes an immutable enabled subset and validates its backends; `null` enables the complete catalog. */ - fun freeze(enabledModelIds: Set? = null): TsFrozenUnknownCallModelRegistry { - val enabledIds = enabledModelIds?.toSet() - val knownIds = registrations.mapTo(mutableSetOf()) { it.descriptor.id } - val unknownIds = enabledIds.orEmpty().subtract(knownIds).sorted() - - require(unknownIds.isEmpty()) { - "Unknown semantic model IDs: ${unknownIds.joinToString()}" - } - - val enabledRegistrations = when (enabledIds) { - null -> registrations - else -> registrations.filter { it.descriptor.id in enabledIds } - } - val missingBackendKinds = enabledRegistrations - .map { it.descriptor.implementationKind } - .distinct() - .filterNot(backends::containsKey) - .sortedBy { it.name } - - require(missingBackendKinds.isEmpty()) { - "Missing semantic model backends: ${missingBackendKinds.joinToString()}" - } - - return TsFrozenUnknownCallModelRegistry(enabledRegistrations, backends) - } - - private fun Collection.associateBackendKinds(): - Map { - val duplicateKinds = groupingBy { it.kind } - .eachCount() - .filterValues { count -> count > 1 } - .keys - .sortedBy { it.name } - - require(duplicateKinds.isEmpty()) { - "Duplicate semantic model backends: ${duplicateKinds.joinToString()}" - } - - return associateBy { it.kind } - } -} - -/** Selects all registered models or a defensively copied explicit subset for one machine run. */ -class TsUnknownCallModelSelection( - enabledModelIds: Set? = null, -) { - val enabledModelIds: Set? = enabledModelIds?.toSet() -} - -/** An immutable deterministic semantic-model catalog used by one machine run. */ -class TsFrozenUnknownCallModelRegistry internal constructor( - private val registrations: List, - private val backends: Map, -) : TsUnknownCallModelProvider { - val descriptors: List = registrations.map { it.descriptor } - val fingerprint: String = computeFingerprint(registrations) - - internal fun select(call: TsUnknownCall): TsUnknownCallModelRegistration? { - val matches = registrations.filter { it.descriptor.matcher.matches(call) } - - check(matches.size <= 1) { - val modelIds = matches.map { it.descriptor.id }.sorted() - "Ambiguous semantic models matched: ${modelIds.joinToString()}" - } - - return matches.singleOrNull() - } - - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val registration = select(call) ?: return TsUnknownCallModelApplication.NotApplicable - val implementationKind = registration.descriptor.implementationKind - val backend = checkNotNull(backends[implementationKind]) { - "No semantic model backend configured for $implementationKind" - } - val execution = backend.execute(registration.implementation, state, call) - - return TsUnknownCallModelApplication.Applied( - modelId = registration.descriptor.id, - precision = registration.descriptor.precision, - execution = execution, - ) - } -} - -private fun computeFingerprint(registrations: List): String { - val digest = MessageDigest.getInstance("SHA-256") - - registrations.forEach { registration -> - digest.updateLengthPrefixed(registration.descriptor.id) - digest.updateLengthPrefixed(registration.descriptor.implementationKind.name) - } - - return digest.digest().joinToString(separator = "") { byte -> - "%02x".format(byte.toInt() and BYTE_MASK) - } -} - -private fun MessageDigest.updateLengthPrefixed(value: String) { - val bytes = value.toByteArray(StandardCharsets.UTF_8) - update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) - update(bytes) -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallObservation.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallObservation.kt index 7adf43527..2ae84bffd 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallObservation.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallObservation.kt @@ -7,12 +7,6 @@ import org.usvm.machine.TsInterpreterObserver private val logger = KotlinLogging.logger {} -/** Explains why a call reached the residual fallback instead of a semantic model. */ -enum class TsUnknownCallResidualReason { - MODEL_LOOKUP_DISABLED, - MODEL_NOT_APPLICABLE, -} - /** Describes the model or fallback action selected for one unknown call. */ sealed interface TsUnknownCallDecision { data class ModelApplied( @@ -25,19 +19,28 @@ sealed interface TsUnknownCallDecision { data class ResidualFallback( val policy: TsResidualCallPolicy, - val reason: TsUnknownCallResidualReason, ) : TsUnknownCallDecision } +val TsUnknownCallDecision.outcome: TsUnknownCallOutcome + get() = when (this) { + is TsUnknownCallDecision.ModelApplied -> TsUnknownCallOutcome.MODEL_APPLIED + is TsUnknownCallDecision.ResidualFallback -> when (policy) { + TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED + TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN + } + } + /** A structured decision reported for one unknown call. */ data class TsUnknownCallEvent( val callSite: EtsStmt, val callee: EtsMethodSignature, val failureReason: TsUnknownCallFailureReason, - val profile: TsUnknownCallProfile, - val outcome: TsUnknownCallOutcome, val decision: TsUnknownCallDecision, -) +) { + val outcome: TsUnknownCallOutcome + get() = decision.outcome +} internal fun TsInterpreterObserver.onUnknownCallSafely(event: TsUnknownCallEvent) { runCatching { onUnknownCall(event) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt deleted file mode 100644 index 1c763c6a1..000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallProfile.kt +++ /dev/null @@ -1,350 +0,0 @@ -package org.usvm.machine.call - -import org.jacodb.ets.model.EtsClassSignature -import org.jacodb.ets.model.EtsType -import org.usvm.UBoolExpr -import org.usvm.api.makeFreshUnknownCallResult -import org.usvm.api.mockMethodCall -import org.usvm.api.setMockMethodCallResult -import org.usvm.isTrue -import org.usvm.machine.TsInterpreterObserver -import org.usvm.machine.interpreter.TsStepScope -import org.usvm.machine.state.TsMethodResult -import org.usvm.machine.state.TsState -import org.usvm.machine.state.newStmt -import org.usvm.solver.USatResult -import org.usvm.solver.USolverResult -import org.usvm.solver.UUnknownResult -import org.usvm.solver.UUnsatResult - -/** The externally observable decision made for a call that could not be executed normally. */ -enum class TsUnknownCallOutcome { - MODEL_APPLIED, - FRESH_SYMBOLIC_RETURN, - PATH_STOPPED, -} - -/** Controls whether the dispatcher asks the configured model provider to handle a call. */ -enum class TsUnknownCallModelLookup { - DISABLED, - ENABLED, -} - -/** - * Selects what happens when model lookup is disabled or no model applies. - * - * [FRESH_SYMBOLIC_RETURN] creates a new symbolic value of the call expression's result type and advances past the - * call. It deliberately ignores all callee side effects and exceptions, so it is an opaque continuation rather than - * a semantic model of the callee. - */ -enum class TsResidualCallPolicy { - STOP_PATH, - FRESH_SYMBOLIC_RETURN, -} - -/** Independently configures model lookup and the fallback for residual calls. */ -data class TsUnknownCallProfile( - val modelLookup: TsUnknownCallModelLookup, - val residualPolicy: TsResidualCallPolicy, - val residualOverrides: Map = emptyMap(), -) { - internal fun residualPolicyFor(call: TsUnknownCall): TsResidualCallPolicy = - residualOverrides[call.callee.enclosingClass] ?: residualPolicy -} - -/** Ready-to-use profiles for the four supported model/fallback combinations. */ -object TsUnknownCallProfiles { - val STOP_ALL = TsUnknownCallProfile( - modelLookup = TsUnknownCallModelLookup.DISABLED, - residualPolicy = TsResidualCallPolicy.STOP_PATH, - ) - val FRESH_SYMBOLIC_FOR_ALL = TsUnknownCallProfile( - modelLookup = TsUnknownCallModelLookup.DISABLED, - residualPolicy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ) - val MODELS_THEN_STOP = TsUnknownCallProfile( - modelLookup = TsUnknownCallModelLookup.ENABLED, - residualPolicy = TsResidualCallPolicy.STOP_PATH, - ) - val MODELS_THEN_FRESH_SYMBOLIC = TsUnknownCallProfile( - modelLookup = TsUnknownCallModelLookup.ENABLED, - residualPolicy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ) -} - -/** Empty provider used until an explicit model registry is configured. */ -object TsNoUnknownCallModels : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication = - TsUnknownCallModelApplication.NotApplicable -} - -/** Applies the selected model/fallback profile to every residual call. */ -class TsProfileUnknownCallDispatcher( - private val profile: TsUnknownCallProfile, - private val modelProvider: TsUnknownCallModelProvider, - private val observer: TsInterpreterObserver? = null, -) : TsUnknownCallModelDispatcher { - override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { - if (profile.modelLookup == TsUnknownCallModelLookup.DISABLED) { - return applyResidualFallback( - scope, - call, - reason = TsUnknownCallResidualReason.MODEL_LOOKUP_DISABLED, - ) - } - - val application = scope.calcOnState { - modelProvider.apply(this, call) - } - return when (application) { - is TsUnknownCallModelApplication.Applied -> applyModel(scope, call, application) - - TsUnknownCallModelApplication.NotApplicable -> applyResidualFallback( - scope, - call, - reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, - ) - } - } - - private fun applyResidualFallback( - scope: TsStepScope, - call: TsUnknownCall, - reason: TsUnknownCallResidualReason, - ): TsUnknownCallOutcome { - val residualPolicy = profile.residualPolicyFor(call) - val outcome = when (residualPolicy) { - TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED - TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN - } - val event = event( - call, - outcome, - decision = TsUnknownCallDecision.ResidualFallback( - residualPolicy, - reason, - ), - ) - when (residualPolicy) { - TsResidualCallPolicy.STOP_PATH -> { - val falseExpr = scope.calcOnState { ctx.falseExpr } - scope.assert(falseExpr) - } - - TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> { - mockMethodCall(scope, call.callee, call.resultType) - scope.doWithState { newStmt(call.callSite) } - } - } - - observer?.onUnknownCallSafely(event) - return outcome - } - - private fun applyModel( - scope: TsStepScope, - call: TsUnknownCall, - application: TsUnknownCallModelApplication.Applied, - ): TsUnknownCallOutcome { - validateExecutionGuards(scope, application) - - val residualGuard = application.execution.residualGuard - val residualPolicy = profile.residualPolicyFor(call) - val freshResidualResult = if ( - residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN - ) { - makeFreshUnknownCallResult(scope, call.resultType) - } else { - null - } - val stoppedResidualIsSatisfiable = residualGuard != null && - residualPolicy == TsResidualCallPolicy.STOP_PATH && - scope.checkSat(residualGuard) != null - - var modelApplied = false - var modelEventReported = false - var freshResidualApplied = false - val guardedStateChanges = application.execution.successors.map { successor -> - successor.guard to modelStateChange( - call, - application, - successor, - onApplied = { - modelApplied = true - if (modelEventReported) { - false - } else { - modelEventReported = true - true - } - }, - ) - }.toMutableList() - - if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { - guardedStateChanges += residualGuard to { - setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) - newStmt(call.callSite) - freshResidualApplied = true - - val event = residualEvent( - call, - policy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ) - observer?.onUnknownCallSafely(event) - } - } - - scope.forkMulti(guardedStateChanges) - - if (stoppedResidualIsSatisfiable) { - val event = residualEvent( - call, - policy = TsResidualCallPolicy.STOP_PATH, - ) - observer?.onUnknownCallSafely(event) - } - - return when { - modelApplied -> TsUnknownCallOutcome.MODEL_APPLIED - freshResidualApplied -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN - stoppedResidualIsSatisfiable -> TsUnknownCallOutcome.PATH_STOPPED - else -> error("Semantic model ${application.modelId} produced no satisfiable successor or residual state") - } - } - - private fun validateExecutionGuards( - scope: TsStepScope, - application: TsUnknownCallModelApplication.Applied, - ) = scope.doWithState { - val namedGuards = buildList { - application.execution.successors.forEachIndexed { index, successor -> - add(NamedGuard(name = "successor[$index]", guard = successor.guard)) - } - application.execution.residualGuard?.let { residualGuard -> - add(NamedGuard(name = "residual", guard = residualGuard)) - } - } - val overlaps = buildList { - namedGuards.forEachIndexed { firstIndex, first -> - namedGuards.drop(firstIndex + 1).forEach { second -> - add( - GuardOverlap( - firstName = first.name, - secondName = second.name, - condition = ctx.mkAnd(first.guard, second.guard), - ) - ) - } - } - } - val coveredDomain = ctx.mkOr(namedGuards.map(NamedGuard::guard)) - val uncoveredDomain = ctx.mkNot(coveredDomain) - val invalidity = ctx.mkOr(overlaps.map(GuardOverlap::condition) + uncoveredDomain) - val validationConstraints = pathConstraints.clone() - validationConstraints += invalidity - - val solverResult = ctx.solver().check(validationConstraints) - solverResult.requireConclusiveGuardValidation(application.modelId) - - when (solverResult) { - is UUnsatResult -> { - // The invalidity condition is unreachable, so the guards form a partition. - } - - is USatResult -> { - val witnessedOverlap = overlaps.firstOrNull { overlap -> - solverResult.model.eval(overlap.condition).isTrue - } - if (witnessedOverlap != null) { - error( - "Semantic model ${application.modelId} produced overlapping guards: " + - "${witnessedOverlap.firstName}, ${witnessedOverlap.secondName}" - ) - } - - error("Semantic model ${application.modelId} guards do not cover the current call domain") - } - - is UUnknownResult -> { - error("Unreachable after conclusive guard validation") - } - } - } - - private fun modelStateChange( - call: TsUnknownCall, - application: TsUnknownCallModelApplication.Applied, - successor: TsUnknownCallModelSuccessor, - onApplied: () -> Boolean, - ): TsState.() -> Unit = { - successor.applyStateChanges(this) - - when (val completion = successor.completion) { - is TsUnknownCallModelCompletion.Normal -> { - val result = completion.result(this) - methodResult = TsMethodResult.Success.MockedCall(result, call.callee) - newStmt(call.callSite) - } - - is TsUnknownCallModelCompletion.Exceptional -> { - val (exception, type) = completion.exception(this) - methodResult = TsMethodResult.TsException(exception, type) - } - } - - if (onApplied()) { - val event = event( - call, - outcome = TsUnknownCallOutcome.MODEL_APPLIED, - decision = TsUnknownCallDecision.ModelApplied(modelId = application.modelId), - ) - observer?.onUnknownCallSafely(event) - } - } - - private fun residualEvent( - call: TsUnknownCall, - policy: TsResidualCallPolicy, - ) = event( - call, - outcome = when (policy) { - TsResidualCallPolicy.STOP_PATH -> TsUnknownCallOutcome.PATH_STOPPED - TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN - }, - decision = TsUnknownCallDecision.ResidualFallback( - policy, - reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, - ), - ) - - private fun event( - call: TsUnknownCall, - outcome: TsUnknownCallOutcome, - decision: TsUnknownCallDecision, - ) = TsUnknownCallEvent( - callSite = call.callSite, - callee = call.callee, - failureReason = call.failureReason, - profile = profile.copy(residualOverrides = profile.residualOverrides.toMap()), - outcome = outcome, - decision = decision, - ) -} - -internal fun USolverResult<*>.requireConclusiveGuardValidation(modelId: String) { - check(this !is UUnknownResult) { - "Semantic model $modelId guards could not be validated: solver returned UNKNOWN" - } -} - -private data class NamedGuard( - val name: String, - val guard: UBoolExpr, -) - -private data class GuardOverlap( - val firstName: String, - val secondName: String, - val condition: UBoolExpr, -) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt deleted file mode 100644 index e82be0e6b..000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopIntrinsicModel.kt +++ /dev/null @@ -1,130 +0,0 @@ -package org.usvm.machine.call.intrinsic - -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsArrayType -import org.usvm.UAddressSort -import org.usvm.UExpr -import org.usvm.USort -import org.usvm.api.typeStreamOf -import org.usvm.machine.call.TsUnknownCall -import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.machine.call.TsUnknownCallModelCompletion -import org.usvm.machine.call.TsUnknownCallModelDescriptor -import org.usvm.machine.call.TsUnknownCallModelExecution -import org.usvm.machine.call.TsUnknownCallModelImplementationKind -import org.usvm.machine.call.TsUnknownCallModelMatcher -import org.usvm.machine.call.TsUnknownCallModelPrecision -import org.usvm.machine.call.TsUnknownCallModelRegistration -import org.usvm.machine.call.TsUnknownCallModelSuccessor -import org.usvm.machine.call.TsUnknownCallModelSupportedDomain -import org.usvm.machine.expr.TsUnresolvedSort -import org.usvm.machine.state.TsState -import org.usvm.types.firstOrNull -import org.usvm.util.mkArrayIndexLValue -import org.usvm.util.mkArrayLengthLValue - -/** Partial intrinsic model for `Array.pop` on resolved one-dimensional primitive arrays. */ -internal object TsArrayPopIntrinsicModel : TsIntrinsicUnknownCallModel { - const val MODEL_ID: String = "ts.array.pop" - - private val descriptor = TsUnknownCallModelDescriptor( - id = MODEL_ID, - matcher = TsUnknownCallModelMatcher { call -> - call.failureReason == TsUnknownCallFailureReason.PARTIAL_APPROXIMATION && - call.callee.name == "pop" - }, - supportedDomain = TsUnknownCallModelSupportedDomain( - id = "native-array-pop", - description = "Resolved one-dimensional native arrays with no arguments and a primitive element sort", - ), - precision = TsUnknownCallModelPrecision.PARTIAL, - implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, - ) - - val registration = TsUnknownCallModelRegistration( - descriptor = descriptor, - implementation = TsIntrinsicUnknownCallModelImplementation(this), - ) - - override fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { - val input = resolveInput(state, call) - ?: return unsupportedExecution(state) - - val lengthLValue = mkArrayLengthLValue(input.array, input.arrayType) - val length = state.memory.read(lengthLValue) - val zero = state.ctx.mkBv(0) - val emptyGuard = state.ctx.mkEq(length, zero) - val nonEmptyGuard = state.ctx.mkBvSignedLessExpr(zero, length) - val residualGuard = state.ctx.mkNot(state.ctx.mkOr(emptyGuard, nonEmptyGuard)) - val newLength = state.ctx.mkBvSubExpr(length, state.ctx.mkBv(1)) - val lastElementLValue = mkArrayIndexLValue( - sort = input.elementSort, - ref = input.array, - index = newLength, - type = input.arrayType, - ) - - val emptySuccessor = TsUnknownCallModelSuccessor( - guard = emptyGuard, - completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, - ) - val nonEmptySuccessor = TsUnknownCallModelSuccessor( - guard = nonEmptyGuard, - completion = TsUnknownCallModelCompletion.Normal { memory.read(lastElementLValue) }, - applyStateChanges = { - memory.write(lengthLValue, newLength, guard = ctx.trueExpr) - }, - ) - - return TsUnknownCallModelExecution( - successors = listOf(emptySuccessor, nonEmptySuccessor), - residualGuard = residualGuard, - ) - } - - private fun resolveInput(state: TsState, call: TsUnknownCall): ArrayPopInput? { - if (call.arguments.isNotEmpty()) { - return null - } - - val receiverValue = call.receiver?.resolved ?: return null - if (receiverValue.sort != state.ctx.addressSort) { - return null - } - - val array = receiverValue.asExpr(state.ctx.addressSort) - val sourceType = requireNotNull(call.receiver).source.type - val memoryType = state.memory.typeStreamOf(array).firstOrNull() - val arrayType = sequenceOf(memoryType, sourceType) - .mapNotNull { it as? EtsArrayType } - .firstOrNull { candidate -> - candidate.dimensions == 1 && state.ctx.typeToSort(candidate.elementType) !is TsUnresolvedSort - } - ?: return null - - val elementSort = state.ctx.typeToSort(arrayType.elementType) - if (elementSort == state.ctx.addressSort) { - return null - } - - return ArrayPopInput(array, arrayType, elementSort) - } - - private fun unsupportedExecution(state: TsState): TsUnknownCallModelExecution { - val unreachableSuccessor = TsUnknownCallModelSuccessor( - guard = state.ctx.falseExpr, - completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, - ) - - return TsUnknownCallModelExecution( - successors = listOf(unreachableSuccessor), - residualGuard = state.ctx.trueExpr, - ) - } - - private class ArrayPopInput( - val array: UExpr, - val arrayType: EtsArrayType, - val elementSort: USort, - ) -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt new file mode 100644 index 000000000..cf86b6041 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -0,0 +1,108 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.usvm.UAddressSort +import org.usvm.UExpr +import org.usvm.USort +import org.usvm.api.memcpy +import org.usvm.api.typeStreamOf +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsState +import org.usvm.types.singleOrNull +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue + +/** Engine intrinsic for `Array.shift`, whose bulk move is implemented by symbolic-memory `memcpy`. */ +internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { + const val MODEL_ID: String = "ts.array.shift" + + override val id: String = MODEL_ID + override val target = TsUnknownCallTarget( + methodName = "shift", + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? = with(state.ctx) { + val input = resolveInput(state, call) ?: return@with null + val lengthLValue = mkArrayLengthLValue(input.array, input.arrayType) + val length = state.memory.read(lengthLValue) + val zero = mkBv(0) + val emptyGuard = mkEq(length, zero) + val nonEmptyGuard = mkBvSignedLessExpr(zero, length) + val newLength = mkBvSubExpr(length, mkBv(1)) + val firstElementLValue = mkArrayIndexLValue( + sort = input.elementSort, + ref = input.array, + index = zero, + type = input.arrayType, + ) + val firstElement = state.memory.read(firstElementLValue) + + val emptySuccessor = TsUnknownCallModelSuccessor( + guard = emptyGuard, + completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, + ) + val nonEmptySuccessor = TsUnknownCallModelSuccessor( + guard = nonEmptyGuard, + completion = TsUnknownCallModelCompletion.Normal { firstElement }, + applyStateChanges = { + memory.memcpy( + srcRef = input.array, + dstRef = input.array, + type = input.arrayType, + elementSort = input.elementSort, + fromSrc = mkBv(1), + fromDst = zero, + length = newLength, + ) + memory.write(lengthLValue, newLength, guard = trueExpr) + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(emptySuccessor, nonEmptySuccessor), + residualGuard = mkNot(mkOr(emptyGuard, nonEmptyGuard)), + ) + } + + private fun resolveInput(state: TsState, call: TsUnknownCall): ArrayShiftInput? = with(state.ctx) { + if (call.arguments.isNotEmpty()) { + return@with null + } + + val receiver = call.receiver ?: return@with null + val receiverValue = receiver.resolved ?: return@with null + if (receiverValue.sort != addressSort || receiverValue.containsFakeObject()) { + return@with null + } + + val array = receiverValue.asExpr(addressSort) + val arrayType = (receiver.source.type as? EtsArrayType) + ?: (state.memory.typeStreamOf(array).singleOrNull() as? EtsArrayType) + ?: return@with null + if (arrayType.dimensions != 1) { + return@with null + } + + val elementSort = typeToSort(arrayType.elementType) + if (elementSort is TsUnresolvedSort) { + return@with null + } + + ArrayShiftInput(array, arrayType, elementSort) + } + + private class ArrayShiftInput( + val array: UExpr, + val arrayType: EtsArrayType, + val elementSort: USort, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt deleted file mode 100644 index 0870cb410..000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.usvm.machine.call.intrinsic - -import org.usvm.machine.call.TsUnknownCall -import org.usvm.machine.call.TsUnknownCallModelBackend -import org.usvm.machine.call.TsUnknownCallModelExecution -import org.usvm.machine.call.TsUnknownCallModelImplementation -import org.usvm.machine.call.TsUnknownCallModelImplementationKind -import org.usvm.machine.state.TsState - -/** Builds constraint-level execution plans directly from a TypeScript symbolic state. */ -fun interface TsIntrinsicUnknownCallModel { - fun execute(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution -} - -/** Opaque registry handle for a Kotlin intrinsic semantic model. */ -class TsIntrinsicUnknownCallModelImplementation( - val model: TsIntrinsicUnknownCallModel, -) : TsUnknownCallModelImplementation { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC -} - -/** Executes intrinsic model handles without exposing them to the common registry or dispatcher contract. */ -object TsIntrinsicUnknownCallModelBackend : TsUnknownCallModelBackend { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC - - override fun execute( - implementation: TsUnknownCallModelImplementation, - state: TsState, - call: TsUnknownCall, - ): TsUnknownCallModelExecution { - val intrinsic = requireNotNull(implementation as? TsIntrinsicUnknownCallModelImplementation) { - "INTRINSIC backend requires TsIntrinsicUnknownCallModelImplementation, got ${implementation::class}" - } - - return intrinsic.model.execute(state, call) - } -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 8e7c845eb..c48b2c552 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -29,7 +29,7 @@ import org.usvm.machine.interpreter.setResolvedValue import org.usvm.machine.state.lastStmt import org.usvm.sizeSort import org.usvm.types.first -import org.usvm.types.firstOrNull +import org.usvm.types.singleOrNull import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods @@ -93,7 +93,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val instanceType = if (instance.sort == addressSort && isAllocatedConcreteHeapRef(instance)) { scope.calcOnState { - memory.typeStreamOf(instance.asExpr(addressSort)).firstOrNull() ?: expr.instance.type + memory.typeStreamOf(instance.asExpr(addressSort)).singleOrNull() ?: expr.instance.type } } else { expr.instance.type @@ -111,7 +111,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.pop() method calls if (expr.callee.name == "pop") { - return handleArrayPopCall(expr, instanceType, elementSort, instance) + return from(handleArrayPop(expr, instanceType, elementSort)) } // Handle `Array.fill() method calls @@ -126,7 +126,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.shift() method calls if (expr.callee.name == "shift") { - return from(handleArrayShift(expr, instanceType, elementSort)) + return handleArrayShiftCall(expr, instanceType, elementSort, instance) } // Handle `Array.join() method calls @@ -163,7 +163,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } -private fun TsExprResolver.handleArrayPopCall( +private fun TsExprResolver.handleArrayShiftCall( expr: EtsInstanceCallExpr, instanceType: EtsArrayType, elementSort: USort, @@ -171,7 +171,7 @@ private fun TsExprResolver.handleArrayPopCall( ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { - return from(handleArrayPop(expr, instanceType, elementSort)) + return from(handleArrayShift(expr, instanceType, elementSort)) } dispatcher.dispatch( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt similarity index 60% rename from usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt rename to usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index ea1dd94d4..a0d16c22b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayPopIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -1,18 +1,23 @@ package org.usvm.machine.call +import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert -import org.junit.jupiter.api.Disabled import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr import org.usvm.UMachineOptions import org.usvm.api.TsTestValue +import org.usvm.api.makeSymbolicRefUntyped import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.util.TsTestResolver @@ -25,24 +30,24 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration -class TsArrayPopIntrinsicModelTest { +class TsArrayShiftIntrinsicModelTest { private val sourceFile = loadEtsFileAutoConvert( - getResourcePath("/models/ArrayPopIntrinsic.ts"), + getResourcePath("/models/ArrayShiftIntrinsic.ts"), provider = EtsIrProvider.TS_FRONTEND, ) private val scene = EtsScene(listOf(sourceFile)) @Test - fun `empty array pop returns undefined through intrinsic model`() { + fun `empty array shift returns undefined through intrinsic model`() { val result = analyze(methodName = "emptyArray") assertIs(result.values.single()) - assertEquals(listOf("ts.array.pop"), result.modelIds) + assertEquals(listOf("ts.array.shift"), result.modelIds) assertTrue(assertNotNull(result.catalogFingerprint).matches(Regex("[0-9a-f]{64}"))) } @Test - fun `non empty array pop returns last element and shrinks array`() { + fun `non empty array shift returns first element moves tail and shrinks array`() { val result = analyze(methodName = "nonEmptyArray") assertEquals(32.0, assertIs(result.values.single()).number) @@ -50,13 +55,10 @@ class TsArrayPopIntrinsicModelTest { } @Test - fun `allocated reference array uses residual fallback`() { - assertUsesResidualFallback(methodName = "aliasedElement") - } + fun `reference array preserves removed element alias`() { + val result = analyze(methodName = "aliasedElement") - @Test - fun `symbolic reference array uses residual fallback`() { - assertUsesResidualFallback(methodName = "symbolicReferenceArray") + assertEquals(42.0, assertIs(result.values.single()).number) } @Test @@ -73,57 +75,51 @@ class TsArrayPopIntrinsicModelTest { } @Test - fun `allocated reference array with symbolic write uses residual fallback`() { - val result = analyze(methodName = "allocatedReferenceArrayWithSymbolicWrite") - - val event = result.events.single() - assertEquals(TsUnknownCallOutcome.PATH_STOPPED, event.outcome) - assertIs(event.decision) + fun `array shift with arguments uses residual fallback`() { + assertUsesResidualFallback(methodName = "shiftWithArguments") } @Test - fun `array pop with arguments uses residual fallback`() { - assertUsesResidualFallback(methodName = "popWithArguments") + fun `fake wrapper receiver is not accepted as an array`() { + val state = analyzeStates(methodName = "unknownValue").single() + val fakeReceiver = makeFakeReceiver(state) + + val execution = TsArrayShiftIntrinsicModel.apply(state, arrayShiftCall(fakeReceiver)) + + assertNull(execution) } - @Disabled("Tracked by https://github.com/UnitTestBot/usvm/issues/379") @Test - fun `symbolic reference array pop preserves fake value representations`() { - val states = analyzeStates(methodName = "symbolicReferenceArrayPreservesFakeValue") - - assertTrue( - states.any { state -> - val result = (state.methodResult as? TsMethodResult.Success)?.value - result == state.ctx.mkFp(44.0, state.ctx.fp64Sort) - }, - "Expected the number representation to reach return 44", + fun `conditional receiver containing fake wrapper is not accepted as an array`() { + val state = analyzeStates(methodName = "unknownValue").single() + val fakeReceiver = makeFakeReceiver(state) + val fakeType = with(state.ctx) { fakeReceiver.getFakeType(state.memory) } + val conditionalReceiver = state.ctx.mkIte( + condition = fakeType.boolTypeExpr, + trueBranch = fakeReceiver, + falseBranch = state.makeSymbolicRefUntyped(), ) + + val execution = TsArrayShiftIntrinsicModel.apply(state, arrayShiftCall(conditionalReceiver)) + + assertNull(execution) } @Test - fun `disabled model sends pop to configured residual fallback`() { - val enabledModelIds = mutableSetOf("ts.array.pop") - val selection = TsUnknownCallModelSelection(enabledModelIds) - enabledModelIds.clear() - val result = analyze( + fun `empty enabled set sends shift to configured fallback`() { + val disabledResult = analyze( methodName = "nonEmptyArray", tsOptions = TsOptions( - unknownCallProfile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, - unknownCallModels = TsUnknownCallModelSelection(enabledModelIds = emptySet()), + enabledUnknownCallModelIds = emptySet(), + unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, ), ) - val selectedResult = analyze( - methodName = "nonEmptyArray", - tsOptions = TsOptions(unknownCallModels = selection), - ) - assertEquals(listOf(TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), result.events.map { it.outcome }) - assertIs(result.events.single().decision) - assertEquals(listOf("ts.array.pop"), selectedResult.modelIds) + assertEquals(listOf(TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), disabledResult.events.map { it.outcome }) } @Test - fun `compatibility dispatcher keeps the legacy pop approximation`() { + fun `compatibility dispatcher keeps the legacy shift approximation`() { val result = analyze( methodName = "nonEmptyArray", dispatcher = TsCompatibilityUnknownCallDispatcher, @@ -164,9 +160,31 @@ class TsArrayPopIntrinsicModelTest { val result = analyze(methodName) assertTrue(result.values.isEmpty()) - val event = result.events.single() - assertEquals(TsUnknownCallOutcome.PATH_STOPPED, event.outcome) - assertIs(event.decision) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.single().outcome) + } + + private fun makeFakeReceiver(state: TsState): UConcreteHeapRef { + val result = assertIs(state.methodResult).value + val fakeReceiver = assertIs(result) + + assertTrue(with(state.ctx) { fakeReceiver.isFakeObject() }) + return fakeReceiver + } + + private fun arrayShiftCall(resolvedReceiver: UExpr<*>): TsUnknownCall { + val callSite = method("nonEmptyArray").cfg.stmts.single { stmt -> + stmt.callExpr?.callee?.name == "shift" + } + val sourceCall = assertIs(assertNotNull(callSite.callExpr)) + + return TsUnknownCall( + callee = sourceCall.callee, + receiver = TsUnknownCallValue(source = sourceCall.instance, resolved = resolvedReceiver), + arguments = emptyList(), + resultType = sourceCall.type, + callSite = callSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ) } private fun analyzeStates(methodName: String): List { @@ -182,7 +200,7 @@ class TsArrayPopIntrinsicModelTest { } private fun method(name: String): EtsMethod = scene.projectClasses - .single { it.name == "ArrayPopIntrinsic" } + .single { it.name == "ArrayShiftIntrinsic" } .methods .single { it.name == name } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 1ed384901..9a29604ea 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -11,6 +11,7 @@ import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType import org.jacodb.ets.model.EtsVoidType import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr @@ -19,7 +20,6 @@ import org.junit.jupiter.api.Test import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy -import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UMachineOptions @@ -32,6 +32,7 @@ import org.usvm.machine.TsOptions import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState +import org.usvm.solver.USatResult import org.usvm.util.getResourcePath import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -50,31 +51,25 @@ class TsUnknownCallDispatcherTest { private val fullScene = EtsScene(listOf(sourceFile)) @Test - fun `every profile decision is reported through the interpreter observer`() { + fun `every model or fallback decision is reported through the interpreter observer`() { val cases = listOf( ObservationCase( - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = TsNoUnknownCallModels, + fallback = TsResidualCallPolicy.STOP_PATH, + models = noModels, outcome = TsUnknownCallOutcome.PATH_STOPPED, - decision = TsUnknownCallDecision.ResidualFallback( - policy = TsResidualCallPolicy.STOP_PATH, - reason = TsUnknownCallResidualReason.MODEL_NOT_APPLICABLE, - ), + decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH), finalStateCount = 0, ), ObservationCase( - profile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, - modelProvider = TsNoUnknownCallModels, + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = noModels, outcome = TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN, - decision = TsUnknownCallDecision.ResidualFallback( - policy = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - reason = TsUnknownCallResidualReason.MODEL_LOOKUP_DISABLED, - ), + decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN), finalStateCount = 1, ), ObservationCase( - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - modelProvider = ApplyingModelProvider, + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = catalog(ApplyingModel), outcome = TsUnknownCallOutcome.MODEL_APPLIED, decision = TsUnknownCallDecision.ModelApplied(modelId = "applying-model"), finalStateCount = 1, @@ -85,17 +80,16 @@ class TsUnknownCallDispatcherTest { val observer = RecordingUnknownCallObserver() val states = analyzeAllStates( methodName = "declaredMethodWithoutBodyContinues", - profile = case.profile, - modelProvider = case.modelProvider, + fallback = case.fallback, + models = case.models, observer = observer, ) - assertEquals(case.finalStateCount, states.size, case.profile.toString()) + assertEquals(case.finalStateCount, states.size, case.fallback.toString()) val event = observer.events.single() assertEquals("declaredMethodWithoutBodyContinues", event.callSite.location.method.name) assertEquals("external", event.callee.name) assertEquals(TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, event.failureReason) - assertEquals(case.profile, event.profile) assertEquals(case.outcome, event.outcome) assertEquals(case.decision, event.decision) } @@ -106,8 +100,7 @@ class TsUnknownCallDispatcherTest { val observer = RecordingUnknownCallObserver() val states = analyzeAllStates( methodName = "modeledUnknownCallForks", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = ForkingModelProvider, + models = catalog(ForkingModel), observer = observer, ) @@ -120,13 +113,13 @@ class TsUnknownCallDispatcherTest { fun `throwing observer cannot change fresh or modeled exploration`() { val cases = listOf( ObservationFailureCase( - profile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, - modelProvider = TsNoUnknownCallModels, + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = noModels, expectedFinalStateCount = 1, ), ObservationFailureCase( - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = ForkingModelProvider, + fallback = TsResidualCallPolicy.STOP_PATH, + models = catalog(ForkingModel), expectedFinalStateCount = 2, methodName = "modeledUnknownCallForks", ), @@ -135,12 +128,12 @@ class TsUnknownCallDispatcherTest { cases.forEach { case -> val states = analyzeAllStates( methodName = case.methodName, - profile = case.profile, - modelProvider = case.modelProvider, + fallback = case.fallback, + models = case.models, observer = ThrowingUnknownCallObserver, ) - assertEquals(case.expectedFinalStateCount, states.size, case.profile.toString()) + assertEquals(case.expectedFinalStateCount, states.size, case.fallback.toString()) } } @@ -149,8 +142,7 @@ class TsUnknownCallDispatcherTest { assertFailsWith { TsUnknownCallModelApplication.Applied( modelId = " ", - precision = TsUnknownCallModelPrecision.EXACT, - execution = exactExecution(), + execution = completeExecution(), ) } assertFailsWith { @@ -159,42 +151,30 @@ class TsUnknownCallDispatcherTest { } @Test - fun `model applications enforce exact and partial residual contracts`() { - assertFailsWith { - TsUnknownCallModelApplication.Applied( - modelId = "invalid-exact", - precision = TsUnknownCallModelPrecision.EXACT, - execution = execution(residualGuard = mockk()), - ) - } - assertFailsWith { - TsUnknownCallModelApplication.Applied( - modelId = "invalid-partial", - precision = TsUnknownCallModelPrecision.PARTIAL, - execution = execution(residualGuard = null), + fun `model execution plans require at least one successor`() { + val error = assertFailsWith { + TsUnknownCallModelExecution( + successors = emptyList(), + residualGuard = mockk(), ) } + + assertEquals("A semantic model must declare at least one guarded successor", error.message) } @Test - fun `fresh fallback keeps fake type constraints in state models`() { - val states = analyzeAllStates( - methodName = "freshUnknownCallResult", - profile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, + fun `fresh fallback preserves all fake value representations`() { + assertFreshResultPreservesAllFakeRepresentations( + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, ) - - assertFreshResultModelSatisfiesFakeType(states.single()) } @Test - fun `partial residual fallback keeps fake type constraints in state models`() { - val states = analyzeAllStates( - methodName = "freshUnknownCallResult", - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - modelProvider = UnsupportedPartialModelProvider, + fun `partial residual fallback preserves all fake value representations`() { + assertFreshResultPreservesAllFakeRepresentations( + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = catalog(UnsupportedPartialModel), ) - - assertFreshResultModelSatisfiesFakeType(states.single()) } @Test @@ -202,8 +182,8 @@ class TsUnknownCallDispatcherTest { val observer = RecordingUnknownCallObserver() val states = analyzeAllStates( methodName = "modeledUnknownCallForks", - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - modelProvider = SupportedTrueResidualFalseProvider, + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = catalog(SupportedTrueResidualFalseModel), observer = observer, ) @@ -219,8 +199,7 @@ class TsUnknownCallDispatcherTest { val observer = RecordingUnknownCallObserver() val states = analyzeAllStates( methodName = "modeledUnknownCallForks", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = SupportedTrueResidualFalseProvider, + models = catalog(SupportedTrueResidualFalseModel), observer = observer, ) @@ -235,8 +214,7 @@ class TsUnknownCallDispatcherTest { fun `exceptional model successor preserves exception state`() { val states = analyzeAllStates( methodName = "modeledUnknownCallThrows", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = ExceptionalModelProvider, + models = catalog(ExceptionalModel), ) assertIs(states.single().methodResult) @@ -246,8 +224,7 @@ class TsUnknownCallDispatcherTest { fun `stateful model can return an existing reference alias`() { val states = analyzeAllStates( methodName = "modeledUnknownCallReturnsAlias", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = StatefulAliasModelProvider, + models = catalog(StatefulAliasModel), ) val aliasReturn = method(fullScene, "modeledUnknownCallReturnsAlias") .cfg @@ -261,76 +238,22 @@ class TsUnknownCallDispatcherTest { } @Test - fun `profiles select model lookup independently from residual fallback`() { - val cases = listOf( - ProfileCase( - profile = TsUnknownCallProfiles.STOP_ALL, - withoutModel = ProfileResult( - reachesReturn = false, - outcome = TsUnknownCallOutcome.PATH_STOPPED, - ), - withModel = ProfileResult( - reachesReturn = false, - outcome = TsUnknownCallOutcome.PATH_STOPPED, - ), - ), - ProfileCase( - profile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, - withoutModel = ProfileResult( - reachesReturn = true, - outcome = TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN, - ), - withModel = ProfileResult( - reachesReturn = true, - outcome = TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN, - ), - ), - ProfileCase( - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - withoutModel = ProfileResult( - reachesReturn = false, - outcome = TsUnknownCallOutcome.PATH_STOPPED, - ), - withModel = ProfileResult( - reachesReturn = true, - outcome = TsUnknownCallOutcome.MODEL_APPLIED, - ), - ), - ProfileCase( - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - withoutModel = ProfileResult( - reachesReturn = true, - outcome = TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN, - ), - withModel = ProfileResult( - reachesReturn = true, - outcome = TsUnknownCallOutcome.MODEL_APPLIED, - ), - ), - ) - - cases.forEach { case -> - assertEquals(case.withoutModel, runProfile(case.profile, TsNoUnknownCallModels), case.profile.toString()) - assertEquals(case.withModel, runProfile(case.profile, ApplyingModelProvider), case.profile.toString()) - } - } - - @Test - fun `TsOptions profile configures the machine dispatcher`() { - assertEquals(TsUnknownCallProfiles.MODELS_THEN_STOP, TsOptions().unknownCallProfile) - assertTrue(TsOptions().unknownCallProfile.residualOverrides.isEmpty()) + fun `TsOptions configures one fallback without profiles`() { + assertEquals(TsResidualCallPolicy.STOP_PATH, TsOptions().unknownCallFallback) + assertNull(TsOptions().enabledUnknownCallModelIds) + assertTrue(TsOptions().unknownCallFallbackOverrides.isEmpty()) assertFalse(reachesReturn("declaredMethodWithoutBodyContinues")) assertTrue( reachesReturn( "declaredMethodWithoutBodyContinues", - tsOptions = TsOptions(unknownCallProfile = TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL), + tsOptions = TsOptions(unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN), ) ) } @Test - fun `explicit family override replaces the profile residual fallback`() { + fun `explicit family override replaces the default residual fallback`() { val family = method(fullScene, "declaredMethodWithoutBodyContinues") .cfg .stmts @@ -338,16 +261,15 @@ class TsUnknownCallDispatcherTest { .single { it.callee.name == "external" } .callee .enclosingClass - val profile = TsUnknownCallProfiles.STOP_ALL.copy( - residualOverrides = mapOf( - family to TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ) - ) assertTrue( reachesReturn( "declaredMethodWithoutBodyContinues", - tsOptions = TsOptions(unknownCallProfile = profile), + tsOptions = TsOptions( + unknownCallFallbackOverrides = mapOf( + family to TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), + ), ) ) } @@ -355,9 +277,9 @@ class TsUnknownCallDispatcherTest { @Test fun `fresh symbolic return uses the source call result type`() { val dispatcher = RecordingResultSortDispatcher( - TsProfileUnknownCallDispatcher( - TsUnknownCallProfiles.FRESH_SYMBOLIC_FOR_ALL, - TsNoUnknownCallModels, + TsModelUnknownCallDispatcher( + models = noModels, + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, ) ) @@ -463,7 +385,7 @@ class TsUnknownCallDispatcherTest { } @Test - fun `descriptor keeps typed call data without eagerly resolving arguments`() { + fun `unknown call keeps typed data without eagerly resolving arguments`() { val dispatcher = RecordingUnknownCallDispatcher() val scene = sceneWithout("ExternalStatic") @@ -478,7 +400,7 @@ class TsUnknownCallDispatcherTest { } @Test - fun `descriptor preserves source and resolved values available at dispatch`() { + fun `unknown call preserves source and resolved values available at dispatch`() { val dispatcher = RecordingUnknownCallDispatcher() assertFalse(reachesReturn("nonReferenceInstanceCallPrunes", dispatcher = dispatcher)) @@ -519,7 +441,7 @@ class TsUnknownCallDispatcherTest { } @Test - fun `pointer descriptor pairs its source with the resolved function pointer`() { + fun `pointer call pairs its source with the resolved function pointer`() { val dispatcher = RecordingUnknownCallDispatcher() val pointerCall = method(fullScene, "associatedLoggingPointerContinues", className = "Log") .cfg @@ -543,7 +465,7 @@ class TsUnknownCallDispatcherTest { } @Test - fun `descriptor result type comes from the source overload`() { + fun `unknown call result type comes from the source overload`() { val dispatcher = RecordingUnknownCallDispatcher() assertTrue(reachesReturn("overloadedDeclaredMethodWithoutBodyContinues", dispatcher = dispatcher)) @@ -561,17 +483,17 @@ class TsUnknownCallDispatcherTest { scene: EtsScene = fullScene, tsOptions: TsOptions = TsOptions(), dispatcher: TsUnknownCallDispatcher? = null, - modelProvider: TsUnknownCallModelProvider = TsNoUnknownCallModels, + models: TsUnknownCallModelCatalog = noModels, className: String = "CallFallbackBaseline", ): Boolean = returnStatement(scene, methodName, className) in - reachedStatements(methodName, scene, tsOptions, dispatcher, modelProvider, className) + reachedStatements(methodName, scene, tsOptions, dispatcher, models, className) private fun reachedStatements( methodName: String, scene: EtsScene, tsOptions: TsOptions, dispatcher: TsUnknownCallDispatcher?, - modelProvider: TsUnknownCallModelProvider, + models: TsUnknownCallModelCatalog, className: String, ): Set { val method = method(scene, methodName, className) @@ -585,7 +507,7 @@ class TsUnknownCallDispatcherTest { tsOptions = tsOptions, machineObserver = ReachabilityObserver(), unknownCallDispatcher = dispatcher, - unknownCallModelProvider = modelProvider, + unknownCallModels = models, ).use { machine -> machine.analyze(listOf(method), listOf(initialTarget)) .flatMapTo(mutableSetOf()) { state -> state.pathNode.allStatements } @@ -617,32 +539,58 @@ class TsUnknownCallDispatcherTest { private fun analyzeAllStates( methodName: String, - profile: TsUnknownCallProfile, - modelProvider: TsUnknownCallModelProvider = TsNoUnknownCallModels, + fallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, + models: TsUnknownCallModelCatalog = noModels, observer: TsInterpreterObserver? = null, ): List { val method = method(fullScene, methodName) return TsMachine( scene = fullScene, options = allStatesMachineOptions, - tsOptions = TsOptions(unknownCallProfile = profile), + tsOptions = TsOptions(unknownCallFallback = fallback), observer = observer, - unknownCallModelProvider = modelProvider, + unknownCallModels = models, ).use { machine -> machine.analyze(listOf(method)) } } - private fun assertFreshResultModelSatisfiesFakeType(state: TsState) { - val result = assertIs(state.methodResult).value - val fakeValue = assertIs(result) - val exactlyOneType = state.ctx.run { - assertTrue(fakeValue.isFakeObject()) - fakeValue.getFakeType(state.memory).mkExactlyOneTypeConstraint(this) - } + private fun assertFreshResultPreservesAllFakeRepresentations( + fallback: TsResidualCallPolicy, + models: TsUnknownCallModelCatalog = noModels, + ) { + val method = method(fullScene, "freshUnknownCallResult") + TsMachine( + scene = fullScene, + options = allStatesMachineOptions, + tsOptions = TsOptions(unknownCallFallback = fallback), + unknownCallModels = models, + ).use { machine -> + val state = machine.analyze(listOf(method)).single() + val result = assertIs(state.methodResult).value + val fakeValue = assertIs(result) + val fakeType = with(state.ctx) { + assertTrue(fakeValue.isFakeObject()) + fakeValue.getFakeType(state.memory) + } + val discriminators = mapOf( + "boolean" to fakeType.boolTypeExpr, + "number" to fakeType.fpTypeExpr, + "reference" to fakeType.refTypeExpr, + ) - assertTrue(state.models.isNotEmpty()) - assertTrue(state.models.all { model -> model.eval(exactlyOneType).isTrue }) + discriminators.forEach { (kind, discriminator) -> + val constraints = state.pathConstraints.clone() + constraints += discriminator + val solverResult = state.ctx.solver().check(constraints) + + assertIs>(solverResult, "Fresh fake result lost its $kind representation") + } + + val exactlyOneType = fakeType.mkExactlyOneTypeConstraint(state.ctx) + assertTrue(state.models.isNotEmpty()) + assertTrue(state.models.all { model -> model.eval(exactlyOneType).isTrue }) + } } private class RecordingUnknownCallDispatcher : TsUnknownCallDispatcher { @@ -659,27 +607,6 @@ class TsUnknownCallDispatcherTest { } } - private fun runProfile( - profile: TsUnknownCallProfile, - modelProvider: TsUnknownCallModelProvider, - ): ProfileResult { - val dispatcher = RecordingOutcomeDispatcher(TsProfileUnknownCallDispatcher(profile, modelProvider)) - val reachesReturn = reachesReturn( - "declaredMethodWithoutBodyContinues", - dispatcher = dispatcher, - ) - return ProfileResult(reachesReturn, dispatcher.outcomes.single()) - } - - private class RecordingOutcomeDispatcher( - private val delegate: TsUnknownCallDispatcher, - ) : TsUnknownCallDispatcher { - val outcomes = mutableListOf() - - override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome = - delegate.dispatch(scope, call).also(outcomes::add) - } - private class RecordingResultSortDispatcher( private val delegate: TsUnknownCallDispatcher, ) : TsUnknownCallDispatcher { @@ -697,52 +624,40 @@ class TsUnknownCallDispatcherTest { } } - private object ApplyingModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object ApplyingModel : TestModel(id = "applying-model", methodName = "external") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val successor = TsUnknownCallModelSuccessor( guard = state.ctx.trueExpr, completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, ) - return TsUnknownCallModelApplication.Applied( - modelId = "applying-model", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = null, - ), - ) + return TsUnknownCallModelExecution(successors = listOf(successor)) } } - private object ForkingModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object ForkingModel : TestModel(id = "forking-model", methodName = "convert") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val result = requireNotNull(call.arguments.single().resolved) val condition = result.asExpr(state.ctx.boolSort) val completion = TsUnknownCallModelCompletion.Normal { result } - return TsUnknownCallModelApplication.Applied( - modelId = "forking-model", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf( - TsUnknownCallModelSuccessor( - guard = condition, - completion = completion, - ), - TsUnknownCallModelSuccessor( - guard = state.ctx.mkNot(condition), - completion = completion, - ), + return TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = condition, + completion = completion, + ), + TsUnknownCallModelSuccessor( + guard = state.ctx.mkNot(condition), + completion = completion, ), - residualGuard = null, ), ) } } - private object SupportedTrueResidualFalseProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object SupportedTrueResidualFalseModel : TestModel(id = "partial-model", methodName = "convert") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val result = requireNotNull(call.arguments.single().resolved) val condition = result.asExpr(state.ctx.boolSort) val successor = TsUnknownCallModelSuccessor( @@ -750,19 +665,15 @@ class TsUnknownCallDispatcherTest { completion = TsUnknownCallModelCompletion.Normal { result }, ) - return TsUnknownCallModelApplication.Applied( - modelId = "partial-model", - precision = TsUnknownCallModelPrecision.PARTIAL, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = state.ctx.mkNot(condition), - ), + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.mkNot(condition), ) } } - private object ExceptionalModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object ExceptionalModel : TestModel(id = "exceptional-model", methodName = "fail") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val successor = TsUnknownCallModelSuccessor( guard = state.ctx.trueExpr, completion = TsUnknownCallModelCompletion.Exceptional { @@ -770,37 +681,26 @@ class TsUnknownCallDispatcherTest { }, ) - return TsUnknownCallModelApplication.Applied( - modelId = "exceptional-model", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = null, - ), - ) + return TsUnknownCallModelExecution(successors = listOf(successor)) } } - private object UnsupportedPartialModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object UnsupportedPartialModel : TestModel(id = "unsupported-partial-model", methodName = "value") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val successor = TsUnknownCallModelSuccessor( guard = state.ctx.falseExpr, completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, ) - return TsUnknownCallModelApplication.Applied( - modelId = "unsupported-partial-model", - precision = TsUnknownCallModelPrecision.PARTIAL, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = state.ctx.trueExpr, - ), + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.trueExpr, ) } } - private object StatefulAliasModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { + private object StatefulAliasModel : TestModel(id = "stateful-alias-model", methodName = "identity") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val argument = requireNotNull(call.arguments.single().resolved) val successor = TsUnknownCallModelSuccessor( guard = state.ctx.trueExpr, @@ -808,17 +708,17 @@ class TsUnknownCallDispatcherTest { applyStateChanges = { addedArtificialLocals += STATE_CHANGE_MARKER }, ) - return TsUnknownCallModelApplication.Applied( - modelId = "stateful-alias-model", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = null, - ), - ) + return TsUnknownCallModelExecution(successors = listOf(successor)) } } + private abstract class TestModel( + override val id: String, + methodName: String, + ) : TsUnknownCallModel { + override val target = TsUnknownCallTarget(methodName = methodName) + } + private class RecordingUnknownCallObserver : TsInterpreterObserver { val events = mutableListOf() @@ -833,28 +733,17 @@ class TsUnknownCallDispatcherTest { } } - private data class ProfileCase( - val profile: TsUnknownCallProfile, - val withoutModel: ProfileResult, - val withModel: ProfileResult, - ) - - private data class ProfileResult( - val reachesReturn: Boolean, - val outcome: TsUnknownCallOutcome, - ) - private data class ObservationCase( - val profile: TsUnknownCallProfile, - val modelProvider: TsUnknownCallModelProvider, + val fallback: TsResidualCallPolicy, + val models: TsUnknownCallModelCatalog, val outcome: TsUnknownCallOutcome, val decision: TsUnknownCallDecision, val finalStateCount: Int, ) private data class ObservationFailureCase( - val profile: TsUnknownCallProfile, - val modelProvider: TsUnknownCallModelProvider, + val fallback: TsResidualCallPolicy, + val models: TsUnknownCallModelCatalog, val expectedFinalStateCount: Int, val methodName: String = "declaredMethodWithoutBodyContinues", ) @@ -878,9 +767,12 @@ class TsUnknownCallDispatcherTest { private companion object { const val STATE_CHANGE_MARKER = "semantic-model-state-change" - fun exactExecution(): TsUnknownCallModelExecution = execution(residualGuard = null) + val noModels = TsUnknownCallModelCatalog(emptyList()) + + fun catalog(vararg models: TsUnknownCallModel): TsUnknownCallModelCatalog = + TsUnknownCallModelCatalog(models.toList()) - fun execution(residualGuard: UBoolExpr?): TsUnknownCallModelExecution = + fun completeExecution(): TsUnknownCallModelExecution = TsUnknownCallModelExecution( successors = listOf( TsUnknownCallModelSuccessor( @@ -888,7 +780,6 @@ class TsUnknownCallDispatcherTest { completion = TsUnknownCallModelCompletion.Normal { mockk>() }, ), ), - residualGuard = residualGuard, ) val machineOptions = UMachineOptions( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt deleted file mode 100644 index f27312644..000000000 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallExecutionGuardValidationTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package org.usvm.machine.call - -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsMethod -import org.jacodb.ets.model.EtsScene -import org.jacodb.ets.utils.EtsIrProvider -import org.jacodb.ets.utils.loadEtsFileAutoConvert -import org.junit.jupiter.api.Test -import org.usvm.PathSelectionStrategy -import org.usvm.SolverType -import org.usvm.StateCollectionStrategy -import org.usvm.UMachineOptions -import org.usvm.machine.TsMachine -import org.usvm.machine.TsOptions -import org.usvm.machine.state.TsState -import org.usvm.solver.UUnknownResult -import org.usvm.util.getResourcePath -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.time.Duration - -class TsUnknownCallExecutionGuardValidationTest { - private val sourceFile = loadEtsFileAutoConvert( - getResourcePath("/baseline/CallFallbackBaseline.ts"), - provider = EtsIrProvider.TS_FRONTEND, - ) - private val scene = EtsScene(listOf(sourceFile)) - - @Test - fun `overlapping model successor guards are rejected`() { - assertInvalidModel( - methodName = "declaredMethodWithoutBodyContinues", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = OverlappingSuccessorsModelProvider, - expectedMessage = "Semantic model overlapping-successors produced overlapping guards: " + - "successor[0], successor[1]", - ) - } - - @Test - fun `overlapping model successor and residual guards are rejected`() { - assertInvalidModel( - methodName = "declaredMethodWithoutBodyContinues", - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - modelProvider = OverlappingResidualModelProvider, - expectedMessage = "Semantic model overlapping-residual produced overlapping guards: successor[0], residual", - ) - } - - @Test - fun `exact model successor guards must cover the current call domain`() { - assertInvalidModel( - methodName = "modeledUnknownCallForks", - profile = TsUnknownCallProfiles.MODELS_THEN_STOP, - modelProvider = IncompleteExactModelProvider, - expectedMessage = "Semantic model incomplete-exact guards do not cover the current call domain", - ) - } - - @Test - fun `partial model successor and residual guards must cover the current call domain`() { - assertInvalidModel( - methodName = "modeledUnknownCallForks", - profile = TsUnknownCallProfiles.MODELS_THEN_FRESH_SYMBOLIC, - modelProvider = IncompletePartialModelProvider, - expectedMessage = "Semantic model incomplete-partial guards do not cover the current call domain", - ) - } - - @Test - fun `unknown solver result cannot validate execution guards`() { - val exception = assertFailsWith { - UUnknownResult().requireConclusiveGuardValidation("unknown-guards") - } - - assertEquals( - "Semantic model unknown-guards guards could not be validated: solver returned UNKNOWN", - exception.message, - ) - } - - private fun assertInvalidModel( - methodName: String, - profile: TsUnknownCallProfile, - modelProvider: TsUnknownCallModelProvider, - expectedMessage: String, - ) { - val exception = assertFailsWith { - analyzeAllStates(methodName, profile, modelProvider) - } - - assertEquals(expectedMessage, exception.message) - } - - private fun analyzeAllStates( - methodName: String, - profile: TsUnknownCallProfile, - modelProvider: TsUnknownCallModelProvider, - ): List { - val method = method(methodName) - - return TsMachine( - scene = scene, - options = machineOptions, - tsOptions = TsOptions(unknownCallProfile = profile), - unknownCallModelProvider = modelProvider, - ).use { machine -> - machine.analyze(listOf(method)) - } - } - - private fun method(name: String): EtsMethod = scene.projectClasses - .single { it.name == "CallFallbackBaseline" } - .methods - .single { it.name == name } - - private object OverlappingSuccessorsModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() } - - return TsUnknownCallModelApplication.Applied( - modelId = "overlapping-successors", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf( - TsUnknownCallModelSuccessor(guard = state.ctx.trueExpr, completion = completion), - TsUnknownCallModelSuccessor(guard = state.ctx.trueExpr, completion = completion), - ), - residualGuard = null, - ), - ) - } - } - - private object OverlappingResidualModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val successor = TsUnknownCallModelSuccessor( - guard = state.ctx.trueExpr, - completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, - ) - - return TsUnknownCallModelApplication.Applied( - modelId = "overlapping-residual", - precision = TsUnknownCallModelPrecision.PARTIAL, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = state.ctx.trueExpr, - ), - ) - } - } - - private object IncompleteExactModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val condition = requireNotNull(call.arguments.single().resolved).asExpr(state.ctx.boolSort) - val successor = TsUnknownCallModelSuccessor( - guard = condition, - completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, - ) - - return TsUnknownCallModelApplication.Applied( - modelId = "incomplete-exact", - precision = TsUnknownCallModelPrecision.EXACT, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = null, - ), - ) - } - } - - private object IncompletePartialModelProvider : TsUnknownCallModelProvider { - override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val condition = requireNotNull(call.arguments.single().resolved).asExpr(state.ctx.boolSort) - val successor = TsUnknownCallModelSuccessor( - guard = condition, - completion = TsUnknownCallModelCompletion.Normal { ctx.mkUndefinedValue() }, - ) - - return TsUnknownCallModelApplication.Applied( - modelId = "incomplete-partial", - precision = TsUnknownCallModelPrecision.PARTIAL, - execution = TsUnknownCallModelExecution( - successors = listOf(successor), - residualGuard = state.ctx.falseExpr, - ), - ) - } - } - - private companion object { - val machineOptions = UMachineOptions( - pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), - stateCollectionStrategy = StateCollectionStrategy.ALL, - exceptionsPropagation = true, - stopOnCoverage = 0, - stopOnTargetsReached = false, - timeout = Duration.INFINITE, - stepsFromLastCovered = 3_500L, - solverType = SolverType.YICES, - solverTimeout = Duration.INFINITE, - typeOperationsTimeout = Duration.INFINITE, - throwExceptionOnStepFailure = true, - ) - } -} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt new file mode 100644 index 000000000..cde61a23c --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -0,0 +1,118 @@ +package org.usvm.machine.call + +import org.usvm.machine.state.TsState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TsUnknownCallModelCatalogTest { + @Test + fun `model IDs and target names must be non blank`() { + assertFailsWith { + TsUnknownCallModelCatalog(listOf(model(id = " "))) + } + assertFailsWith { + TsUnknownCallTarget(methodName = " ") + } + assertFailsWith { + TsUnknownCallTarget(methodName = "method", enclosingClassName = " ") + } + } + + @Test + fun `duplicate IDs are rejected`() { + val error = assertFailsWith { + TsUnknownCallModelCatalog( + models = listOf( + model(id = "duplicate", methodName = "first"), + model(id = "duplicate", methodName = "second"), + ) + ) + } + + assertEquals("Duplicate semantic model IDs: duplicate", error.message) + } + + @Test + fun `overlapping declarative targets are rejected before execution`() { + val error = assertFailsWith { + TsUnknownCallModelCatalog( + models = listOf( + model(id = "z-model", methodName = "target"), + model( + id = "a-model", + methodName = "target", + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ), + ) + ) + } + + assertEquals("Ambiguous semantic model targets: a-model, z-model", error.message) + } + + @Test + fun `unknown enabled IDs are rejected`() { + val error = assertFailsWith { + TsUnknownCallModelCatalog( + models = listOf(model(id = "known")), + enabledModelIds = setOf("missing"), + ) + } + + assertEquals("Unknown semantic model IDs: missing", error.message) + } + + @Test + fun `selection and fingerprint do not depend on model order`() { + val forward = listOf( + model(id = "a", methodName = "first"), + model(id = "b", methodName = "second"), + ) + + val first = TsUnknownCallModelCatalog(forward) + val second = TsUnknownCallModelCatalog(forward.reversed()) + + assertEquals(listOf("a", "b"), first.modelIds) + assertEquals(first.modelIds, second.modelIds) + assertEquals(first.fingerprint, second.fingerprint) + } + + @Test + fun `enabled subset is detached and changes fingerprint`() { + val mutableIds = mutableSetOf("a") + val models = listOf( + model(id = "a", methodName = "first"), + model(id = "b", methodName = "second"), + ) + val onlyA = TsUnknownCallModelCatalog(models, enabledModelIds = mutableIds) + mutableIds += "b" + val both = TsUnknownCallModelCatalog(models) + + assertEquals(listOf("a"), onlyA.modelIds) + assertNotEquals(onlyA.fingerprint, both.fingerprint) + assertTrue(onlyA.fingerprint.matches(Regex("[0-9a-f]{64}"))) + } + + private fun model( + id: String, + methodName: String = "target-$id", + failureReason: TsUnknownCallFailureReason? = null, + ): TsUnknownCallModel = FakeModel( + id = id, + target = TsUnknownCallTarget( + methodName = methodName, + failureReason = failureReason, + ), + ) + + private class FakeModel( + override val id: String, + override val target: TsUnknownCallTarget, + ) : TsUnknownCallModel { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution = + error("Fake model must not execute in catalog metadata tests") + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt deleted file mode 100644 index f45624784..000000000 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelRegistryTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.usvm.machine.call - -import io.mockk.mockk -import org.usvm.machine.state.TsState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue - -class TsUnknownCallModelRegistryTest { - @Test - fun `descriptor IDs and supported domains must be non blank`() { - assertFailsWith { - descriptor(id = " ") - } - assertFailsWith { - descriptor(id = "model", domainId = "") - } - assertFailsWith { - descriptor(id = "model", domainDescription = " ") - } - } - - @Test - fun `duplicate IDs are rejected`() { - val error = assertFailsWith { - TsUnknownCallModelRegistry( - registrations = listOf(registration("duplicate"), registration("duplicate")), - ) - } - - assertEquals("Duplicate semantic model IDs: duplicate", error.message) - } - - @Test - fun `ambiguous matches report stable sorted IDs`() { - val registry = TsUnknownCallModelRegistry( - registrations = listOf(registration("z-model"), registration("a-model")), - backends = listOf(FakeBackend), - ).freeze() - - val error = assertFailsWith { - registry.select(mockk()) - } - - assertEquals("Ambiguous semantic models matched: a-model, z-model", error.message) - } - - @Test - fun `unknown enabled IDs are rejected`() { - val registry = TsUnknownCallModelRegistry(listOf(registration("known"))) - - val error = assertFailsWith { - registry.freeze(enabledModelIds = setOf("missing")) - } - - assertEquals("Unknown semantic model IDs: missing", error.message) - } - - @Test - fun `enabled implementation kinds require configured backends`() { - val registry = TsUnknownCallModelRegistry( - registrations = listOf(registration("model-without-backend")), - ) - - val error = assertFailsWith { - registry.freeze() - } - - assertEquals("Missing semantic model backends: INTRINSIC", error.message) - } - - @Test - fun `selection and fingerprint do not depend on registration order`() { - val forward = listOf( - registration(id = "a", matches = false), - registration(id = "b", matches = true), - ) - val call = mockk() - - val first = TsUnknownCallModelRegistry( - registrations = forward, - backends = listOf(FakeBackend), - ).freeze() - val second = TsUnknownCallModelRegistry( - registrations = forward.reversed(), - backends = listOf(FakeBackend), - ).freeze() - - assertEquals("b", first.select(call)?.descriptor?.id) - assertEquals("b", second.select(call)?.descriptor?.id) - assertEquals(first.fingerprint, second.fingerprint) - } - - @Test - fun `frozen subset is detached and changes fingerprint`() { - val mutableIds = mutableSetOf("a") - val registry = TsUnknownCallModelRegistry( - registrations = listOf(registration("a"), registration("b")), - backends = listOf(FakeBackend), - ) - - val onlyA = registry.freeze(enabledModelIds = mutableIds) - mutableIds += "b" - val both = registry.freeze() - - assertEquals(listOf("a"), onlyA.descriptors.map { it.id }) - assertNotEquals(onlyA.fingerprint, both.fingerprint) - assertTrue(onlyA.fingerprint.matches(Regex("[0-9a-f]{64}"))) - } - - private fun registration( - id: String, - matches: Boolean = true, - ) = TsUnknownCallModelRegistration( - descriptor = descriptor(id, matches = matches), - implementation = FakeImplementation, - ) - - private fun descriptor( - id: String, - domainId: String = "test-domain", - domainDescription: String = "Test-only supported domain", - matches: Boolean = true, - ) = TsUnknownCallModelDescriptor( - id = id, - matcher = TsUnknownCallModelMatcher { matches }, - supportedDomain = TsUnknownCallModelSupportedDomain( - id = domainId, - description = domainDescription, - ), - precision = TsUnknownCallModelPrecision.EXACT, - implementationKind = TsUnknownCallModelImplementationKind.INTRINSIC, - ) - - private object FakeImplementation : TsUnknownCallModelImplementation { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC - } - - private object FakeBackend : TsUnknownCallModelBackend { - override val kind: TsUnknownCallModelImplementationKind = - TsUnknownCallModelImplementationKind.INTRINSIC - - override fun execute( - implementation: TsUnknownCallModelImplementation, - state: TsState, - call: TsUnknownCall, - ): TsUnknownCallModelExecution = error("Fake backend must not execute in registry metadata tests") - } -} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt deleted file mode 100644 index 30ce632c5..000000000 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/intrinsic/TsIntrinsicUnknownCallModelTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.usvm.machine.call.intrinsic - -import org.usvm.machine.call.TsUnknownCallModelImplementationKind -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class TsIntrinsicUnknownCallModelTest { - @Test - fun `array pop registration binds the intrinsic backend`() { - val registration = TsArrayPopIntrinsicModel.registration - - assertEquals(expected = "ts.array.pop", actual = registration.descriptor.id) - assertEquals( - expected = TsUnknownCallModelImplementationKind.INTRINSIC, - actual = registration.descriptor.implementationKind, - ) - assertIs(registration.implementation) - } -} diff --git a/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts deleted file mode 100644 index 0258b3974..000000000 --- a/usvm-ts/src/test/resources/models/ArrayPopIntrinsic.ts +++ /dev/null @@ -1,75 +0,0 @@ -// @ts-nocheck -// noinspection JSUnusedGlobalSymbols - -class ArrayElement {} - -export class ArrayPopIntrinsic { - emptyArray(): number | undefined { - const values: number[] = []; - return values.pop(); - } - - nonEmptyArray(): number { - const values = [10, 20, 30]; - return values.pop()! + values.length; - } - - aliasedElement(): number { - const element = new ArrayElement(); - const values: ArrayElement[] = [element]; - if (values.pop() === element) { - return 42; - } - return 0; - } - - symbolicReferenceArray(values: ArrayElement[]): number { - values.pop(); - return 45; - } - - symbolicNumberArray(values: number[]): number { - values.pop(); - return 46; - } - - symbolicUnknownArray(values: any[]): number { - values.pop(); - return 47; - } - - allocatedReferenceArrayWithSymbolicWrite(index: number, value: any): number { - if (index !== 1) { - return 0; - } - - const values: ArrayElement[] = [new ArrayElement(), new ArrayElement()]; - values[index] = value; - const popped: any = values.pop(); - if (typeof popped === "number") { - return 45; - } - - return 0; - } - - popWithArguments(): number { - const values = [1]; - values.pop(0); - return 48; - } - - symbolicReferenceArrayPreservesFakeValue(values: ArrayElement[], value: any): number { - if (values.length !== 1) { - return 0; - } - - values[0] = value; - const popped: any = values.pop(); - if (typeof popped === "number") { - return 44; - } - - return 0; - } -} diff --git a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts new file mode 100644 index 000000000..b6fbaee65 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts @@ -0,0 +1,46 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +class ArrayElement {} + +export class ArrayShiftIntrinsic { + unknownValue(value: any): any { + return value; + } + + emptyArray(): number | undefined { + const values: number[] = []; + return values.shift(); + } + + nonEmptyArray(): number { + const values = [10, 20, 30]; + return values.shift()! + values[0] + values.length; + } + + aliasedElement(): number { + const element = new ArrayElement(); + const values: ArrayElement[] = [element]; + if (values.shift() === element) { + return 42; + } + + return 0; + } + + symbolicNumberArray(values: number[]): number { + values.shift(); + return 46; + } + + symbolicUnknownArray(values: any[]): number { + values.shift(); + return 47; + } + + shiftWithArguments(): number { + const values = [1]; + values.shift(0); + return 48; + } +} From bcc9d62bf0aeb67433a4cd23f3612b99d1637439 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 7 Sep 2026 23:47:42 +0300 Subject: [PATCH 07/18] [TS Calls] Finish model contract simplification --- usvm-ts/UNKNOWN_CALL_MODELS.md | 38 ++++++++++--------- .../org/usvm/machine/TsInterpreterObserver.kt | 2 +- .../main/kotlin/org/usvm/machine/TsMachine.kt | 1 - .../main/kotlin/org/usvm/machine/TsOptions.kt | 2 - .../call/TsUnknownCallModelDispatcher.kt | 19 +++------- .../intrinsic/TsArrayShiftIntrinsicModel.kt | 7 +--- .../call/TsArrayShiftIntrinsicModelTest.kt | 14 +++++++ .../call/TsUnknownCallDispatcherTest.kt | 23 ----------- 8 files changed, 42 insertions(+), 64 deletions(-) diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 578e3fe5b..2e2c6182e 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -82,23 +82,6 @@ The available policies are: `FRESH_SYMBOLIC_RETURN` is deliberately imprecise. Use it only when opaque continuation is preferable to pruning. -### Per-family fallback overrides - -`unknownCallFallbackOverrides` changes the fallback for calls whose callee has a particular -`EtsClassSignature`: - -```kotlin -TsOptions( - unknownCallFallback = TsResidualCallPolicy.STOP_PATH, - unknownCallFallbackOverrides = mapOf( - externalApiSignature to TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ), -) -``` - -An override applies both when no model accepts the call and to a residual state returned by a model. Prefer the global -fallback unless one call family has a concrete reason to differ. - ## Model identity and target Every model implements `TsUnknownCallModel`: @@ -200,6 +183,21 @@ Good intrinsic candidates include: Do not write an intrinsic merely because a library method is stateful. +## Source-model migration + +A source model uses the same `TsUnknownCallModel` object and the same ID, target, successor, and residual contract. +The source-model work in PR #380 should extend a successor completion with the EtsIR entry point and resolved inputs, +make the model's EtsIR files visible in the analysis scene, and enter that method through the regular interpreter. +Receiver binding, arguments, returns, exceptions, heap changes, aliases, and nested calls then use normal interpreter +semantics. They must not be reimplemented in a source-specific dispatcher or backend registry. + +The model checks its supported domain before entering EtsIR. An unsupported call returns `null`; a guarded supported +subdomain uses the complementary residual guard and the same configured fallback. Recursive redirection is prevented +by tracking the active model ID in execution state, not by creating a second catalog. + +`Array.pop` is the source-model example. Its TypeScript body uses indexing and `length`; it must not call `pop` again. +The existing `Array.shift` intrinsic remains the example for engine-only symbolic-memory `memcpy`. + ## Dynamic receivers A method name does not prove the receiver type. In particular, `value.shift()` may call a user-defined property rather @@ -224,7 +222,11 @@ The catalog sorts enabled models by ID and hashes their length-prefixed IDs. The not affect the fingerprint and ambiguous concatenations cannot collide merely because of ID boundaries. The fingerprint identifies the frozen enabled model set for one run. It is not a version and must not be used as a -manually maintained configuration value. +manually maintained configuration value. Experiment metadata records the tool revision separately. If model source +can change independently of that revision, the runner also records a content hash for the external source or generated +artifact; that content identity is experiment metadata, not another model ID, version, or compatibility setting. Keep +the catalog fingerprint based only on enabled model IDs rather than adding implementation-specific fingerprint fields +to the common model contract. ## Observation diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index df1ad1961..6b6c45168 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -13,7 +13,7 @@ import org.usvm.statistics.UInterpreterObserver @Suppress("unused") interface TsInterpreterObserver : UInterpreterObserver { - /** Called after the profile dispatcher selects an outcome for an unknown call. */ + /** Called after the dispatcher selects a model or fallback decision for an unknown call. */ fun onUnknownCall(event: TsUnknownCallEvent) { // default empty implementation } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index edc5c5b5e..15e480447 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -64,7 +64,6 @@ class TsMachine( private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsModelUnknownCallDispatcher( models = requireNotNull(resolvedUnknownCallModels), fallback = tsOptions.unknownCallFallback, - fallbackOverrides = tsOptions.unknownCallFallbackOverrides, observer = observer, ) private val interpreter = TsInterpreter( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index fedf989c0..c3213f3a8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -1,6 +1,5 @@ package org.usvm.machine -import org.jacodb.ets.model.EtsClassSignature import org.usvm.machine.call.TsResidualCallPolicy data class TsOptions( @@ -10,5 +9,4 @@ data class TsOptions( /** `null` enables every built-in model; an empty set disables all models. */ val enabledUnknownCallModelIds: Set? = null, val unknownCallFallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, - val unknownCallFallbackOverrides: Map = emptyMap(), ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index c8933340f..d5d8681d6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -1,6 +1,5 @@ package org.usvm.machine.call -import org.jacodb.ets.model.EtsClassSignature import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall import org.usvm.api.setMockMethodCallResult @@ -27,11 +26,8 @@ enum class TsResidualCallPolicy { class TsModelUnknownCallDispatcher( private val models: TsUnknownCallModelCatalog, private val fallback: TsResidualCallPolicy, - fallbackOverrides: Map = emptyMap(), private val observer: TsInterpreterObserver? = null, ) : TsUnknownCallModelDispatcher { - private val fallbackOverrides = fallbackOverrides.toMap() - override fun dispatch(scope: TsStepScope, call: TsUnknownCall): TsUnknownCallOutcome { val application = scope.calcOnState { this@TsModelUnknownCallDispatcher.models.apply(this, call) @@ -47,10 +43,9 @@ class TsModelUnknownCallDispatcher( scope: TsStepScope, call: TsUnknownCall, ): TsUnknownCallOutcome { - val policy = fallbackFor(call) - val decision = TsUnknownCallDecision.ResidualFallback(policy) + val decision = TsUnknownCallDecision.ResidualFallback(fallback) - when (policy) { + when (fallback) { TsResidualCallPolicy.STOP_PATH -> { val falseExpr = scope.calcOnState { ctx.falseExpr } scope.assert(falseExpr) @@ -72,18 +67,17 @@ class TsModelUnknownCallDispatcher( application: TsUnknownCallModelApplication.Applied, ): TsUnknownCallOutcome { val residualGuard = application.execution.residualGuard - val residualPolicy = fallbackFor(call) // Creating an unresolved value may add fake-value constraints. Do it before forking so the residual clone // inherits both the constraints and their solver models. val freshResidualResult = if ( - residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN + residualGuard != null && fallback == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN ) { makeFreshUnknownCallResult(scope, call.resultType) } else { null } val stoppedResidualIsSatisfiable = residualGuard != null && - residualPolicy == TsResidualCallPolicy.STOP_PATH && + fallback == TsResidualCallPolicy.STOP_PATH && scope.checkSat(residualGuard) != null var modelApplied = false @@ -106,7 +100,7 @@ class TsModelUnknownCallDispatcher( ) }.toMutableList() - if (residualGuard != null && residualPolicy == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { + if (residualGuard != null && fallback == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { guardedStateChanges += residualGuard to { setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) newStmt(call.callSite) @@ -160,9 +154,6 @@ class TsModelUnknownCallDispatcher( } } - private fun fallbackFor(call: TsUnknownCall): TsResidualCallPolicy = - fallbackOverrides[call.callee.enclosingClass] ?: fallback - private fun event( call: TsUnknownCall, decision: TsUnknownCallDecision, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index cf86b6041..cd80fa71b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -36,7 +36,7 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { val length = state.memory.read(lengthLValue) val zero = mkBv(0) val emptyGuard = mkEq(length, zero) - val nonEmptyGuard = mkBvSignedLessExpr(zero, length) + val nonEmptyGuard = mkNot(emptyGuard) val newLength = mkBvSubExpr(length, mkBv(1)) val firstElementLValue = mkArrayIndexLValue( sort = input.elementSort, @@ -67,10 +67,7 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { }, ) - TsUnknownCallModelExecution( - successors = listOf(emptySuccessor, nonEmptySuccessor), - residualGuard = mkNot(mkOr(emptyGuard, nonEmptyGuard)), - ) + TsUnknownCallModelExecution(successors = listOf(emptySuccessor, nonEmptySuccessor)) } private fun resolveInput(state: TsState, call: TsUnknownCall): ArrayShiftInput? = with(state.ctx) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index a0d16c22b..32ac35267 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -69,6 +69,20 @@ class TsArrayShiftIntrinsicModelTest { assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) } + @Test + fun `empty and non empty guards are complementary`() { + val state = analyzeStates(methodName = "unknownValue").single() + val symbolicArray = state.makeSymbolicRefUntyped() + + val application = TsArrayShiftIntrinsicModel.apply(state, arrayShiftCall(symbolicArray)) + val execution = assertNotNull(application) + val (emptyArray, nonEmptyArray) = execution.successors + + assertEquals(2, execution.successors.size) + assertEquals(state.ctx.mkNot(emptyArray.guard), nonEmptyArray.guard) + assertNull(execution.residualGuard) + } + @Test fun `symbolic unknown array uses residual fallback`() { assertUsesResidualFallback(methodName = "symbolicUnknownArray") diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 9a29604ea..81ae7a03b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -241,7 +241,6 @@ class TsUnknownCallDispatcherTest { fun `TsOptions configures one fallback without profiles`() { assertEquals(TsResidualCallPolicy.STOP_PATH, TsOptions().unknownCallFallback) assertNull(TsOptions().enabledUnknownCallModelIds) - assertTrue(TsOptions().unknownCallFallbackOverrides.isEmpty()) assertFalse(reachesReturn("declaredMethodWithoutBodyContinues")) assertTrue( @@ -252,28 +251,6 @@ class TsUnknownCallDispatcherTest { ) } - @Test - fun `explicit family override replaces the default residual fallback`() { - val family = method(fullScene, "declaredMethodWithoutBodyContinues") - .cfg - .stmts - .mapNotNull { it.callExpr } - .single { it.callee.name == "external" } - .callee - .enclosingClass - - assertTrue( - reachesReturn( - "declaredMethodWithoutBodyContinues", - tsOptions = TsOptions( - unknownCallFallbackOverrides = mapOf( - family to TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, - ), - ), - ) - ) - } - @Test fun `fresh symbolic return uses the source call result type`() { val dispatcher = RecordingResultSortDispatcher( From eba8e4e69bab023a99d09371467796ac56b9e3f7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 8 Sep 2026 16:27:35 +0300 Subject: [PATCH 08/18] [TS Calls] Support unresolved Array.shift elements --- usvm-ts/UNKNOWN_CALL_MODELS.md | 15 +- .../usvm/machine/call/TsUnknownCallModel.kt | 6 + .../call/TsUnknownCallModelDispatcher.kt | 36 +++- .../intrinsic/TsArrayShiftIntrinsicModel.kt | 190 ++++++++++++++++-- .../kotlin/org/usvm/machine/expr/ReadArray.kt | 52 +++-- .../org/usvm/machine/types/FakeExprUtil.kt | 13 ++ .../usvm/machine/types/TsUnresolvedValue.kt | 13 ++ .../call/TsArrayShiftIntrinsicModelTest.kt | 103 +++++++++- .../resources/models/ArrayShiftIntrinsic.ts | 38 +++- 9 files changed, 408 insertions(+), 58 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 2e2c6182e..70dbb9c4d 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -60,10 +60,11 @@ The built-in catalog currently contains one model: | ID | Implementation | Accepted calls | | --- | --- | --- | -| `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array whose element sort is known. | +| `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | -An `any`/unknown receiver, a fake-value wrapper, a non-array receiver, and an array whose element sort is unresolved do -not become applicable merely because the method is named `shift`; they use fallback. +An `any`/unknown receiver, a fake-value wrapper, and a non-array receiver do not become applicable merely because the +method is named `shift`; they use fallback. A definitely-array receiver with an unresolved element sort remains +applicable and uses the fake-value representation described below. ### `unknownCallFallback` @@ -138,7 +139,7 @@ The target identifies a call family. State-dependent checks, such as the receive The built-in array target intentionally combines the method name with `PARTIAL_APPROXIMATION` instead of a class name. That failure reason is emitted only after the regular approximation path has classified the receiver as an `EtsArrayType`. Calls on `any`/unknown receivers reach another failure reason and cannot match this target. The model -still validates the resolved receiver and its element sort before changing memory. +still validates the resolved receiver and array shape before changing memory. ## Applicability and residual states @@ -171,8 +172,10 @@ model on every call. An intrinsic directly builds guarded successors and symbolic-memory operations in Kotlin. Use it only for an operation that TypeScript cannot express without losing symbolic efficiency or correctness. -`Array.shift` is the built-in example because shifting a symbolic array is naturally represented by one -`memory.memcpy` operation. +`Array.shift` is the built-in example because shifting a symbolic array is naturally represented by symbolic-memory +`memcpy` operations. A resolved element sort uses one array region. A symbolic array with an unresolved element sort +uses the boolean, number, and address regions that back a fake value; its removed element is materialized before +forking so the exactly-one type constraint and updated solver models are inherited by every successor. Good intrinsic candidates include: diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt index 923236737..240f4f003 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsType import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedValue /** Declaratively identifies the calls handled by one semantic model. */ data class TsUnknownCallTarget( @@ -55,6 +56,11 @@ sealed interface TsUnknownCallModelCompletion { val result: TsState.() -> UExpr<*>, ) : TsUnknownCallModelCompletion + /** Produces a normal fake-wrapped result for a value whose runtime kind is unresolved. */ + class Unresolved( + val value: TsUnresolvedValue, + ) : TsUnknownCallModelCompletion + /** Produces an exceptional result and its TypeScript type on the selected successor state. */ class Exceptional( val exception: TsState.() -> Pair, EtsType>, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index d5d8681d6..5a524e6de 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -1,5 +1,6 @@ package org.usvm.machine.call +import org.usvm.UExpr import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall import org.usvm.api.setMockMethodCallResult @@ -8,6 +9,7 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.state.newStmt +import org.usvm.machine.types.mkFakeValue /** The externally observable effect of an unknown-call decision. */ enum class TsUnknownCallOutcome { @@ -83,11 +85,22 @@ class TsModelUnknownCallDispatcher( var modelApplied = false var modelEventReported = false var freshResidualApplied = false - val guardedStateChanges = application.execution.successors.map { successor -> + // Materializing an unresolved result adds its exactly-one constraint. Do it before forking so every + // successor that uses the wrapper inherits both the constraint and the refreshed solver models. + val preparedUnresolvedResults = application.execution.successors.map { successor -> + val completion = successor.completion as? TsUnknownCallModelCompletion.Unresolved + ?: return@map null + + scope.calcOnState { + mkFakeValue(scope = scope, value = completion.value) + } + } + val guardedStateChanges = application.execution.successors.mapIndexed { index, successor -> successor.guard to modelStateChange( call = call, modelId = application.modelId, successor = successor, + preparedUnresolvedResult = preparedUnresolvedResults[index], onApplied = { modelApplied = true if (modelEventReported) { @@ -106,18 +119,18 @@ class TsModelUnknownCallDispatcher( newStmt(call.callSite) freshResidualApplied = true - observer?.onUnknownCallSafely( - event(call, TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN)) - ) + val decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) + val fallbackEvent = event(call, decision) + observer?.onUnknownCallSafely(fallbackEvent) } } scope.forkMulti(guardedStateChanges) if (stoppedResidualIsSatisfiable) { - observer?.onUnknownCallSafely( - event(call, TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH)) - ) + val decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH) + val fallbackEvent = event(call, decision) + observer?.onUnknownCallSafely(fallbackEvent) } return when { @@ -132,6 +145,7 @@ class TsModelUnknownCallDispatcher( call: TsUnknownCall, modelId: String, successor: TsUnknownCallModelSuccessor, + preparedUnresolvedResult: UExpr<*>?, onApplied: () -> Boolean, ): TsState.() -> Unit = { successor.applyStateChanges(this) @@ -143,6 +157,14 @@ class TsModelUnknownCallDispatcher( newStmt(call.callSite) } + is TsUnknownCallModelCompletion.Unresolved -> { + val result = requireNotNull(preparedUnresolvedResult) { + "Unresolved semantic-model result was not materialized" + } + methodResult = TsMethodResult.Success.MockedCall(result, call.callee) + newStmt(call.callSite) + } + is TsUnknownCallModelCompletion.Exceptional -> { val (exception, type) = completion.exception(this) methodResult = TsMethodResult.TsException(exception, type) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index cd80fa71b..5c8608af1 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -2,11 +2,17 @@ package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsUnknownType import org.usvm.UAddressSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.USort import org.usvm.api.memcpy import org.usvm.api.typeStreamOf +import org.usvm.collection.array.UArrayIndexLValue +import org.usvm.machine.TsSizeSort import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModel @@ -15,7 +21,10 @@ import org.usvm.machine.call.TsUnknownCallModelExecution import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.expr.readSymbolicUnresolvedArrayElement import org.usvm.machine.state.TsState +import org.usvm.machine.types.findMaterializedFakeValue +import org.usvm.sizeSort import org.usvm.types.singleOrNull import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue @@ -35,16 +44,11 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { val lengthLValue = mkArrayLengthLValue(input.array, input.arrayType) val length = state.memory.read(lengthLValue) val zero = mkBv(0) + val one = mkBv(1) val emptyGuard = mkEq(length, zero) val nonEmptyGuard = mkNot(emptyGuard) - val newLength = mkBvSubExpr(length, mkBv(1)) - val firstElementLValue = mkArrayIndexLValue( - sort = input.elementSort, - ref = input.array, - index = zero, - type = input.arrayType, - ) - val firstElement = state.memory.read(firstElementLValue) + val newLength = mkBvSubExpr(length, one) + val firstElementCompletion = state.firstElementCompletion(input, zero) val emptySuccessor = TsUnknownCallModelSuccessor( guard = emptyGuard, @@ -52,17 +56,9 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { ) val nonEmptySuccessor = TsUnknownCallModelSuccessor( guard = nonEmptyGuard, - completion = TsUnknownCallModelCompletion.Normal { firstElement }, + completion = firstElementCompletion, applyStateChanges = { - memory.memcpy( - srcRef = input.array, - dstRef = input.array, - type = input.arrayType, - elementSort = input.elementSort, - fromSrc = mkBv(1), - fromDst = zero, - length = newLength, - ) + shiftElements(input, fromSrc = one, fromDst = zero, length = newLength) memory.write(lengthLValue, newLength, guard = trueExpr) }, ) @@ -90,11 +86,163 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { } val elementSort = typeToSort(arrayType.elementType) - if (elementSort is TsUnresolvedSort) { - return@with null + ArrayShiftInput(array, arrayType, elementSort) + } + + private fun TsState.firstElementCompletion( + input: ArrayShiftInput, + index: UExpr, + ): TsUnknownCallModelCompletion = with(ctx) { + if (input.elementSort !is TsUnresolvedSort) { + val firstElementLValue = mkArrayIndexLValue( + sort = input.elementSort, + ref = input.array, + index = index, + type = input.arrayType, + ) + val firstElement = memory.read(firstElementLValue) + + return@with TsUnknownCallModelCompletion.Normal { firstElement } } - ArrayShiftInput(array, arrayType, elementSort) + if (input.array is UConcreteHeapRef) { + val firstElementLValue = mkArrayIndexLValue( + sort = addressSort, + ref = input.array, + index = index, + type = input.arrayType, + ) + val firstElement = memory.read(firstElementLValue) + + return@with TsUnknownCallModelCompletion.Normal { + check(firstElement.isFakeObject()) { + "Expected fake object in concrete array with unresolved element type, got: $firstElement" + } + firstElement + } + } + + val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + val firstElementLValue = mkArrayIndexLValue(addressSort, input.array, index, unknownArrayType) + val materializedFirstElement = findMaterializedFakeValue(firstElementLValue) + if (materializedFirstElement != null) { + return@with TsUnknownCallModelCompletion.Normal { materializedFirstElement } + } + + val firstElement = readSymbolicUnresolvedArrayElement(input.array, index) + TsUnknownCallModelCompletion.Unresolved(firstElement) + } + + private fun TsState.shiftElements( + input: ArrayShiftInput, + fromSrc: UExpr, + fromDst: UExpr, + length: UExpr, + ) = with(ctx) { + if (input.elementSort !is TsUnresolvedSort) { + copyArrayRegion( + input = input, + arrayType = input.arrayType, + elementSort = input.elementSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + return@with + } + + if (input.array is UConcreteHeapRef) { + copyArrayRegion( + input = input, + arrayType = input.arrayType, + elementSort = addressSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + shiftMaterializedFakeValues(input) + return@with + } + + copyArrayRegion( + input = input, + arrayType = EtsArrayType(EtsBooleanType, dimensions = 1), + elementSort = boolSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + copyArrayRegion( + input = input, + arrayType = EtsArrayType(EtsNumberType, dimensions = 1), + elementSort = fp64Sort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + copyArrayRegion( + input = input, + arrayType = EtsArrayType(EtsUnknownType, dimensions = 1), + elementSort = addressSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + shiftMaterializedFakeValues(input) + } + + private fun TsState.copyArrayRegion( + input: ArrayShiftInput, + arrayType: EtsArrayType, + elementSort: USort, + fromSrc: UExpr, + fromDst: UExpr, + length: UExpr, + ) { + memory.memcpy( + srcRef = input.array, + dstRef = input.array, + type = ctx.arrayDescriptorOf(arrayType), + elementSort = elementSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + } + + private fun TsState.shiftMaterializedFakeValues(input: ArrayShiftInput) = with(ctx) { + val arrayDescriptor = if (input.array is UConcreteHeapRef) { + arrayDescriptorOf(input.arrayType) + } else { + arrayDescriptorOf(EtsArrayType(EtsUnknownType, dimensions = 1)) + } + val zero = mkBv(0) + val one = mkBv(1) + val shiftedValues = lValuesToAllocatedFakeObjects.mapNotNull { (lValue, fakeValue) -> + if ( + lValue !is UArrayIndexLValue<*, *, *> || + lValue.ref != input.array || + lValue.arrayType != arrayDescriptor + ) { + return@mapNotNull null + } + + val sourceIndex = lValue.index.asExpr(sizeSort) + if (sourceIndex == zero) { + return@mapNotNull null + } + + val destinationIndex = mkBvSubExpr(sourceIndex, one) + val destinationLValue = UArrayIndexLValue( + addressSort, + input.array, + destinationIndex, + arrayDescriptor, + ) + destinationLValue to fakeValue + } + + lValuesToAllocatedFakeObjects += shiftedValues } private class ArrayShiftInput( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt index 684c8902d..3b097ad1a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt @@ -15,6 +15,9 @@ import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedValue +import org.usvm.machine.types.findMaterializedFakeValue import org.usvm.machine.types.mkFakeValue import org.usvm.sizeSort import org.usvm.types.first @@ -127,28 +130,49 @@ fun TsContext.readArray( // that can hold boolean, number, and reference values. // We read all three types from the array and combine them into a fake object. return scope.calcOnState { - val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) - val boolLValue = mkArrayIndexLValue(boolSort, array, index, boolArrayType) - val bool = memory.read(boolLValue) - - val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) - val fpLValue = mkArrayIndexLValue(fp64Sort, array, index, numberArrayType) - val fp = memory.read(fpLValue) - val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) val refLValue = mkArrayIndexLValue(addressSort, array, index, unknownArrayType) - val ref = memory.read(refLValue) + val materializedValue = findMaterializedFakeValue(refLValue) + if (materializedValue != null) { + return@calcOnState materializedValue + } + + val value = readSymbolicUnresolvedArrayElement(array, index) - // If the read reference is already a fake object, we can return it directly. - // Otherwise, we need to create a new fake object and write it back to the memory. + // Reuse an existing fake object or materialize a flat wrapper for all three payloads. // TODO: Think about the type constraint to get a consistent array resolution later - if (ref.isFakeObject()) { - ref + if (value.refValue.isFakeObject()) { + value.refValue } else { - val fakeObj = mkFakeValue(scope, bool, fp, ref) + val fakeObj = mkFakeValue(scope = scope, value = value) lValuesToAllocatedFakeObjects += refLValue to fakeObj memory.write(refLValue, fakeObj, guard = trueExpr) fakeObj } } } + +internal fun TsState.readSymbolicUnresolvedArrayElement( + array: UHeapRef, + index: UExpr, +): TsUnresolvedValue = with(ctx) { + check(array !is UConcreteHeapRef) { "A concrete unresolved array stores fake-value wrappers directly" } + + val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) + val boolLValue = mkArrayIndexLValue(boolSort, array, index, boolArrayType) + val boolValue = memory.read(boolLValue) + + val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val fpLValue = mkArrayIndexLValue(fp64Sort, array, index, numberArrayType) + val fpValue = memory.read(fpLValue) + + val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + val refLValue = mkArrayIndexLValue(addressSort, array, index, unknownArrayType) + val refValue = memory.read(refLValue) + + TsUnresolvedValue( + boolValue = boolValue, + fpValue = fpValue, + refValue = refValue, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 59fa51a6b..3c75494d9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -15,6 +15,9 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsState import org.usvm.memory.ULValue +internal fun TsState.findMaterializedFakeValue(lValue: ULValue<*, *>): UConcreteHeapRef? = + lValuesToAllocatedFakeObjects.lastOrNull { (recordedLValue) -> recordedLValue == lValue }?.second + /** * Creates a fresh synthetic wrapper for a TypeScript value with a not necessarily known runtime kind. * @@ -84,6 +87,16 @@ fun TsState.mkFakeValue( fakeValueRef } +fun TsState.mkFakeValue( + scope: TsStepScope?, + value: TsUnresolvedValue, +): UConcreteHeapRef = mkFakeValue( + scope = scope, + boolValue = value.boolValue, + fpValue = value.fpValue, + refValue = value.refValue, +) + fun TsState.extractValue( value: UExpr, sort: T, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt new file mode 100644 index 000000000..7d7f7bb65 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt @@ -0,0 +1,13 @@ +package org.usvm.machine.types + +import io.ksmt.sort.KFp64Sort +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef + +/** The three backing payloads of a TypeScript value whose active runtime kind is not resolved yet. */ +data class TsUnresolvedValue( + val boolValue: UBoolExpr, + val fpValue: UExpr, + val refValue: UHeapRef, +) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index 32ac35267..7b8bdd69a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -1,8 +1,12 @@ package org.usvm.machine.call +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert @@ -22,6 +26,8 @@ import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.util.TsTestResolver import org.usvm.util.getResourcePath +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -55,7 +61,7 @@ class TsArrayShiftIntrinsicModelTest { } @Test - fun `reference array preserves removed element alias`() { + fun `reference array preserves removed element alias and moves tail`() { val result = analyze(methodName = "aliasedElement") assertEquals(42.0, assertIs(result.values.single()).number) @@ -84,8 +90,92 @@ class TsArrayShiftIntrinsicModelTest { } @Test - fun `symbolic unknown array uses residual fallback`() { - assertUsesResidualFallback(methodName = "symbolicUnknownArray") + fun `symbolic unknown array preserves removed element and moves all value regions`() { + val result = analyze(methodName = "symbolicUnknownArray") + val reachesExpectedResult = result.values.any { value -> + (value as? TsTestValue.TsNumber)?.number == 47.0 + } + + assertTrue(reachesExpectedResult) + assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) + } + + @Test + fun `symbolic unknown array copies boolean number and address regions`() { + val state = analyzeStates(methodName = "unknownValue").single() + val symbolicArray = state.makeSymbolicRefUntyped() + + with(state.ctx) { + val zero = mkBv(0) + val one = mkBv(1) + val boolValue = trueExpr + val fpValue = mkFp64(17.0) + val refValue = state.makeSymbolicRefUntyped() + + val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) + val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + + val lengthLValue = mkArrayLengthLValue(symbolicArray, unknownArrayType) + state.memory.write(lengthLValue, mkBv(2), guard = trueExpr) + state.memory.write( + mkArrayIndexLValue(boolSort, symbolicArray, one, boolArrayType), + boolValue, + guard = trueExpr, + ) + state.memory.write( + mkArrayIndexLValue(fp64Sort, symbolicArray, one, numberArrayType), + fpValue, + guard = trueExpr, + ) + state.memory.write( + mkArrayIndexLValue(addressSort, symbolicArray, one, unknownArrayType), + refValue, + guard = trueExpr, + ) + + val execution = assertNotNull( + TsArrayShiftIntrinsicModel.apply( + state, + arrayShiftCall(symbolicArray, methodName = "symbolicUnknownArray"), + ) + ) + val nonEmptySuccessor = execution.successors.last() + assertIs(nonEmptySuccessor.completion) + + nonEmptySuccessor.applyStateChanges(state) + + val shiftedBoolValue = state.memory.read( + mkArrayIndexLValue(boolSort, symbolicArray, zero, boolArrayType) + ) + val shiftedFpValue = state.memory.read( + mkArrayIndexLValue(fp64Sort, symbolicArray, zero, numberArrayType) + ) + val shiftedRefValue = state.memory.read( + mkArrayIndexLValue(addressSort, symbolicArray, zero, unknownArrayType) + ) + + assertEquals(boolValue, shiftedBoolValue) + assertEquals(fpValue, shiftedFpValue) + assertEquals(refValue, shiftedRefValue) + assertEquals(one, state.memory.read(lengthLValue)) + } + } + + @Test + fun `concrete unknown array shifts fake wrapped values`() { + val result = analyze(methodName = "mixedUnknownArray") + + assertEquals(49.0, assertIs(result.values.single()).number) + assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) + } + + @Test + fun `empty concrete unknown array returns undefined`() { + val result = analyze(methodName = "emptyUnknownArray") + + assertIs(result.values.single()) + assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) } @Test @@ -185,8 +275,11 @@ class TsArrayShiftIntrinsicModelTest { return fakeReceiver } - private fun arrayShiftCall(resolvedReceiver: UExpr<*>): TsUnknownCall { - val callSite = method("nonEmptyArray").cfg.stmts.single { stmt -> + private fun arrayShiftCall( + resolvedReceiver: UExpr<*>, + methodName: String = "nonEmptyArray", + ): TsUnknownCall { + val callSite = method(methodName).cfg.stmts.single { stmt -> stmt.callExpr?.callee?.name == "shift" } val sourceCall = assertIs(assertNotNull(callSite.callExpr)) diff --git a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts index b6fbaee65..52f114978 100644 --- a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts +++ b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts @@ -19,9 +19,10 @@ export class ArrayShiftIntrinsic { } aliasedElement(): number { - const element = new ArrayElement(); - const values: ArrayElement[] = [element]; - if (values.shift() === element) { + const first = new ArrayElement(); + const second = new ArrayElement(); + const values: ArrayElement[] = [first, second]; + if (values.shift() === first && values[0] === second && values.length === 1) { return 42; } @@ -34,8 +35,35 @@ export class ArrayShiftIntrinsic { } symbolicUnknownArray(values: any[]): number { - values.shift(); - return 47; + if (values.length < 2) { + return 0; + } + + const oldLength = values.length; + const firstType = typeof values[0]; + const secondType = typeof values[1]; + const removedType = typeof values.shift(); + if (removedType === firstType && typeof values[0] === secondType && values.length === oldLength - 1) { + return 47; + } + + return 1; + } + + mixedUnknownArray(): number { + const element = new ArrayElement(); + const values: any[] = [10, true, element]; + const removed = values.shift(); + if (removed === 10 && values[0] === true && values[1] === element && values.length === 2) { + return 49; + } + + return 0; + } + + emptyUnknownArray(): any { + const values: any[] = []; + return values.shift(); } shiftWithArguments(): number { From 9251124fa96f2f2a63074478272b03c400113692 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 17 Sep 2026 23:17:29 +0300 Subject: [PATCH 09/18] [TS Calls] Read unresolved arrays from symbolic memory --- .../intrinsic/TsArrayShiftIntrinsicModel.kt | 47 ------------- .../kotlin/org/usvm/machine/expr/ReadArray.kt | 18 ++--- .../org/usvm/machine/types/FakeExprUtil.kt | 44 ++++++++++--- .../call/TsArrayShiftIntrinsicModelTest.kt | 66 +++++++++++++++++-- .../kotlin/org/usvm/util/TsTestResolver.kt | 8 ++- .../resources/models/ArrayShiftIntrinsic.ts | 31 +++++++++ 6 files changed, 137 insertions(+), 77 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index 5c8608af1..f30ca7ec7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -11,7 +11,6 @@ import org.usvm.UExpr import org.usvm.USort import org.usvm.api.memcpy import org.usvm.api.typeStreamOf -import org.usvm.collection.array.UArrayIndexLValue import org.usvm.machine.TsSizeSort import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason @@ -23,8 +22,6 @@ import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.expr.readSymbolicUnresolvedArrayElement import org.usvm.machine.state.TsState -import org.usvm.machine.types.findMaterializedFakeValue -import org.usvm.sizeSort import org.usvm.types.singleOrNull import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue @@ -122,13 +119,6 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { } } - val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) - val firstElementLValue = mkArrayIndexLValue(addressSort, input.array, index, unknownArrayType) - val materializedFirstElement = findMaterializedFakeValue(firstElementLValue) - if (materializedFirstElement != null) { - return@with TsUnknownCallModelCompletion.Normal { materializedFirstElement } - } - val firstElement = readSymbolicUnresolvedArrayElement(input.array, index) TsUnknownCallModelCompletion.Unresolved(firstElement) } @@ -160,7 +150,6 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { fromDst = fromDst, length = length, ) - shiftMaterializedFakeValues(input) return@with } @@ -188,7 +177,6 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { fromDst = fromDst, length = length, ) - shiftMaterializedFakeValues(input) } private fun TsState.copyArrayRegion( @@ -210,41 +198,6 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { ) } - private fun TsState.shiftMaterializedFakeValues(input: ArrayShiftInput) = with(ctx) { - val arrayDescriptor = if (input.array is UConcreteHeapRef) { - arrayDescriptorOf(input.arrayType) - } else { - arrayDescriptorOf(EtsArrayType(EtsUnknownType, dimensions = 1)) - } - val zero = mkBv(0) - val one = mkBv(1) - val shiftedValues = lValuesToAllocatedFakeObjects.mapNotNull { (lValue, fakeValue) -> - if ( - lValue !is UArrayIndexLValue<*, *, *> || - lValue.ref != input.array || - lValue.arrayType != arrayDescriptor - ) { - return@mapNotNull null - } - - val sourceIndex = lValue.index.asExpr(sizeSort) - if (sourceIndex == zero) { - return@mapNotNull null - } - - val destinationIndex = mkBvSubExpr(sourceIndex, one) - val destinationLValue = UArrayIndexLValue( - addressSort, - input.array, - destinationIndex, - arrayDescriptor, - ) - destinationLValue to fakeValue - } - - lValuesToAllocatedFakeObjects += shiftedValues - } - private class ArrayShiftInput( val array: UExpr, val arrayType: EtsArrayType, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt index 3b097ad1a..13df2781b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt @@ -17,7 +17,6 @@ import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsState import org.usvm.machine.types.TsUnresolvedValue -import org.usvm.machine.types.findMaterializedFakeValue import org.usvm.machine.types.mkFakeValue import org.usvm.sizeSort import org.usvm.types.first @@ -132,23 +131,16 @@ fun TsContext.readArray( return scope.calcOnState { val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) val refLValue = mkArrayIndexLValue(addressSort, array, index, unknownArrayType) - val materializedValue = findMaterializedFakeValue(refLValue) - if (materializedValue != null) { - return@calcOnState materializedValue - } - val value = readSymbolicUnresolvedArrayElement(array, index) - // Reuse an existing fake object or materialize a flat wrapper for all three payloads. - // TODO: Think about the type constraint to get a consistent array resolution later - if (value.refValue.isFakeObject()) { - value.refValue - } else { - val fakeObj = mkFakeValue(scope = scope, value = value) + // Materialize the current symbolic-memory value instead of consulting allocation history. + val fakeObj = mkFakeValue(scope = scope, value = value) + if (fakeObj != value.refValue) { lValuesToAllocatedFakeObjects += refLValue to fakeObj memory.write(refLValue, fakeObj, guard = trueExpr) - fakeObj } + + fakeObj } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 3c75494d9..141e68be2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -6,6 +6,7 @@ import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.UIteExpr import org.usvm.USort import org.usvm.api.makeSymbolicPrimitive import org.usvm.collection.field.UFieldLValue @@ -15,9 +16,6 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsState import org.usvm.memory.ULValue -internal fun TsState.findMaterializedFakeValue(lValue: ULValue<*, *>): UConcreteHeapRef? = - lValuesToAllocatedFakeObjects.lastOrNull { (recordedLValue) -> recordedLValue == lValue }?.second - /** * Creates a fresh synthetic wrapper for a TypeScript value with a not necessarily known runtime kind. * @@ -88,14 +86,40 @@ fun TsState.mkFakeValue( } fun TsState.mkFakeValue( - scope: TsStepScope?, + scope: TsStepScope, + value: TsUnresolvedValue, +): UConcreteHeapRef = materializeFakeValue(scope, value, value.refValue) + +private fun TsState.materializeFakeValue( + scope: TsStepScope, value: TsUnresolvedValue, -): UConcreteHeapRef = mkFakeValue( - scope = scope, - boolValue = value.boolValue, - fpValue = value.fpValue, - refValue = value.refValue, -) + refValue: UHeapRef, +): UConcreteHeapRef = with(ctx) { + when { + refValue.isFakeObject() -> refValue + + !refValue.containsFakeObject() -> mkFakeValue( + scope = scope, + boolValue = value.boolValue, + fpValue = value.fpValue, + refValue = refValue, + ) + + refValue is UIteExpr<*> -> { + val trueValue = materializeFakeValue(scope, value, refValue.trueBranch.asExpr(addressSort)) + val falseValue = materializeFakeValue(scope, value, refValue.falseBranch.asExpr(addressSort)) + + iteWriteIntoFakeObject( + scope = scope, + condition = refValue.condition, + trueBranchValue = trueValue, + falseBranchValue = falseValue, + ) + } + + else -> error("Unsupported fake-value reference expression: $refValue") + } +} fun TsState.extractValue( value: UExpr, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index 7b8bdd69a..6ff81e5c4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -92,14 +92,40 @@ class TsArrayShiftIntrinsicModelTest { @Test fun `symbolic unknown array preserves removed element and moves all value regions`() { val result = analyze(methodName = "symbolicUnknownArray") - val reachesExpectedResult = result.values.any { value -> - (value as? TsTestValue.TsNumber)?.number == 47.0 - } + val numbers = result.values.mapNotNull { value -> (value as? TsTestValue.TsNumber)?.number } - assertTrue(reachesExpectedResult) + assertEquals(setOf(0.0, 47.0), numbers.toSet()) assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) } + @Test + fun `shift reads the moved element from current memory`() { + val result = analyze(methodName = "readBeforeShift") + val numbers = result.values.mapNotNull { value -> (value as? TsTestValue.TsNumber)?.number } + + assertEquals(setOf(0.0, 1.0, 2.0), numbers.toSet()) + } + + @Test + fun `array read observes a write through an aliased symbolic index`() { + val result = analyze(methodName = "writeThroughSymbolicIndex") + val numbers = result.values.mapNotNull { value -> (value as? TsTestValue.TsNumber)?.number } + + assertTrue(20.0 in numbers) + assertTrue(10.0 !in numbers) + } + + @Test + fun `current resolver observes a shifted fake wrapper`() { + val result = analyze(methodName = "shiftedWrittenUnknownArray") + val arrays = result.values.filterIsInstance>() + val shiftedValues = arrays.mapNotNull { array -> + (array.values.singleOrNull() as? TsTestValue.TsNumber)?.number + } + + assertEquals(listOf(20.0), shiftedValues) + } + @Test fun `symbolic unknown array copies boolean number and address regions`() { val state = analyzeStates(methodName = "unknownValue").single() @@ -170,6 +196,38 @@ class TsArrayShiftIntrinsicModelTest { assertEquals(listOf(TsUnknownCallOutcome.MODEL_APPLIED), result.events.map { it.outcome }) } + @Test + fun `repeated shifts do not copy fake allocation history`() { + val state = analyzeStates(methodName = "unknownValue").single() + val symbolicArray = state.makeSymbolicRefUntyped() + val fakeValue = makeFakeReceiver(state) + + with(state.ctx) { + val arrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + val materializedElement = mkArrayIndexLValue(addressSort, symbolicArray, mkBv(19), arrayType) + state.lValuesToAllocatedFakeObjects += materializedElement to fakeValue + state.memory.write(materializedElement, fakeValue, guard = trueExpr) + state.memory.write( + mkArrayLengthLValue(symbolicArray, arrayType), + mkBv(20), + guard = trueExpr, + ) + val historyBeforeShift = state.lValuesToAllocatedFakeObjects.toList() + + repeat(12) { + val execution = assertNotNull( + TsArrayShiftIntrinsicModel.apply( + state, + arrayShiftCall(symbolicArray, methodName = "symbolicUnknownArray"), + ) + ) + execution.successors.last().applyStateChanges(state) + } + + assertEquals(historyBeforeShift, state.lValuesToAllocatedFakeObjects) + } + } + @Test fun `empty concrete unknown array returns undefined`() { val result = analyze(methodName = "emptyUnknownArray") diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt index 3e81ed3d1..9fd2d523f 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -273,11 +273,13 @@ open class TsTestStateResolver( val sort = typeToSort(type.elementType) if (sort is TsUnresolvedSort) { - val arrayIndexLValue = mkArrayIndexLValue(addressSort, concreteRef, index, type) + val resolvedArrayIndexLValue = mkArrayIndexLValue(addressSort, concreteRef, index, type) val fakeObject = if (memory is UModel) { - resolvedLValuesToFakeObjects.firstOrNull { it.first == arrayIndexLValue }?.second + resolvedLValuesToFakeObjects.firstOrNull { it.first == resolvedArrayIndexLValue }?.second } else { - resolvedLValuesToFakeObjects.lastOrNull { it.first == arrayIndexLValue }?.second + val currentArrayIndexLValue = mkArrayIndexLValue(addressSort, heapRef, index, type) + val currentValue = evaluateInModel(memory.read(currentArrayIndexLValue)) + (currentValue as? UConcreteHeapRef)?.takeIf { it.isFakeObject() } } fakeObject ?: return@map TsTestValue.TsUndefined diff --git a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts index 52f114978..c22d82dcd 100644 --- a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts +++ b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts @@ -71,4 +71,35 @@ export class ArrayShiftIntrinsic { values.shift(0); return 48; } + + readBeforeShift(values: any[]): number { + if (values.length !== 2) { + return 0; + } + + values[0] = 10; + values.shift(); + return values[0] === 20 ? 1 : 2; + } + + writeThroughSymbolicIndex(values: any[], index: number): any { + if (values.length !== 2 || index !== 0) { + return 0; + } + + values[0] = 10; + values[index] = 20; + return values[0]; + } + + shiftedWrittenUnknownArray(values: any[]): any[] { + if (values.length !== 2) { + return []; + } + + values[0] = 10; + values[1] = 20; + values.shift(); + return values; + } } From baebc661834906865e4a18c77fcf287853d8e1cf Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 13:34:45 +0300 Subject: [PATCH 10/18] [TS Calls] Preserve array storage and input types across shifts --- usvm-ts/UNKNOWN_CALL_MODELS.md | 14 +- .../main/kotlin/org/usvm/machine/TsContext.kt | 1 - .../org/usvm/machine/TsInterpreterObserver.kt | 5 +- .../call/TsUnknownCallModelDispatcher.kt | 32 +- .../intrinsic/TsArrayShiftIntrinsicModel.kt | 39 +-- .../usvm/machine/expr/CallApproximations.kt | 4 +- .../kotlin/org/usvm/machine/expr/ReadArray.kt | 70 +--- .../org/usvm/machine/expr/ReadLength.kt | 4 +- .../org/usvm/machine/expr/WriteArray.kt | 14 +- .../org/usvm/machine/types/FakeExprUtil.kt | 35 +- .../machine/types/TsUnresolvedArrayKind.kt | 56 +++ .../usvm/machine/types/TsUnresolvedValue.kt | 3 +- .../main/kotlin/org/usvm/util/LValueUtil.kt | 18 + .../call/TsArrayShiftIntrinsicModelTest.kt | 44 ++- .../machine/call/TsArrayShiftMatrixTest.kt | 181 ++++++++++ .../machine/call/TsArrayShiftReplayTest.kt | 318 ++++++++++++++++++ .../call/TsUnknownCallDispatcherTest.kt | 27 ++ .../kotlin/org/usvm/util/TsTestResolver.kt | 41 +-- .../resources/models/ArrayShiftIntrinsic.ts | 48 +++ 19 files changed, 775 insertions(+), 179 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 70dbb9c4d..79f5ad268 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -174,8 +174,14 @@ that TypeScript cannot express without losing symbolic efficiency or correctness `Array.shift` is the built-in example because shifting a symbolic array is naturally represented by symbolic-memory `memcpy` operations. A resolved element sort uses one array region. A symbolic array with an unresolved element sort -uses the boolean, number, and address regions that back a fake value; its removed element is materialized before -forking so the exactly-one type constraint and updated solver models are inherited by every successor. +copies three payload regions (boolean, number, and address) and three boolean runtime-kind selector regions. +Selectors belong to input elements and move with their payloads, so repeated shifts preserve the constraints needed +to reconstruct and replay the original input. Allocated unresolved arrays store fake-value wrappers in the address +region. The removed element is materialized before forking so the exactly-one type constraint and updated solver +models are inherited by every successor. + +Array reads, writes, length access, and `shift` use the storage type known to symbolic memory when it is unique. +Widening a local from `number[]` to `any[]` therefore keeps the same element and length regions. Good intrinsic candidates include: @@ -233,7 +239,9 @@ to the common model contract. ## Observation -Every applied model or fallback produces `TsUnknownCallEvent` through `TsInterpreterObserver.onUnknownCall`. +Every completed model or fallback decision produces `TsUnknownCallEvent` through `TsInterpreterObserver.onUnknownCall`. +A model event is emitted once after all satisfiable successor callbacks complete. If a callback throws, dispatch does +not report success for the discarded step. A partially supported call may report both a model and a fallback event. - `ModelApplied(modelId)` identifies the semantic model. - `ResidualFallback(policy)` records the effective fallback. diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 0248715fa..8596f6931 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -171,7 +171,6 @@ class TsContext( is EtsBooleanType -> EtsArrayType(EtsBooleanType, dimensions = 1) is EtsNumberType -> EtsArrayType(EtsNumberType, dimensions = 1) is EtsArrayType -> TODO("Unsupported yet: $type") - is EtsUnionType -> EtsArrayType(type.elementType, dimensions = 1) else -> EtsArrayType(EtsUnknownType, dimensions = 1) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 6b6c45168..132c6413b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -13,7 +13,10 @@ import org.usvm.statistics.UInterpreterObserver @Suppress("unused") interface TsInterpreterObserver : UInterpreterObserver { - /** Called after the dispatcher selects a model or fallback decision for an unknown call. */ + /** + * Called after the dispatcher completes a model or fallback decision for an unknown call. + * A model decision is reported once after all its satisfiable successor callbacks complete. + */ fun onUnknownCall(event: TsUnknownCallEvent) { // default empty implementation } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index 5a524e6de..cf4f285eb 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -83,7 +83,6 @@ class TsModelUnknownCallDispatcher( scope.checkSat(residualGuard) != null var modelApplied = false - var modelEventReported = false var freshResidualApplied = false // Materializing an unresolved result adds its exactly-one constraint. Do it before forking so every // successor that uses the wrapper inherits both the constraint and the refreshed solver models. @@ -98,18 +97,9 @@ class TsModelUnknownCallDispatcher( val guardedStateChanges = application.execution.successors.mapIndexed { index, successor -> successor.guard to modelStateChange( call = call, - modelId = application.modelId, successor = successor, preparedUnresolvedResult = preparedUnresolvedResults[index], - onApplied = { - modelApplied = true - if (modelEventReported) { - false - } else { - modelEventReported = true - true - } - }, + onApplied = { modelApplied = true }, ) }.toMutableList() @@ -118,19 +108,16 @@ class TsModelUnknownCallDispatcher( setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) newStmt(call.callSite) freshResidualApplied = true - - val decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) - val fallbackEvent = event(call, decision) - observer?.onUnknownCallSafely(fallbackEvent) } } scope.forkMulti(guardedStateChanges) - if (stoppedResidualIsSatisfiable) { - val decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH) - val fallbackEvent = event(call, decision) - observer?.onUnknownCallSafely(fallbackEvent) + if (modelApplied) { + observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ModelApplied(application.modelId))) + } + if (freshResidualApplied || stoppedResidualIsSatisfiable) { + observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ResidualFallback(fallback))) } return when { @@ -143,10 +130,9 @@ class TsModelUnknownCallDispatcher( private fun modelStateChange( call: TsUnknownCall, - modelId: String, successor: TsUnknownCallModelSuccessor, preparedUnresolvedResult: UExpr<*>?, - onApplied: () -> Boolean, + onApplied: () -> Unit, ): TsState.() -> Unit = { successor.applyStateChanges(this) @@ -171,9 +157,7 @@ class TsModelUnknownCallDispatcher( } } - if (onApplied()) { - observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ModelApplied(modelId))) - } + onApplied() } private fun event( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index f30ca7ec7..c7fcce998 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -10,7 +10,6 @@ import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.USort import org.usvm.api.memcpy -import org.usvm.api.typeStreamOf import org.usvm.machine.TsSizeSort import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason @@ -20,9 +19,10 @@ import org.usvm.machine.call.TsUnknownCallModelExecution import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.expr.TsUnresolvedSort -import org.usvm.machine.expr.readSymbolicUnresolvedArrayElement import org.usvm.machine.state.TsState -import org.usvm.types.singleOrNull +import org.usvm.machine.types.TsUnresolvedArrayKind +import org.usvm.machine.types.readUnresolvedArrayElement +import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue @@ -75,8 +75,7 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { } val array = receiverValue.asExpr(addressSort) - val arrayType = (receiver.source.type as? EtsArrayType) - ?: (state.memory.typeStreamOf(array).singleOrNull() as? EtsArrayType) + val arrayType = state.arrayStorageType(array, receiver.source.type) as? EtsArrayType ?: return@with null if (arrayType.dimensions != 1) { return@with null @@ -102,24 +101,7 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { return@with TsUnknownCallModelCompletion.Normal { firstElement } } - if (input.array is UConcreteHeapRef) { - val firstElementLValue = mkArrayIndexLValue( - sort = addressSort, - ref = input.array, - index = index, - type = input.arrayType, - ) - val firstElement = memory.read(firstElementLValue) - - return@with TsUnknownCallModelCompletion.Normal { - check(firstElement.isFakeObject()) { - "Expected fake object in concrete array with unresolved element type, got: $firstElement" - } - firstElement - } - } - - val firstElement = readSymbolicUnresolvedArrayElement(input.array, index) + val firstElement = readUnresolvedArrayElement(memory, input.array, index) TsUnknownCallModelCompletion.Unresolved(firstElement) } @@ -177,6 +159,17 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { fromDst = fromDst, length = length, ) + TsUnresolvedArrayKind.entries.forEach { kind -> + memory.memcpy( + srcRef = input.array, + dstRef = input.array, + type = kind, + elementSort = boolSort, + fromSrc = fromSrc, + fromDst = fromDst, + length = length, + ) + } } private fun TsState.copyArrayRegion( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index c48b2c552..5e6599282 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -29,7 +29,7 @@ import org.usvm.machine.interpreter.setResolvedValue import org.usvm.machine.state.lastStmt import org.usvm.sizeSort import org.usvm.types.first -import org.usvm.types.singleOrNull +import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods @@ -93,7 +93,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val instanceType = if (instance.sort == addressSort && isAllocatedConcreteHeapRef(instance)) { scope.calcOnState { - memory.typeStreamOf(instance.asExpr(addressSort)).singleOrNull() ?: expr.instance.type + arrayStorageType(instance.asExpr(addressSort), expr.instance.type) } } else { expr.instance.type diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt index 13df2781b..4ffb6e227 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt @@ -4,22 +4,15 @@ import io.ksmt.utils.asExpr import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType -import org.jacodb.ets.model.EtsBooleanType -import org.jacodb.ets.model.EtsNumberType -import org.jacodb.ets.model.EtsUnknownType -import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef -import org.usvm.api.typeStreamOf -import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope -import org.usvm.machine.state.TsState -import org.usvm.machine.types.TsUnresolvedValue import org.usvm.machine.types.mkFakeValue +import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort -import org.usvm.types.first +import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue @@ -63,12 +56,7 @@ internal fun TsExprResolver.handleArrayAccess( isSigned = true, ).asExpr(sizeSort) - // Determine the array type. - val arrayType = if (isAllocatedConcreteHeapRef(array)) { - scope.calcOnState { memory.typeStreamOf(array).first() } - } else { - value.array.type - } + val arrayType = scope.calcOnState { arrayStorageType(array, value.array.type) } check(arrayType is EtsArrayType) { "Expected EtsArrayType, got: ${value.array.type}" } @@ -109,62 +97,14 @@ fun TsContext.readArray( return scope.calcOnState { memory.read(lValue) } } - // Concrete arrays with the unresolved sort should consist of fake objects only. - if (array is UConcreteHeapRef) { - // Read a fake object from the array. - val lValue = mkArrayIndexLValue( - sort = addressSort, - ref = array, - index = index, - type = arrayType, - ) - val fake = scope.calcOnState { memory.read(lValue) } - check(fake.isFakeObject()) { - "Expected fake object in concrete array with unresolved element type, got: $fake" - } - return fake - } - - // If the element type is unresolved, we need to create a fake object - // that can hold boolean, number, and reference values. - // We read all three types from the array and combine them into a fake object. return scope.calcOnState { - val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) - val refLValue = mkArrayIndexLValue(addressSort, array, index, unknownArrayType) - val value = readSymbolicUnresolvedArrayElement(array, index) - - // Materialize the current symbolic-memory value instead of consulting allocation history. + val value = readUnresolvedArrayElement(memory, array, index) val fakeObj = mkFakeValue(scope = scope, value = value) if (fakeObj != value.refValue) { - lValuesToAllocatedFakeObjects += refLValue to fakeObj + val refLValue = mkArrayIndexLValue(addressSort, array, index, arrayType) memory.write(refLValue, fakeObj, guard = trueExpr) } fakeObj } } - -internal fun TsState.readSymbolicUnresolvedArrayElement( - array: UHeapRef, - index: UExpr, -): TsUnresolvedValue = with(ctx) { - check(array !is UConcreteHeapRef) { "A concrete unresolved array stores fake-value wrappers directly" } - - val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) - val boolLValue = mkArrayIndexLValue(boolSort, array, index, boolArrayType) - val boolValue = memory.read(boolLValue) - - val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) - val fpLValue = mkArrayIndexLValue(fp64Sort, array, index, numberArrayType) - val fpValue = memory.read(fpLValue) - - val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) - val refLValue = mkArrayIndexLValue(addressSort, array, index, unknownArrayType) - val refValue = memory.read(refLValue) - - TsUnresolvedValue( - boolValue = boolValue, - fpValue = fpValue, - refValue = refValue, - ) -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt index fa4d83b68..86444e105 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt @@ -12,6 +12,7 @@ import org.usvm.UHeapRef import org.usvm.machine.TsContext import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort +import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayLengthLValue // Handles reading the `length` property. @@ -22,7 +23,8 @@ fun TsContext.readLengthProperty( maxArraySize: Int, ): UExpr<*>? { // Determine the array type. - val arrayType: EtsArrayType = when (val type = instanceLocal.type) { + val storageType = scope.calcOnState { arrayStorageType(instance, instanceLocal.type) } + val arrayType: EtsArrayType = when (val type = storageType) { is EtsArrayType -> type is EtsAnyType, is EtsUnknownType -> { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt index b6ad444ea..f24700c2f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt @@ -5,13 +5,11 @@ import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType import org.usvm.UExpr import org.usvm.UHeapRef -import org.usvm.api.typeStreamOf -import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort -import org.usvm.types.first +import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue @@ -44,14 +42,7 @@ internal fun TsExprResolver.handleAssignToArrayIndex( isSigned = true, ).asExpr(sizeSort) - // Determine the array type. - // TODO: handle the case when `lhv.array.type` is NOT an array. - // In this case, it could be created manually: `EtsArrayType(EtsUnknownType, 1)`. - val arrayType = if (isAllocatedConcreteHeapRef(array)) { - scope.calcOnState { memory.typeStreamOf(array).first() } - } else { - lhv.array.type - } + val arrayType = scope.calcOnState { arrayStorageType(array, lhv.array.type) } check(arrayType is EtsArrayType) { "Expected EtsArrayType, got: ${lhv.array.type}" } @@ -106,7 +97,6 @@ fun TsContext.assignToArrayIndex( ) val fakeExpr = expr.toFakeObject(scope) return scope.doWithState { - lValuesToAllocatedFakeObjects += lValue to fakeExpr memory.write(lValue, fakeExpr, guard = trueExpr) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 141e68be2..532d7d929 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -22,7 +22,8 @@ import org.usvm.memory.ULValue * Non-null arguments initialize the corresponding boolean, number, and reference payload fields. When exactly one * payload is supplied, the wrapper is constrained to that runtime kind. When multiple payloads are supplied, all * three kind discriminators remain symbolic and [EtsFakeType.mkExactlyOneTypeConstraint] selects exactly one active - * representation. Callers that model a completely unknown value should therefore supply all three payloads. + * representation. [valueType], when provided, preserves existing kind selectors instead of creating fresh ones. + * Callers that model a completely unknown value should supply all three payloads. * * The returned concrete heap reference identifies the wrapper, not its reference payload. Consumers must preserve * the wrapper or explicitly constrain the appropriate discriminator before extracting a payload. @@ -36,6 +37,7 @@ fun TsState.mkFakeValue( boolValue: UBoolExpr? = null, fpValue: UExpr? = null, refValue: UHeapRef? = null, + valueType: EtsFakeType? = null, ): UConcreteHeapRef = with(ctx) { require(boolValue != null || fpValue != null || refValue != null) { "Fake object should contain at least one value" @@ -44,20 +46,22 @@ fun TsState.mkFakeValue( val fakeValueRef = createFakeObjectRef() val address = fakeValueRef.address - val boolTypeExpr = trueExpr - .takeIf { boolValue != null && fpValue == null && refValue == null } - ?: makeSymbolicPrimitive(boolSort) - val fpTypeExpr = trueExpr - .takeIf { boolValue == null && fpValue != null && refValue == null } - ?: makeSymbolicPrimitive(boolSort) - val refTypeExpr = trueExpr - .takeIf { boolValue == null && fpValue == null && refValue != null } - ?: makeSymbolicPrimitive(boolSort) - - val type = EtsFakeType( - boolTypeExpr = boolTypeExpr, - fpTypeExpr = fpTypeExpr, - refTypeExpr = refTypeExpr, + val type = valueType ?: EtsFakeType( + boolTypeExpr = if (boolValue != null && fpValue == null && refValue == null) { + trueExpr + } else { + makeSymbolicPrimitive(boolSort) + }, + fpTypeExpr = if (boolValue == null && fpValue != null && refValue == null) { + trueExpr + } else { + makeSymbolicPrimitive(boolSort) + }, + refTypeExpr = if (boolValue == null && fpValue == null && refValue != null) { + trueExpr + } else { + makeSymbolicPrimitive(boolSort) + }, ) memory.types.allocate(address, type) val constraint = type.mkExactlyOneTypeConstraint(ctx) @@ -103,6 +107,7 @@ private fun TsState.materializeFakeValue( boolValue = value.boolValue, fpValue = value.fpValue, refValue = refValue, + valueType = value.type, ) refValue is UIteExpr<*> -> { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt new file mode 100644 index 000000000..7837aa67a --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt @@ -0,0 +1,56 @@ +package org.usvm.machine.types + +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.collection.array.UArrayIndexLValue +import org.usvm.isAllocatedConcreteHeapRef +import org.usvm.machine.TsContext +import org.usvm.machine.TsSizeSort +import org.usvm.memory.UReadOnlyMemory +import org.usvm.util.mkArrayIndexLValue + +/** Kind selectors live with input elements, so copying elements also preserves their runtime types. */ +internal enum class TsUnresolvedArrayKind { + BOOLEAN, + NUMBER, + REFERENCE, +} + +internal fun TsContext.readUnresolvedArrayElement( + memory: UReadOnlyMemory<*>, + array: UHeapRef, + index: UExpr, +): TsUnresolvedValue { + val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + val refValue = memory.read(mkArrayIndexLValue(addressSort, array, index, unknownArrayType)) + + // Allocated unresolved arrays store complete wrappers, including conditional writes, in the address region. + if (isAllocatedConcreteHeapRef(array)) { + return TsUnresolvedValue( + boolValue = falseExpr, + fpValue = mkFp64(0.0), + refValue = refValue, + type = EtsFakeType.mkRef(this), + ) + } + + val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) + val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val boolKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.BOOLEAN)) + val fpKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.NUMBER)) + val refKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.REFERENCE)) + val type = EtsFakeType(boolTypeExpr = boolKind, fpTypeExpr = fpKind, refTypeExpr = refKind) + val boolValue = memory.read(mkArrayIndexLValue(boolSort, array, index, boolArrayType)) + val fpValue = memory.read(mkArrayIndexLValue(fp64Sort, array, index, numberArrayType)) + + return TsUnresolvedValue( + boolValue = boolValue, + fpValue = fpValue, + refValue = refValue, + type = type, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt index 7d7f7bb65..19781e016 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt @@ -5,9 +5,10 @@ import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.UHeapRef -/** The three backing payloads of a TypeScript value whose active runtime kind is not resolved yet. */ +/** The backing payloads and kind selectors of a TypeScript value with an unresolved runtime kind. */ data class TsUnresolvedValue( val boolValue: UBoolExpr, val fpValue: UExpr, val refValue: UHeapRef, + val type: EtsFakeType, ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt index abedd267c..8b319bd53 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt @@ -4,17 +4,35 @@ import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsField import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsType +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort +import org.usvm.USymbolicHeapRef +import org.usvm.api.typeStreamOf import org.usvm.collection.array.UArrayIndexLValue import org.usvm.collection.array.length.UArrayLengthLValue import org.usvm.collection.field.UFieldLValue +import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.IntermediateLValueField import org.usvm.machine.TsSizeSort import org.usvm.machine.expr.tctx +import org.usvm.machine.state.TsState import org.usvm.memory.URegisterStackLValue import org.usvm.sizeSort +import org.usvm.types.singleOrNull + +/** Local type widening does not change the regions backing an array. */ +internal fun TsState.arrayStorageType(ref: UHeapRef, staticType: EtsType): EtsType { + if (ref !is UConcreteHeapRef && ref !is USymbolicHeapRef) return staticType + + val memoryType = memory.typeStreamOf(ref).singleOrNull() + return if (memoryType is EtsArrayType || isAllocatedConcreteHeapRef(ref)) { + memoryType ?: staticType + } else { + staticType + } +} fun mkFieldLValue( sort: Sort, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index 6ff81e5c4..099c18a28 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -10,6 +10,8 @@ import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.callExpr import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy @@ -18,12 +20,14 @@ import org.usvm.UExpr import org.usvm.UMachineOptions import org.usvm.api.TsTestValue import org.usvm.api.makeSymbolicRefUntyped +import org.usvm.collection.array.UArrayIndexLValue import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.util.TsTestResolver import org.usvm.util.getResourcePath import org.usvm.util.mkArrayIndexLValue @@ -127,7 +131,7 @@ class TsArrayShiftIntrinsicModelTest { } @Test - fun `symbolic unknown array copies boolean number and address regions`() { + fun `symbolic unknown array copies payload and runtime kind regions`() { val state = analyzeStates(methodName = "unknownValue").single() val symbolicArray = state.makeSymbolicRefUntyped() @@ -160,6 +164,11 @@ class TsArrayShiftIntrinsicModelTest { guard = trueExpr, ) + TsUnresolvedArrayKind.entries.forEach { kind -> + val selector = UArrayIndexLValue(boolSort, symbolicArray, one, kind) + state.memory.write(selector, mkBool(kind == TsUnresolvedArrayKind.NUMBER), guard = trueExpr) + } + val execution = assertNotNull( TsArrayShiftIntrinsicModel.apply( state, @@ -184,6 +193,10 @@ class TsArrayShiftIntrinsicModelTest { assertEquals(boolValue, shiftedBoolValue) assertEquals(fpValue, shiftedFpValue) assertEquals(refValue, shiftedRefValue) + TsUnresolvedArrayKind.entries.forEach { kind -> + val shiftedSelector = UArrayIndexLValue(boolSort, symbolicArray, zero, kind) + assertEquals(mkBool(kind == TsUnresolvedArrayKind.NUMBER), state.memory.read(shiftedSelector)) + } assertEquals(one, state.memory.read(lengthLValue)) } } @@ -292,6 +305,34 @@ class TsArrayShiftIntrinsicModelTest { assertNull(result.catalogFingerprint) } + @ParameterizedTest + @ValueSource( + strings = [ + "numberArrayThroughAnyAlias", + "booleanArrayThroughUnknownAlias", + "conditionalArray", + "conditionalEmptyArray", + "pushAfterShift", + ], + ) + fun `aliases and mutated mixed arrays preserve values`(methodName: String) { + val result = analyze(methodName) + + assertTrue(result.values.isNotEmpty()) + assertEquals(setOf(1.0), result.values.map { assertIs(it).number }.toSet()) + if (methodName == "conditionalArray" || methodName == "conditionalEmptyArray") { + assertEquals(2, result.values.size, "Both receiver choices must be explored") + } + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `conditional fake elements retain both possible writes`() { + val result = analyze(methodName = "conditionalFakeElement") + + assertEquals(setOf(0.0, 1.0), result.values.map { assertIs(it).number }.toSet()) + } + private fun analyze( methodName: String, tsOptions: TsOptions = TsOptions(), @@ -393,6 +434,7 @@ class TsArrayShiftIntrinsicModelTest { pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), stateCollectionStrategy = StateCollectionStrategy.ALL, exceptionsPropagation = true, + throwExceptionOnStepFailure = true, timeout = Duration.INFINITE, stepsFromLastCovered = 3_500L, solverType = SolverType.YICES, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt new file mode 100644 index 000000000..b4633a6c0 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt @@ -0,0 +1,181 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.io.TempDir +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayShiftMatrixTest { + @TempDir + lateinit var directory: Path + + @TestFactory + fun `concrete array matrix agrees with JavaScript`(): List { + val cases = concreteCases() + val source = directory.resolve("ArrayShiftMatrix.ts") + source.writeText(renderSource(cases, typed = true)) + val scene = EtsScene(listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) + val methods = scene.projectClasses.single { it.name == "ArrayShiftMatrix" }.methods.associateBy { it.name } + val invocations = cases.indices.joinToString(separator = ",") { "new ArrayShiftMatrix().case$it()" } + val oracleSource = renderSource(cases, typed = false) + "\nconsole.log([$invocations].join('\\n'));\n" + val expected = runJavaScript(oracleSource) + + assertEquals(cases.size, expected.size) + assertTrue(expected.all { it == "1" }, "Generated invariants must hold in native JavaScript") + + return cases.mapIndexed { index, case -> + DynamicTest.dynamicTest(case.label) { + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + val method = methods.getValue("case$index") + + val values = TsMachine(scene, options = machineOptions, tsOptions = TsOptions(), observer = observer) + .use { machine -> + machine.analyze(listOf(method)).map { state -> + TsTestResolver().resolve(method, state).returnValue + } + } + + val actual = values.map { assertIs(it).number } + assertEquals(listOf(expected[index].toDouble()), actual) + assertEquals(case.shiftCount, events.size) + assertTrue(events.all { it.decision == TsUnknownCallDecision.ModelApplied("ts.array.shift") }) + } + } + } + + private fun concreteCases(): List { + val families = listOf( + Family(name = "numbers", type = "number", values = listOf("-3", "0", "17")), + Family(name = "booleans", type = "boolean", values = listOf("true", "false", "true")), + Family(name = "strings", type = "string", values = listOf("'left'", "''", "'right'")), + Family(name = "references", type = "ShiftElement", values = listOf("first", "second", "first")), + Family(name = "nulls", type = "null", values = listOf("null", "null")), + Family(name = "undefineds", type = "undefined", values = listOf("undefined", "undefined")), + Family(name = "mixed", type = "any", values = listOf("17", "true", "first", "'left'", "null", "undefined")), + Family(name = "union", type = "(number | boolean)", values = listOf("17", "true", "-3", "false")), + Family( + name = "nullable", + type = "(ShiftElement | null | undefined)", + values = listOf("null", "first", "undefined"), + ), + Family(name = "nested values", type = "any", values = listOf("true", "nested", "first")), + Family(name = "function values", type = "any", values = listOf("false", "callback", "undefined")), + Family(name = "special numbers", type = "number", values = listOf("NaN", "Infinity", "-Infinity", "-0.0")), + ) + + return buildList { + for (family in families) { + for (size in listOf(0, 1, family.values.size).distinct()) { + for (type in listOf(family.type, "any", "unknown").distinct()) { + val values = family.values.take(size) + for (drain in listOf(false, true)) { + add( + ShiftCase( + label = "${family.name}, $type[], size=$size, drain=$drain", + type = type, + values = values, + drain = drain, + ) + ) + } + } + } + } + } + } + + private fun renderSource(cases: List, typed: Boolean): String = buildString { + appendLine("class ShiftElement {}") + appendLine("class ArrayShiftMatrix {") + cases.forEachIndexed { index, case -> + appendLine("case$index() {") + appendLine("const first = new ShiftElement(); const second = new ShiftElement();") + appendLine("const nested = [7, 8]; const callback = () => 1;") + val annotation = if (typed) ": ${case.type}[]" else "" + appendLine("const original$annotation = [${case.values.joinToString()}];") + appendLine("const values$annotation = original;") + + repeat(case.shiftCount) { shift -> + appendLine("const removed$shift = values.shift();") + val value = case.values.getOrElse(shift) { "undefined" } + appendLine("if (!(${sameValue("removed$shift", value)})) return -1;") + val tail = case.values.drop(shift + 1) + appendLine("if (original.length !== ${tail.size} || values.length !== ${tail.size}) return -2;") + tail.forEachIndexed { tailIndex, tailValue -> + appendLine("if (!(${sameValue("original[$tailIndex]", tailValue)})) return -3;") + } + } + appendLine("return 1;") + appendLine("}") + } + appendLine("}") + } + + private fun sameValue(actual: String, expected: String): String = when (expected) { + "NaN" -> "Number.isNaN($actual)" + "-0.0" -> "1 / $actual === -Infinity" + else -> "$actual === $expected" + } + + private fun runJavaScript(source: String): List { + val script = directory.resolve("oracle.js") + val output = directory.resolve("oracle.out") + script.writeText(source) + val process = ProcessBuilder("node", script.toString()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start() + + try { + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "JavaScript oracle timed out") + assertEquals(0, process.exitValue(), output.readText()) + return output.readText().trim().lines() + } finally { + if (process.isAlive) process.destroyForcibly() + } + } + + private data class Family(val name: String, val type: String, val values: List) + + private data class ShiftCase(val label: String, val type: String, val values: List, val drain: Boolean) { + val shiftCount: Int get() = if (drain) values.size + 1 else 1 + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt new file mode 100644 index 000000000..1cdfc0328 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -0,0 +1,318 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.io.TempDir +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayShiftReplayTest { + @TempDir + lateinit var directory: Path + + @TestFactory + fun `symbolic shift inputs results and heap changes replay in JavaScript`(): List { + val cases = replayCases() + val source = directory.resolve("ArrayShiftReplay.ts") + source.writeText(renderSource(cases)) + val scene = EtsScene(listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) + val methods = scene.projectClasses.single { it.name == "ArrayShiftReplay" }.methods.associateBy { it.name } + + return cases.mapIndexed { index, case -> + DynamicTest.dynamicTest(case.name) { + val method = methods.getValue("case$index") + + val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions()).use { machine -> + machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } + } + + assertTrue(tests.isNotEmpty()) + val results = tests.map { assertIs(it.returnValue).number }.toSet() + assertEquals((0..case.maxResult).map { it.toDouble() }.toSet(), results) + + val script = buildString { + appendLine(renderSource(cases)) + appendLine(JS_SAME_VALUE) + tests.forEachIndexed { stateIndex, test -> + val arguments = test.before.parameters.joinToString(transform = ::jsValue) + val expectedAfter = test.after.parameters.joinToString(transform = ::jsValue) + appendLine("{") + appendLine("const args = [$arguments];") + appendLine("const actual = new ArrayShiftReplay().case$index(...args);") + val expectedResult = jsValue(test.returnValue) + appendLine("if (!same(actual, $expectedResult)) throw Error('result $stateIndex');") + appendLine("if (!same(args, [$expectedAfter])) throw Error('heap $stateIndex');") + appendLine("}") + } + } + assertReplay(script, index) + } + } + } + + private fun replayCases(): List = buildList { + for (type in listOf("any", "unknown")) { + add( + ReplayCase( + name = "first $type read", + parameters = "values: $type[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const removed = values.shift(); + if (removed === 42) return 1; + if (removed === true) return 2; + if (removed === false) return 3; + if (removed === null) return 4; + if (removed === undefined) return 5; + return 6; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "repeated mixed $type shifts", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const first = values.shift(); + const second = values.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "read shifted $type tail", + parameters = "values: $type[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + values.shift(); + if (values[0] === 42) return 1; + if (values[0] === true) return 2; + return 3; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "overwrite $type then shift", + parameters = "values: $type[], index: number", + maxResult = 3, + body = """ + if (values.length !== 2 || (index !== 0 && index !== 1)) return 0; + values[index] = true; + const first = values.shift(); + if (first === 42 && values[0] === true) return 1; + if (first === true && values[0] === 17) return 2; + return 3; + """.trimIndent(), + ) + ) + } + addAll(pairCases()) + addAll(typedCases()) + } + + private fun pairCases(): List = buildList { + val literals = listOf("42", "true", "false", "null", "undefined", "'left'") + for (type in listOf("any", "unknown")) { + for (first in literals) { + for (second in literals) { + // Unconstrained string equality is not modeled yet; write string slots before shifting. + val writes = listOf(first, second).mapIndexedNotNull { index, literal -> + if (literal == "'left'") "values[$index] = $literal;" else null + }.joinToString(separator = "\n") + val label = if (writes.isEmpty()) "input pair" else "pair with written string" + val maxResult = if (first == "'left'" && second == "'left'") 1 else 2 + add( + ReplayCase( + name = "$type $label $first then $second", + parameters = "values: $type[]", + maxResult = maxResult, + body = """ + if (values.length !== 2) return 0; + $writes + const first = values.shift(); + const second = values.shift(); + return first === $first && second === $second ? 1 : 2; + """.trimIndent(), + ) + ) + } + } + } + } + + private fun typedCases(): List = buildList { + for ((type, literal) in listOf("number" to "42", "boolean" to "true")) { + add( + ReplayCase( + name = "homogeneous $type", + parameters = "values: $type[]", + maxResult = 2, + body = """ + if (values.length !== 2) return 0; + const first = values.shift(); + return first === $literal && values[0] === $literal ? 1 : 2; + """.trimIndent(), + ) + ) + } + add( + ReplayCase( + name = "written homogeneous string", + parameters = "values: string[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = 'left'; + values[1] = 'right'; + return values.shift() === 'left' && values[0] === 'right' ? 1 : 2; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "written symbolic union", + parameters = "values: (number | boolean)[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = 42; + values[1] = true; + return values.shift() === 42 && values[0] === true ? 1 : 2; + """.trimIndent(), + ) + ) + for (type in listOf("number", "boolean")) { + for (aliasType in listOf("any", "unknown")) { + add( + ReplayCase( + name = "symbolic $type array through $aliasType alias", + parameters = "values: $type[]", + maxResult = if (type == "number") 2 else 1, + body = """ + if (values.length !== 2) return 0; + const alias: $aliasType[] = values; + const first = values[0]; + const second = values[1]; + return alias.shift() === first && values[0] === second && alias.length === 1 ? 1 : 2; + """.trimIndent(), + ) + ) + } + } + add( + ReplayCase( + name = "symbolic object array preserves references", + parameters = "values: ReplayElement[]", + maxResult = 2, + body = """ + if (values.length !== 2) return 0; + const first = values[0]; + const second = values[1]; + if (first == null || second == null) return 2; + return values.shift() === first && values[0] === second ? 1 : 3; + """.trimIndent(), + ) + ) + } + + private fun renderSource(cases: List): String = buildString { + appendLine("class ReplayElement {}") + appendLine("class ArrayShiftReplay {") + cases.forEachIndexed { index, case -> + appendLine("case$index(${case.parameters}) { ${case.body} }") + } + appendLine("}") + } + + private fun jsValue(value: TsTestValue): String = when (value) { + TsTestValue.TsUndefined -> "undefined" + TsTestValue.TsNull -> "null" + is TsTestValue.TsBoolean -> value.value.toString() + is TsTestValue.TsNumber -> value.number.toString() + is TsTestValue.TsString -> jsString(value.value) + is TsTestValue.TsArray<*> -> value.values.joinToString(prefix = "[", postfix = "]", transform = ::jsValue) + is TsTestValue.TsClass -> value.properties.entries.joinToString(prefix = "({", postfix = "})") { + "${jsString(it.key)}: ${jsValue(it.value)}" + } + else -> error("Unsupported replay value: $value") + } + + private fun jsString(value: String): String = value.map { "\\u%04x".format(it.code) }.joinToString( + separator = "", + prefix = "\"", + postfix = "\"", + ) + + private fun assertReplay(source: String, index: Int) { + val script = directory.resolve("replay$index.ts") + val output = directory.resolve("replay$index.out") + script.writeText(source) + val process = ProcessBuilder("node", "--experimental-strip-types", script.toString()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start() + + try { + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Replay timed out") + assertEquals(0, process.exitValue(), "${output.readText()}\n$source") + } finally { + if (process.isAlive) process.destroyForcibly() + } + } + + private data class ReplayCase( + val name: String, + val parameters: String, + val maxResult: Int, + val body: String, + ) + + private companion object { + const val JS_SAME_VALUE = """ + function same(a, b) { + if (Object.is(a, b)) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every(k => same(a[k], b[k])); + } + """ + + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 81ae7a03b..a0bae2c19 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -109,6 +109,19 @@ class TsUnknownCallDispatcherTest { assertEquals(TsUnknownCallOutcome.MODEL_APPLIED, event.outcome) } + @Test + fun `failed fork callback does not report a completed model decision`() { + val observer = RecordingUnknownCallObserver() + val states = analyzeAllStates( + methodName = "modeledUnknownCallForks", + models = catalog(FailingSecondSuccessorModel), + observer = observer, + ) + + assertTrue(states.isEmpty()) + assertTrue(observer.events.isEmpty()) + } + @Test fun `throwing observer cannot change fresh or modeled exploration`() { val cases = listOf( @@ -633,6 +646,20 @@ class TsUnknownCallDispatcherTest { } } + private object FailingSecondSuccessorModel : TestModel(id = "failing-model", methodName = "convert") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { + val execution = ForkingModel.apply(state, call) + val (first, second) = execution.successors + val failingSecond = TsUnknownCallModelSuccessor( + guard = second.guard, + completion = second.completion, + applyStateChanges = { error("second successor failed") }, + ) + + return TsUnknownCallModelExecution(successors = listOf(first, failingSecond)) + } + } + private object SupportedTrueResidualFalseModel : TestModel(id = "partial-model", methodName = "convert") { override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val result = requireNotNull(call.arguments.single().resolved) diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt index 9fd2d523f..2f950432f 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -45,6 +45,7 @@ import org.usvm.machine.expr.extractInt import org.usvm.machine.expr.toConcreteBoolValue import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState +import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.memory.ULValue import org.usvm.memory.UReadOnlyMemory import org.usvm.mkSizeExpr @@ -241,7 +242,7 @@ open class TsTestStateResolver( } is EtsArrayType -> { - resolveTsArray(concreteRef, heapRef, type) + resolveTsArray(heapRef, type) } is EtsUnknownType -> { @@ -261,7 +262,6 @@ open class TsTestStateResolver( } private fun resolveTsArray( - concreteRef: UConcreteHeapRef, heapRef: UHeapRef, type: EtsArrayType, ): TsTestValue.TsArray<*> = with(ctx) { @@ -273,40 +273,21 @@ open class TsTestStateResolver( val sort = typeToSort(type.elementType) if (sort is TsUnresolvedSort) { - val resolvedArrayIndexLValue = mkArrayIndexLValue(addressSort, concreteRef, index, type) - val fakeObject = if (memory is UModel) { - resolvedLValuesToFakeObjects.firstOrNull { it.first == resolvedArrayIndexLValue }?.second - } else { - val currentArrayIndexLValue = mkArrayIndexLValue(addressSort, heapRef, index, type) - val currentValue = evaluateInModel(memory.read(currentArrayIndexLValue)) - (currentValue as? UConcreteHeapRef)?.takeIf { it.isFakeObject() } + val value = readUnresolvedArrayElement(memory, heapRef, index) + val currentRef = evaluateInModel(value.refValue) + if (currentRef.isFakeObject()) { + return@map resolveFakeObject(currentRef) } - fakeObject ?: return@map TsTestValue.TsUndefined - - check(fakeObject.isFakeObject()) - - val fakeType = fakeObject.getFakeType(finalStateMemory) return@map when { - model.eval(fakeType.fpTypeExpr).isTrue -> { - resolveExpr(fakeObject.extractFp(finalStateMemory)) - } - - model.eval(fakeType.boolTypeExpr).isTrue -> { - resolveExpr(fakeObject.extractBool(finalStateMemory)) - } - - model.eval(fakeType.refTypeExpr).isTrue -> { - resolveExpr(fakeObject.extractRef(finalStateMemory)) - } - - else -> { - error("Unsupported fake object type: $fakeType") - } + model.eval(value.type.fpTypeExpr).isTrue -> resolveExpr(value.fpValue) + model.eval(value.type.boolTypeExpr).isTrue -> resolveExpr(value.boolValue) + model.eval(value.type.refTypeExpr).isTrue -> resolveExpr(value.refValue) + else -> TsTestValue.TsUndefined // An unread input element is unconstrained. } } - require(sort is UFpSort || sort is UBoolSort) { + require(sort is UFpSort || sort is UBoolSort || sort is UAddressSort) { "Other sorts must be resolved above, but got: $sort" } diff --git a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts index c22d82dcd..146989eed 100644 --- a/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts +++ b/usvm-ts/src/test/resources/models/ArrayShiftIntrinsic.ts @@ -102,4 +102,52 @@ export class ArrayShiftIntrinsic { values.shift(); return values; } + + numberArrayThroughAnyAlias(): number { + const original: number[] = [10, 20]; + const values: any[] = original; + return values.shift() === 10 && original[0] === 20 && values.length === 1 ? 1 : 0; + } + + booleanArrayThroughUnknownAlias(): number { + const original: boolean[] = [true, false]; + const values: unknown[] = original; + return values.shift() === true && original[0] === false && values.length === 1 ? 1 : 0; + } + + conditionalFakeElement(index: number): number { + if (index !== 0 && index !== 1) return 0; + const values: any[] = [10, true]; + values[index] = 20; + const removed = values.shift(); + if (index === 0) return removed === 20 && values[0] === true ? 1 : -1; + return removed === 10 && values[0] === 20 ? 1 : -1; + } + + conditionalArray(flag: boolean): number { + const first: any[] = [10, true]; + const second: any[] = [false, 20]; + const values: any[] = flag ? first : second; + const removed = values.shift(); + if (flag) return removed === 10 && values[0] === true && first.length === 1 && second.length === 2 ? 1 : 0; + return removed === false && values[0] === 20 && second.length === 1 && first.length === 2 ? 1 : 0; + } + + conditionalEmptyArray(flag: boolean): number { + const first: any[] = [10, true]; + const second: any[] = []; + const values: any[] = flag ? first : second; + const removed = values.shift(); + if (flag) return removed === 10 && values[0] === true && first.length === 1 && second.length === 0 ? 1 : 0; + return removed === undefined && first.length === 2 && second.length === 0 ? 1 : 0; + } + + pushAfterShift(): number { + const element = new ArrayElement(); + const values: any[] = [10, true, element]; + const first = values.shift(); + values.push(null); + return first === 10 && values.shift() === true && values.shift() === element && + values.shift() === null && values.shift() === undefined && values.length === 0 ? 1 : 0; + } } From 35dce9593b735e731a207a688f727518f37c4142 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 14:02:21 +0300 Subject: [PATCH 11/18] [TS Calls] Normalize receivers before instance-call dispatch --- usvm-ts/UNKNOWN_CALL_MODELS.md | 22 ++- .../org/usvm/machine/call/TsUnknownCall.kt | 2 + .../intrinsic/TsArrayShiftIntrinsicModel.kt | 2 +- .../main/kotlin/org/usvm/machine/expr/Call.kt | 109 +++++------ .../usvm/machine/expr/CallApproximations.kt | 103 ++++++----- .../usvm/machine/interpreter/TsInterpreter.kt | 77 ++++---- .../call/TsArrayShiftIntrinsicModelTest.kt | 26 --- .../call/TsInstanceCallReceiverTest.kt | 168 +++++++++++++++++ .../resources/models/InstanceCallReceiver.ts | 169 ++++++++++++++++++ 9 files changed, 502 insertions(+), 176 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt create mode 100644 usvm-ts/src/test/resources/models/InstanceCallReceiver.ts diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 79f5ad268..ea5de4779 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -62,9 +62,11 @@ The built-in catalog currently contains one model: | --- | --- | --- | | `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | -An `any`/unknown receiver, a fake-value wrapper, and a non-array receiver do not become applicable merely because the -method is named `shift`; they use fallback. A definitely-array receiver with an unresolved element sort remains -applicable and uses the fake-value representation described below. +The common instance-call pipeline splits fake-value wrappers and conditional references under their runtime-kind +and branch guards before selecting an approximation or resolving a method. A wrapped array can therefore use the +model, including through an `any` alias. An unknown or non-array receiver does not become an array merely because +the method is named `shift`. A definitely-array receiver with an unresolved element sort remains applicable and uses +the fake-value representation described below. ### `unknownCallFallback` @@ -138,8 +140,9 @@ The target identifies a call family. State-dependent checks, such as the receive The built-in array target intentionally combines the method name with `PARTIAL_APPROXIMATION` instead of a class name. That failure reason is emitted only after the regular approximation path has classified the receiver as an -`EtsArrayType`. Calls on `any`/unknown receivers reach another failure reason and cannot match this target. The model -still validates the resolved receiver and array shape before changing memory. +`EtsArrayType` using the normalized receiver's storage type. An `any` alias of a known array can satisfy that check; +a receiver without array-type evidence cannot. The model still validates the resolved receiver and array shape +before changing memory. ## Applicability and residual states @@ -212,7 +215,14 @@ The existing `Array.shift` intrinsic remains the example for engine-only symboli A method name does not prove the receiver type. In particular, `value.shift()` may call a user-defined property rather than `Array.prototype.shift`. -Use this decision rule: +Instance calls share receiver normalization before built-in approximations and ordinary method lookup. It reuses +`extractValue` to select a fake payload together with its kind constraint and `splitUHeapRef` to retain conditional +reference guards. Each feasible alternative continues through the existing virtual-call statement. This preserves +supported primitive calls such as `valueOf` and the existing `toString` approximation, while null and undefined +receivers take the property-access exception path. Other primitive calls use `NON_REFERENCE_RECEIVER` fallback. +Receiver normalization does not make the existing built-in approximations exact. + +Use this decision rule after normalization: | Receiver knowledge | Action | | --- | --- | diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index 48bffe05a..a46c7d9d6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -19,6 +19,8 @@ import org.usvm.machine.state.newStmt /** * A call that the regular TypeScript execution pipeline could not execute. * + * Instance receivers are normalized under their runtime-kind and conditional-reference guards before method + * lookup and receiver-dependent approximations. Null and undefined receivers fail at property access. * Frontend call resolution and the existing built-in approximations run before this boundary. A call reaches the * dispatcher only after one of those stages cannot continue normally. Successful compatibility approximations such * as `toString`, `valueOf`, `Math.floor`, and `$r` therefore remain outside this boundary until they are classified diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index c7fcce998..dfe304ea7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -70,7 +70,7 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { val receiver = call.receiver ?: return@with null val receiverValue = receiver.resolved ?: return@with null - if (receiverValue.sort != addressSort || receiverValue.containsFakeObject()) { + if (receiverValue.sort != addressSort) { return@with null } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt index 5aaa33b7b..81fd2b27b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/Call.kt @@ -1,22 +1,24 @@ package org.usvm.machine.expr import io.ksmt.utils.asExpr -import mu.KotlinLogging import org.jacodb.ets.model.EtsInstanceCallExpr +import org.usvm.UBoolExpr import org.usvm.UExpr +import org.usvm.UIteExpr +import org.usvm.isFalse +import org.usvm.isTrue import org.usvm.machine.TsContext import org.usvm.machine.TsVirtualMethodCallStmt -import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.machine.call.dispatch import org.usvm.machine.expr.TsExprApproximationResult.NoApproximation import org.usvm.machine.expr.TsExprApproximationResult.ResolveFailure import org.usvm.machine.expr.TsExprApproximationResult.SuccessfulApproximation import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt - -private val logger = KotlinLogging.logger {} +import org.usvm.machine.types.extractValue +import org.usvm.memory.splitUHeapRef internal fun TsExprResolver.handleInstanceCall( expr: EtsInstanceCallExpr, @@ -36,47 +38,13 @@ internal fun TsExprResolver.handleInstanceCall( } // Try to approximate the call. - when (val result = tryApproximateInstanceCall(expr)) { + when (val result = tryApproximateGlobalInstanceCall(expr)) { is SuccessfulApproximation -> return result.expr is ResolveFailure -> return null is NoApproximation -> {} } - // Resolve the instance. - val instance = run { - val resolved = resolve(expr.instance) ?: return null - if (resolved.isFakeObject()) { - val fakeType = resolved.getFakeType(scope) - scope.assert(fakeType.refTypeExpr) ?: run { - logger.warn { "Calls on non-ref (fake) instance is not supported: $expr" } - unknownCallDispatcher.dispatch( - scope = scope, - call = expr, - callSite = scope.calcOnState { lastStmt }, - failureReason = TsUnknownCallFailureReason.NON_REFERENCE_RECEIVER, - resolvedReceiver = resolved, - ) - return null - } - resolved.extractRef(scope) - } else { - if (resolved.sort != addressSort) { - logger.warn { "Calling method on non-ref instance is not yet supported: $expr" } - unknownCallDispatcher.dispatch( - scope = scope, - call = expr, - callSite = scope.calcOnState { lastStmt }, - failureReason = TsUnknownCallFailureReason.NON_REFERENCE_RECEIVER, - resolvedReceiver = resolved, - ) - return null - } - resolved.asExpr(addressSort) - } - } - - // Check for undefined or null property access. - checkUndefinedOrNullPropertyRead(scope, instance, expr.callee.name) ?: return null + val instance = resolve(expr.instance) ?: return null // Resolve arguments. val args = expr.args.map { resolve(it) ?: return null } @@ -91,15 +59,56 @@ fun TsContext.callInstanceMethod( instance: UExpr<*>, args: List>, ): UExpr<*>? { - // Create the virtual call statement. - val virtualCall = TsVirtualMethodCallStmt( - call = call, - instance = instance, - args = args, - returnSite = scope.calcOnState { lastStmt }, - ) - scope.doWithState { newStmt(virtualCall) } + val returnSite = scope.calcOnState { lastStmt } + val alternatives = scope.calcOnState { receiverAlternatives(instance) } + val successors = alternatives.map { (guard, receiver) -> + val callStmt = TsVirtualMethodCallStmt( + call = call, + instance = receiver, + args = args, + returnSite = returnSite, + ) + val advance: TsState.() -> Unit = { newStmt(callStmt) } + guard to advance + } + + if (successors.size == 1 && successors.single().first.isTrue) { + scope.doWithState(successors.single().second) + } else { + scope.forkMulti(successors) + } - // Return null to indicate that we are waiting for the call to be executed. return null } + +/** Keeps the type constraints attached to every receiver passed to the common instance-call pipeline. */ +private fun TsState.receiverAlternatives( + value: UExpr<*>, + guard: UBoolExpr = ctx.trueExpr, +): List>> = with(ctx) { + if (guard.isFalse) return emptyList() + + when { + value.isFakeObject() -> listOf( + extractValue(value, boolSort, ::getIntermediateBoolLValue), + extractValue(value, fp64Sort, ::getIntermediateFpLValue), + extractValue(value, addressSort, ::getIntermediateRefLValue), + ).flatMap { (payload, typeGuard) -> + receiverAlternatives(requireNotNull(payload), mkAnd(guard, typeGuard)) + } + + value.sort == addressSort && value is UIteExpr<*> -> { + val refs = splitUHeapRef( + ref = value.asExpr(addressSort), + initialGuard = guard, + ignoreNullRefs = false, + collapseHeapRefs = false, + ) + (refs.concreteHeapRefs + refs.symbolicHeapRef).flatMap { (ref, refGuard) -> + receiverAlternatives(ref, refGuard) + } + } + + else -> listOf(guard to value) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 5e6599282..47509a11e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -7,10 +7,12 @@ import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr import org.usvm.UExpr +import org.usvm.UHeapRef import org.usvm.USort import org.usvm.api.allocateConcreteRef import org.usvm.api.initializeArray @@ -26,7 +28,6 @@ import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue -import org.usvm.machine.state.lastStmt import org.usvm.sizeSort import org.usvm.types.first import org.usvm.util.arrayStorageType @@ -36,7 +37,7 @@ import org.usvm.util.resolveEtsMethods private val logger = KotlinLogging.logger {} -internal fun TsExprResolver.tryApproximateInstanceCall( +internal fun TsExprResolver.tryApproximateGlobalInstanceCall( expr: EtsInstanceCallExpr, ): TsExprApproximationResult = with(ctx) { // Mock all calls to `Logger` methods @@ -44,19 +45,6 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return from(mkUndefinedValue()) } - // Mock `.toString()` method calls - if (expr.callee.name == "toString") { - if (expr.args.isNotEmpty()) { - logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } - } - return from(mkStringConstant("I am a string", scope)) - } - - // Handle `.valueOf()` method calls - if (expr.callee.name == "valueOf") { - return from(handleValueOf(expr)) - } - // Handle `Number.isNaN()` calls if (expr.instance.name == "Number") { if (expr.callee.name == "isNaN") { @@ -88,17 +76,32 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } } - val instance = resolve(expr.instance) - ?: return TsExprApproximationResult.ResolveFailure + return TsExprApproximationResult.NoApproximation +} - val instanceType = if (instance.sort == addressSort && isAllocatedConcreteHeapRef(instance)) { - scope.calcOnState { - arrayStorageType(instance.asExpr(addressSort), expr.instance.type) +internal fun TsExprResolver.tryApproximateInstanceCall( + expr: EtsInstanceCallExpr, + instance: UExpr<*>, + returnSite: EtsStmt, +): TsExprApproximationResult = with(ctx) { + // Mock `.toString()` method calls + if (expr.callee.name == "toString") { + if (expr.args.isNotEmpty()) { + logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } } - } else { - expr.instance.type + return from(mkStringConstant("I am a string", scope)) } + // Handle `.valueOf()` method calls + if (expr.callee.name == "valueOf") { + return from(handleValueOf(expr, instance)) + } + + if (instance.sort != addressSort) return TsExprApproximationResult.NoApproximation + + val array = instance.asExpr(addressSort) + val instanceType = scope.calcOnState { arrayStorageType(array, expr.instance.type) } + if (instanceType is EtsArrayType) { val elementSort = typeToSort(instanceType.elementType) .takeIf { it !is TsUnresolvedSort } @@ -106,57 +109,57 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle 'Array.push()' method calls if (expr.callee.name == "push") { - return from(handleArrayPush(expr, instanceType, elementSort)) + return from(handleArrayPush(expr, instanceType, elementSort, array)) } // Handle `Array.pop() method calls if (expr.callee.name == "pop") { - return from(handleArrayPop(expr, instanceType, elementSort)) + return from(handleArrayPop(expr, instanceType, elementSort, array)) } // Handle `Array.fill() method calls if (expr.callee.name == "fill") { - return from(handleArrayFill(expr, instanceType, elementSort)) + return from(handleArrayFill(expr, instanceType, elementSort, array)) } // Handle `Array.unshift() method calls if (expr.callee.name == "unshift") { - return from(handleArrayUnshift(expr, instanceType, elementSort)) + return from(handleArrayUnshift(expr, instanceType, elementSort, array)) } // Handle `Array.shift() method calls if (expr.callee.name == "shift") { - return handleArrayShiftCall(expr, instanceType, elementSort, instance) + return handleArrayShiftCall(expr, instanceType, elementSort, instance, returnSite) } // Handle `Array.join() method calls if (expr.callee.name == "join") { - return from(handleArrayJoin(expr, instanceType, elementSort)) + return from(handleArrayJoin(expr)) } // Handle `Array.slice() method calls if (expr.callee.name == "slice") { - return from(handleArraySlice(expr, instanceType, elementSort)) + return from(handleArraySlice(expr, instanceType, elementSort, array)) } // Handle `Array.concat() method calls if (expr.callee.name == "concat") { - return from(handleArrayConcat(expr, instanceType, elementSort)) + return from(handleArrayConcat(expr, instanceType, elementSort, array)) } // Handle `Array.indexOf() method calls if (expr.callee.name == "indexOf") { - return from(handleArrayIndexOf(expr, instanceType, elementSort)) + return from(handleArrayIndexOf(expr, instanceType, elementSort, array)) } // Handle `Array.includes() method calls if (expr.callee.name == "includes") { - return from(handleArrayIncludes(expr, instanceType, elementSort)) + return from(handleArrayIncludes(expr)) } // Handle `Array.reverse() method calls if (expr.callee.name == "reverse") { - return from(handleArrayReverse(expr, instanceType, elementSort)) + return from(handleArrayReverse(expr, instanceType, elementSort, array)) } } @@ -168,16 +171,17 @@ private fun TsExprResolver.handleArrayShiftCall( instanceType: EtsArrayType, elementSort: USort, resolvedReceiver: UExpr<*>, + returnSite: EtsStmt, ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { - return from(handleArrayShift(expr, instanceType, elementSort)) + return from(handleArrayShift(expr, instanceType, elementSort, resolvedReceiver.asExpr(ctx.addressSort))) } dispatcher.dispatch( scope, expr, - scope.calcOnState { lastStmt }, + returnSite, failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, resolvedReceiver = resolvedReceiver, ) @@ -185,13 +189,12 @@ private fun TsExprResolver.handleArrayShiftCall( return TsExprApproximationResult.ResolveFailure } -private fun TsExprResolver.handleValueOf(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { +private fun TsExprResolver.handleValueOf(expr: EtsInstanceCallExpr, instance: UExpr<*>): UExpr<*> { if (expr.args.isNotEmpty()) { logger.warn { "valueOf() should have no arguments, but got ${expr.args.size}" } } - val instance = resolve(expr.instance) ?: return null - instance + return instance } private fun TsExprResolver.handleNumberIsNaN(expr: EtsInstanceCallExpr): UBoolExpr? = with(ctx) { @@ -332,8 +335,8 @@ private fun TsExprResolver.handleArrayPush( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size == 1) { "Array.push() should have exactly one argument, but got ${expr.args.size}" } @@ -393,8 +396,8 @@ private fun TsExprResolver.handleArrayPop( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.isEmpty()) { "Array.pop() should have no arguments, but got ${expr.args.size}" } @@ -459,8 +462,8 @@ private fun TsExprResolver.handleArrayFill( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size >= 1 && expr.args.size <= 3) { "Array.fill() should have 1 to 3 arguments, but got ${expr.args.size}" } @@ -574,8 +577,8 @@ private fun TsExprResolver.handleArrayShift( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.isEmpty()) { "Array.shift() should have no arguments, but got ${expr.args.size}" } @@ -640,8 +643,8 @@ private fun TsExprResolver.handleArrayUnshift( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null // TODO: support vararg check(expr.args.size == 1) { "Array.unshift() should have exactly one argument, but got ${expr.args.size}" @@ -709,10 +712,7 @@ private fun TsExprResolver.handleArrayUnshift( */ private fun TsExprResolver.handleArrayJoin( expr: EtsInstanceCallExpr, - arrayType: EtsArrayType, - elementSort: USort, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size <= 1) { "Array.join() should have at most one argument, but got ${expr.args.size}" } @@ -754,8 +754,8 @@ private fun TsExprResolver.handleArraySlice( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size <= 2) { "Array.slice() should have at most two arguments, but got ${expr.args.size}" } @@ -851,8 +851,8 @@ private fun TsExprResolver.handleArrayConcat( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.isNotEmpty()) { "Array.concat() should have at least one argument, but got ${expr.args.size}" } @@ -952,8 +952,8 @@ private fun TsExprResolver.handleArrayIndexOf( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size == 1) { "Array.indexOf() should have exactly one argument, but got ${expr.args.size}" } @@ -1011,10 +1011,7 @@ private fun TsExprResolver.handleArrayIndexOf( */ private fun TsExprResolver.handleArrayIncludes( expr: EtsInstanceCallExpr, - arrayType: EtsArrayType, - elementSort: USort, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.size == 1) { "Array.includes() should have exactly one argument, but got ${expr.args.size}" } @@ -1062,8 +1059,8 @@ private fun TsExprResolver.handleArrayReverse( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, elementSort: USort, + array: UHeapRef, ): UExpr<*>? = with(ctx) { - val array = resolve(expr.instance)?.asExpr(addressSort) ?: return null check(expr.args.isEmpty()) { "Array.reverse() should have no arguments, but got ${expr.args.size}" } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index cc8e915f6..71c7dff01 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -35,7 +35,6 @@ import org.usvm.StepResult import org.usvm.StepScope import org.usvm.UExpr import org.usvm.UInterpreter -import org.usvm.UIteExpr import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArray import org.usvm.api.targets.TsTarget @@ -52,14 +51,17 @@ import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch +import org.usvm.machine.expr.TsExprApproximationResult import org.usvm.machine.expr.TsExprResolver import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.expr.checkUndefinedOrNullPropertyRead import org.usvm.machine.expr.handleAssignToArrayIndex import org.usvm.machine.expr.handleAssignToInstanceField import org.usvm.machine.expr.handleAssignToLocal import org.usvm.machine.expr.handleAssignToStaticField import org.usvm.machine.expr.mkTruthyExpr import org.usvm.machine.expr.readGlobal +import org.usvm.machine.expr.tryApproximateInstanceCall import org.usvm.machine.expr.writeGlobal import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState @@ -165,25 +167,39 @@ class TsInterpreter( val instance = stmt.instance val callee = stmt.call.callee - val unwrappedInstance = if (instance.isFakeObject()) { - // TODO support primitives calls - // We ignore the possibility of method call on primitives. - // Therefore, the fake object should be unwrapped. - scope.assert(instance.getFakeType(scope).refTypeExpr) - instance.extractRef(scope) - } else { - instance.asExpr(addressSort) + if (instance.sort == addressSort) { + checkUndefinedOrNullPropertyRead(scope, instance.asExpr(addressSort), callee.name) ?: return + } + + val resolver = exprResolverWithScope(scope) + when (val result = resolver.tryApproximateInstanceCall(stmt.call, instance, stmt.returnSite)) { + is TsExprApproximationResult.SuccessfulApproximation -> { + scope.doWithState { + methodResult = TsMethodResult.Success.MockedCall(result.expr, callee) + newStmt(stmt.returnSite) + } + return + } + + TsExprApproximationResult.ResolveFailure -> return + TsExprApproximationResult.NoApproximation -> {} } + if (instance.sort != addressSort) { + unknownCallDispatcher.dispatch(scope, stmt, Reason.NON_REFERENCE_RECEIVER, instance) + return + } + val receiver = instance.asExpr(addressSort) + val concreteMethods: MutableList = mutableListOf() - if (isAllocatedConcreteHeapRef(unwrappedInstance)) { - val type = scope.calcOnState { memory.typeStreamOf(unwrappedInstance) }.single() + if (isAllocatedConcreteHeapRef(receiver)) { + val type = scope.calcOnState { memory.typeStreamOf(receiver) }.single() if (type is EtsClassType) { val classes = graph.hierarchy.classesForType(type) if (classes.isEmpty()) { logger.warn { "Could not resolve class: ${type.typeName}" } - unknownCallDispatcher.dispatch(scope, stmt, Reason.RECEIVER_CLASS_NOT_FOUND, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.RECEIVER_CLASS_NOT_FOUND, receiver) return } if (classes.size > 1) { @@ -203,7 +219,7 @@ class TsInterpreter( logger.warn { "Could not resolve method: $callee on type: $type" } - unknownCallDispatcher.dispatch(scope, stmt, Reason.UNSUPPORTED_RECEIVER_TYPE, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.UNSUPPORTED_RECEIVER_TYPE, receiver) return } } else { @@ -212,25 +228,25 @@ class TsInterpreter( if (callee.name !in listOf("then")) { logger.warn { "Could not resolve method: $callee" } } - unknownCallDispatcher.dispatch(scope, stmt, Reason.VIRTUAL_METHOD_NOT_FOUND, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.VIRTUAL_METHOD_NOT_FOUND, receiver) return } concreteMethods += methods } val possibleTypes = scope.calcOnState { - memory.typeStreamOf(unwrappedInstance).take(scene.projectAndSdkClasses.size) + memory.typeStreamOf(receiver).take(scene.projectAndSdkClasses.size) } if (possibleTypes !is TypesResult.SuccessfulTypesResult) { - unknownCallDispatcher.dispatch(scope, stmt, Reason.RECEIVER_TYPE_STREAM_UNAVAILABLE, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.RECEIVER_TYPE_STREAM_UNAVAILABLE, receiver) return } val possibleTypesSet = possibleTypes.types.toSet() if (possibleTypesSet.singleOrNull() == EtsAnyType) { - unknownCallDispatcher.dispatch(scope, stmt, Reason.ANY_RECEIVER, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.ANY_RECEIVER, receiver) return } @@ -270,30 +286,11 @@ class TsInterpreter( val type = requireNotNull(method.enclosingClass).type val constraint = scope.calcOnState { - val ref = stmt.instance.asExpr(addressSort) - .takeIf { !it.isFakeObject() } - ?: unwrappedInstance.asExpr(addressSort) - - // TODO: adhoc: "expand" ITE - if (ref is UIteExpr<*>) { - val trueBranch = ref.trueBranch - val falseBranch = ref.falseBranch - if (trueBranch.isFakeObject() || falseBranch.isFakeObject()) { - val unwrappedTrueExpr = trueBranch.asExpr(addressSort).unwrapRefWithPathConstraint(scope) - val unwrappedFalseExpr = falseBranch.asExpr(addressSort).unwrapRefWithPathConstraint(scope) - return@calcOnState mkIte( - condition = ref.condition, - trueBranch = memory.types.evalIsSubtype(unwrappedTrueExpr, type), - falseBranch = memory.types.evalIsSubtype(unwrappedFalseExpr, type), - ) - } - } - // TODO mistake, should be separated into several hierarchies // or evalTypeEqual with several concrete types mkAnd( - memory.types.evalIsSubtype(ref, clazz), - memory.types.evalIsSupertype(ref, type) + memory.types.evalIsSubtype(receiver, clazz), + memory.types.evalIsSupertype(receiver, type) ) } constraint to block @@ -301,9 +298,9 @@ class TsInterpreter( if (conditionsWithBlocks.isEmpty()) { logger.warn { - "No suitable methods found for call: $callee with instance: $unwrappedInstance" + "No suitable methods found for call: $callee with instance: $receiver" } - unknownCallDispatcher.dispatch(scope, stmt, Reason.NO_SUITABLE_VIRTUAL_TARGET, unwrappedInstance) + unknownCallDispatcher.dispatch(scope, stmt, Reason.NO_SUITABLE_VIRTUAL_TARGET, receiver) return } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index 099c18a28..eaa60d4a3 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -254,32 +254,6 @@ class TsArrayShiftIntrinsicModelTest { assertUsesResidualFallback(methodName = "shiftWithArguments") } - @Test - fun `fake wrapper receiver is not accepted as an array`() { - val state = analyzeStates(methodName = "unknownValue").single() - val fakeReceiver = makeFakeReceiver(state) - - val execution = TsArrayShiftIntrinsicModel.apply(state, arrayShiftCall(fakeReceiver)) - - assertNull(execution) - } - - @Test - fun `conditional receiver containing fake wrapper is not accepted as an array`() { - val state = analyzeStates(methodName = "unknownValue").single() - val fakeReceiver = makeFakeReceiver(state) - val fakeType = with(state.ctx) { fakeReceiver.getFakeType(state.memory) } - val conditionalReceiver = state.ctx.mkIte( - condition = fakeType.boolTypeExpr, - trueBranch = fakeReceiver, - falseBranch = state.makeSymbolicRefUntyped(), - ) - - val execution = TsArrayShiftIntrinsicModel.apply(state, arrayShiftCall(conditionalReceiver)) - - assertNull(execution) - } - @Test fun `empty enabled set sends shift to configured fallback`() { val disabledResult = analyze( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt new file mode 100644 index 000000000..d7a167e47 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt @@ -0,0 +1,168 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.io.TempDir +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsInstanceCallReceiverTest { + @TempDir + lateinit var directory: Path + + @TestFactory + fun `normalized receivers preserve supported calls and exceptional branches`(): List { + val source = getResourcePath("/models/InstanceCallReceiver.ts") + val scene = EtsScene(listOf(loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND))) + val methods = scene.projectClasses.single { it.name == "InstanceCallReceiver" }.methods.associateBy { it.name } + + return cases.map { case -> + DynamicTest.dynamicTest(case.method) { + val method = methods.getValue(case.method) + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions(), observer = observer) + .use { machine -> + machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } + } + + val results = tests.mapNotNull { (it.returnValue as? TsTestValue.TsNumber)?.number }.toSet() + assertEquals(case.results, results) + assertEquals(case.throws, tests.any { it.returnValue is TsTestValue.TsException }) + assertTrue(tests.isNotEmpty()) + if (method.parameters.singleOrNull()?.name == "index") { + val indices = tests.map { assertIs(it.before.parameters.single()).number } + assertTrue(indices.containsAll(listOf(0.0, 1.0)), "Both receiver alternatives must be explored") + } + + if (case.method == "wrappedShift") { + assertEquals(TsUnknownCallDecision.ModelApplied("ts.array.shift"), events.single().decision) + } + if (case.method == "customShift") assertTrue(events.isEmpty()) + + val replay = buildString { + appendLine(source.readText()) + tests.forEachIndexed { index, test -> + val args = test.before.parameters.joinToString(transform = ::jsValue) + val expected = if (test.returnValue is TsTestValue.TsException) { + "'throws'" + } else { + assertIs(test.returnValue).number.toString() + } + appendLine("{") + appendLine("let actual;") + appendLine("try { actual = new InstanceCallReceiver().${case.method}($args); }") + appendLine("catch { actual = 'throws'; }") + appendLine("if (actual !== $expected) throw Error('case ${case.method}, state $index');") + appendLine("}") + } + } + assertReplay(replay, case.method) + } + } + } + + private fun jsValue(value: TsTestValue): String = when (value) { + TsTestValue.TsUndefined -> "undefined" + TsTestValue.TsNull -> "null" + is TsTestValue.TsBoolean -> value.value.toString() + is TsTestValue.TsNumber -> value.number.toString() + is TsTestValue.TsString -> jsString(value.value) + is TsTestValue.TsClass -> value.properties.entries.joinToString(prefix = "({", postfix = "})") { + "${jsString(it.key)}: ${jsValue(it.value)}" + } + else -> error("Unsupported receiver input: $value") + } + + private fun jsString(value: String): String = value.map { "\\u%04x".format(it.code) }.joinToString( + separator = "", + prefix = "\"", + postfix = "\"", + ) + + private fun assertReplay(source: String, name: String) { + val script = directory.resolve("$name.ts") + val output = directory.resolve("$name.out") + script.writeText(source) + val process = ProcessBuilder("node", "--experimental-strip-types", script.toString()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start() + + try { + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Receiver replay timed out") + assertEquals(0, process.exitValue(), "${output.readText()}\n$source") + } finally { + if (process.isAlive) process.destroyForcibly() + } + } + + private data class Case( + val method: String, + val results: Set, + val throws: Boolean = false, + ) + + private companion object { + val cases = listOf( + Case(method = "wrappedShift", results = setOf(1.0)), + Case(method = "wrappedPop", results = setOf(1.0)), + Case(method = "wrappedPush", results = setOf(1.0)), + Case(method = "wrappedReverse", results = setOf(1.0)), + Case(method = "wrappedFill", results = setOf(1.0)), + Case(method = "wrappedUnshift", results = setOf(1.0)), + Case(method = "wrappedSlice", results = setOf(1.0)), + Case(method = "wrappedConcat", results = setOf(1.0)), + Case(method = "wrappedUserMethod", results = setOf(1.0)), + Case(method = "customShift", results = setOf(1.0)), + Case(method = "conditionalArrays", results = setOf(0.0, 1.0)), + Case(method = "conditionalEmptyArray", results = setOf(0.0, 1.0)), + Case(method = "arrayOrUserMethod", results = setOf(0.0, 1.0)), + Case(method = "primitiveValueOf", results = setOf(0.0, 1.0)), + Case(method = "primitiveToString", results = setOf(0.0, 1.0)), + Case(method = "constrainedFake", results = setOf(0.0, 1.0, 2.0)), + Case(method = "nullableReceiver", results = setOf(0.0, 1.0), throws = true), + Case(method = "undefinedReceiver", results = setOf(0.0, 1.0), throws = true), + Case(method = "nullToString", results = emptySet(), throws = true), + Case(method = "undefinedValueOf", results = emptySet(), throws = true), + Case(method = "nullShift", results = emptySet(), throws = true), + Case(method = "undefinedShift", results = emptySet(), throws = true), + ) + + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts b/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts new file mode 100644 index 000000000..4eefab2f4 --- /dev/null +++ b/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts @@ -0,0 +1,169 @@ +// @ts-nocheck +class ReceiverObject { + value: number = 42; + read(): number { return this.value; } + shift(): number { return 99; } +} + +export class InstanceCallReceiver { + wrappedShift(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const receiver = box[0]; + return receiver.shift() === 10 && values[0] === 20 && values.length === 1 ? 1 : -1; + } + + wrappedPop(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const receiver = box[0]; + return receiver.pop() === 20 && values[0] === 10 && values.length === 1 ? 1 : -1; + } + + wrappedPush(): number { + const values = [10]; + const box: any[] = [values, true]; + const receiver = box[0]; + return receiver.push(20) === 2 && values[1] === 20 ? 1 : -1; + } + + wrappedReverse(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const receiver = box[0]; + receiver.reverse(); + return values[0] === 20 && values[1] === 10 ? 1 : -1; + } + + wrappedFill(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + box[0].fill(7); + return values[0] === 7 && values[1] === 7 ? 1 : -1; + } + + wrappedUnshift(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + return box[0].unshift(7) === 3 && values[0] === 7 && values[1] === 10 ? 1 : -1; + } + + wrappedSlice(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(1); + return result.length === 1 && result[0] === 20 && values.length === 2 ? 1 : -1; + } + + wrappedConcat(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].concat([30]); + return result.length === 3 && result[2] === 30 && values.length === 2 ? 1 : -1; + } + + wrappedUserMethod(): number { + const object = new ReceiverObject(); + const box: any[] = [object, true]; + return box[0].read() === 42 ? 1 : -1; + } + + customShift(): number { + const object = new ReceiverObject(); + const box: any[] = [object, true]; + return box[0].shift() === 99 ? 1 : -1; + } + + conditionalArrays(index: number): number { + if (index !== 0 && index !== 1) return 0; + const numbers = [10, 20]; + const booleans = [true, false]; + const box: any[] = [numbers, true]; + box[index] = booleans; + const receiver = box[0]; + const result = receiver.shift(); + if (index === 0) return result === true && booleans[0] === false && numbers.length === 2 ? 1 : -1; + return result === 10 && numbers[0] === 20 && booleans.length === 2 ? 1 : -1; + } + + conditionalEmptyArray(index: number): number { + if (index !== 0 && index !== 1) return 0; + const values: any[] = [10, true]; + const empty: any[] = []; + const box: any[] = [values, false]; + box[index] = empty; + const result = box[0].shift(); + if (index === 0) return result === undefined && values.length === 2 ? 1 : -1; + return result === 10 && values[0] === true && empty.length === 0 ? 1 : -1; + } + + arrayOrUserMethod(index: number): number { + if (index !== 0 && index !== 1) return 0; + const values = [10, 20]; + const object = new ReceiverObject(); + const box: any[] = [values, true]; + box[index] = object; + const result = box[0].shift(); + if (index === 0) return result === 99 && values.length === 2 ? 1 : -1; + return result === 10 && values.length === 1 ? 1 : -1; + } + + primitiveValueOf(index: number): number { + if (index !== 0 && index !== 1) return 0; + const box: any[] = [17, true]; + const receiver = box[index]; + return receiver.valueOf() === receiver ? 1 : -1; + } + + primitiveToString(index: number): number { + if (index !== 0 && index !== 1) return 0; + const box: any[] = [17, true]; + return typeof box[index].toString() === 'string' ? 1 : -1; + } + + constrainedFake(value: any): number { + if (value !== 17 && value !== true) return 0; + const result = value.valueOf(); + if (result === 17) return 1; + if (result === true) return 2; + return -1; + } + + nullableReceiver(index: number): number { + if (index !== 0 && index !== 1) return 0; + const object = new ReceiverObject(); + const box: any[] = [object, null]; + return box[index].read() === 42 ? 1 : -1; + } + + undefinedReceiver(index: number): number { + if (index !== 0 && index !== 1) return 0; + const object = new ReceiverObject(); + const box: any[] = [object, undefined]; + return box[index].read() === 42 ? 1 : -1; + } + + nullToString(): number { + const box: any[] = [null, 17, true]; + box[0].toString(); + return -1; + } + + undefinedValueOf(): number { + const box: any[] = [undefined, 17, true]; + box[0].valueOf(); + return -1; + } + + nullShift(): number { + const box: any[] = [null, 17, true]; + box[0].shift(); + return -1; + } + + undefinedShift(): number { + const box: any[] = [undefined, 17, true]; + box[0].shift(); + return -1; + } +} From c9e8d12ab96f8f9af6dfa1a7f33881d62282a3e5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 14:08:52 +0300 Subject: [PATCH 12/18] [TS Calls] Preserve approximation arguments and slice length --- .../usvm/machine/expr/CallApproximations.kt | 24 +++++------ .../usvm/machine/interpreter/TsInterpreter.kt | 2 +- .../call/TsUnknownCallDispatcherTest.kt | 43 ++++++++++++++++++- .../baseline/CallFallbackBaseline.ts | 10 +++++ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 47509a11e..2fe95f6a9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -7,7 +7,6 @@ import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature -import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr @@ -21,6 +20,7 @@ import org.usvm.api.memcpy import org.usvm.api.typeStreamOf import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsSizeSort +import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModelDispatcher import org.usvm.machine.call.dispatch @@ -80,10 +80,11 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( } internal fun TsExprResolver.tryApproximateInstanceCall( - expr: EtsInstanceCallExpr, - instance: UExpr<*>, - returnSite: EtsStmt, + stmt: TsVirtualMethodCallStmt, ): TsExprApproximationResult = with(ctx) { + val expr = stmt.call + val instance = stmt.instance + // Mock `.toString()` method calls if (expr.callee.name == "toString") { if (expr.args.isNotEmpty()) { @@ -129,7 +130,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.shift() method calls if (expr.callee.name == "shift") { - return handleArrayShiftCall(expr, instanceType, elementSort, instance, returnSite) + return handleArrayShiftCall(stmt, instanceType, elementSort) } // Handle `Array.join() method calls @@ -167,23 +168,20 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } private fun TsExprResolver.handleArrayShiftCall( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, elementSort: USort, - resolvedReceiver: UExpr<*>, - returnSite: EtsStmt, ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { - return from(handleArrayShift(expr, instanceType, elementSort, resolvedReceiver.asExpr(ctx.addressSort))) + return from(handleArrayShift(stmt.call, instanceType, elementSort, stmt.instance.asExpr(ctx.addressSort))) } dispatcher.dispatch( scope, - expr, - returnSite, + stmt, failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, - resolvedReceiver = resolvedReceiver, + resolvedReceiver = stmt.instance, ) return TsExprApproximationResult.ResolveFailure @@ -824,6 +822,8 @@ private fun TsExprResolver.handleArraySlice( length = newLength, ) + memory.write(mkArrayLengthLValue(slicedArray, arrayType), newLength, guard = trueExpr) + // Return the new array containing the slice slicedArray } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 71c7dff01..70ad6909f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -172,7 +172,7 @@ class TsInterpreter( } val resolver = exprResolverWithScope(scope) - when (val result = resolver.tryApproximateInstanceCall(stmt.call, instance, stmt.returnSite)) { + when (val result = resolver.tryApproximateInstanceCall(stmt)) { is TsExprApproximationResult.SuccessfulApproximation -> { scope.doWithState { methodResult = TsMethodResult.Success.MockedCall(result.expr, callee) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index a0bae2c19..707a46870 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -1,5 +1,6 @@ package org.usvm.machine.call +import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr import io.mockk.mockk import org.jacodb.ets.model.EtsFile @@ -20,6 +21,7 @@ import org.junit.jupiter.api.Test import org.usvm.PathSelectionStrategy import org.usvm.SolverType import org.usvm.StateCollectionStrategy +import org.usvm.UBoolSort import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UMachineOptions @@ -405,8 +407,7 @@ class TsUnknownCallDispatcherTest { @Test fun `normally executable and compatibility-approximated calls bypass unknown dispatch`() { val methods = listOf( - // The native frontend gives this call a concrete executable target despite the legacy baseline name. - "anyReceiverWithKnownMethodContinues", + "knownReceiverMethodContinues", "loggerCallSkipsBody", "toStringUsesPlaceholder", "valueOfReturnsReceiver", @@ -422,6 +423,44 @@ class TsUnknownCallDispatcherTest { } } + @Test + fun `unknown receiver preserves primitive fallbacks and executes the reference method`() { + val dispatcher = RecordingUnknownCallDispatcher() + + assertTrue(reachesReturn("anyReceiverWithKnownMethodContinues", dispatcher = dispatcher)) + + assertEquals(2, dispatcher.calls.size) + assertTrue(dispatcher.calls.all { it.failureReason == TsUnknownCallFailureReason.NON_REFERENCE_RECEIVER }) + val sorts = dispatcher.calls.map { assertNotNull(it.receiver?.resolved).sort } + assertTrue(sorts.any { it is UBoolSort }) + assertTrue(sorts.any { it is KFp64Sort }) + dispatcher.calls.forEach { call -> + assertEquals("known", call.callee.name) + assertEquals("anyReceiverWithKnownMethodContinues", call.callSite.location.method.name) + assertEquals(call.callee, assertNotNull(call.callSite.callExpr).callee) + } + } + + @Test + fun `partial approximation preserves resolved arguments and original call site`() { + val calls = mutableListOf() + val model = object : TestModel(id = "recording-shift", methodName = "shift") { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + calls += call + assertEquals(state.ctx.mkFp64(17.0), call.arguments.single().resolved) + return null + } + } + + assertFalse(reachesReturn("arrayShiftWithArgument", models = catalog(model))) + + val call = calls.single() + assertEquals(TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, call.failureReason) + assertNotNull(call.receiver?.resolved) + assertEquals("arrayShiftWithArgument", call.callSite.location.method.name) + assertEquals(call.arguments.single().source, assertNotNull(call.callSite.callExpr).args.single()) + } + @Test fun `pre-call allocation failures are documented dispatcher exclusions`() { val dispatcher = RecordingUnknownCallDispatcher() diff --git a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts index 7b80de0eb..e37b10172 100644 --- a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts +++ b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts @@ -92,6 +92,16 @@ class CallFallbackBaseline { return ExternalAny.value(); } + knownReceiverMethodContinues(receiver: KnownReceiver): number { + receiver.known(); + return 102; + } + + arrayShiftWithArgument(): number { + const values = [10, 20]; + return values.shift(17); + } + anyReceiverWithKnownMethodContinues(receiver: any): number { receiver.known(); return 102; From 9e36359bba617aeed608a63cb6ad350e43ad8478 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 14:15:36 +0300 Subject: [PATCH 13/18] [TS Calls] Normalize slice bounds and strengthen argument regression --- .../usvm/machine/expr/CallApproximations.kt | 17 ++++++--- .../call/TsInstanceCallReceiverTest.kt | 5 +++ .../call/TsUnknownCallDispatcherTest.kt | 4 ++- .../resources/models/InstanceCallReceiver.ts | 35 +++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 2fe95f6a9..26e1b382f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -758,7 +758,6 @@ private fun TsExprResolver.handleArraySlice( "Array.slice() should have at most two arguments, but got ${expr.args.size}" } - // TODO: Support negative `start` and `end` indices. val start = if (expr.args.isNotEmpty()) { resolve(expr.args[0]) ?: return null } else { @@ -805,8 +804,18 @@ private fun TsExprResolver.handleArraySlice( scope.calcOnState { val descriptor = arrayDescriptorOf(arrayType) - // Calculate the new length of the sliced array - val newLength = mkBvSubExpr(endBv, startBv) + val length = memory.read(mkArrayLengthLValue(array, arrayType)) + val zero = mkBv(0) + + fun normalizeIndex(index: UExpr): UExpr { + val relative = mkIte(mkBvSignedLessExpr(index, zero), mkBvAddExpr(length, index), index) + val capped = mkIte(mkBvSignedGreaterExpr(relative, length), length, relative) + return mkIte(mkBvSignedLessExpr(relative, zero), zero, capped) + } + + val from = normalizeIndex(startBv) + val to = normalizeIndex(endBv) + val newLength = mkIte(mkBvSignedLessExpr(from, to), mkBvSubExpr(to, from), zero) // Allocate a new array for the slice val slicedArray = memory.allocConcrete(descriptor) @@ -817,7 +826,7 @@ private fun TsExprResolver.handleArraySlice( dstRef = slicedArray, type = descriptor, elementSort = elementSort, - fromSrc = startBv, + fromSrc = from, fromDst = mkBv(0), length = newLength, ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt index d7a167e47..a38ba9b3f 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt @@ -136,6 +136,11 @@ class TsInstanceCallReceiverTest { Case(method = "wrappedFill", results = setOf(1.0)), Case(method = "wrappedUnshift", results = setOf(1.0)), Case(method = "wrappedSlice", results = setOf(1.0)), + Case(method = "wrappedSliceReversed", results = setOf(1.0)), + Case(method = "wrappedSlicePastEnd", results = setOf(1.0)), + Case(method = "wrappedSlicePastStart", results = setOf(1.0)), + Case(method = "wrappedSliceNegative", results = setOf(1.0)), + Case(method = "wrappedSliceEmpty", results = setOf(1.0)), Case(method = "wrappedConcat", results = setOf(1.0)), Case(method = "wrappedUserMethod", results = setOf(1.0)), Case(method = "customShift", results = setOf(1.0)), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 707a46870..b6426bbbc 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -444,10 +444,11 @@ class TsUnknownCallDispatcherTest { @Test fun `partial approximation preserves resolved arguments and original call site`() { val calls = mutableListOf() + var expectedArgument: UExpr<*>? = null val model = object : TestModel(id = "recording-shift", methodName = "shift") { override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { calls += call - assertEquals(state.ctx.mkFp64(17.0), call.arguments.single().resolved) + expectedArgument = state.ctx.mkFp64(17.0) return null } } @@ -455,6 +456,7 @@ class TsUnknownCallDispatcherTest { assertFalse(reachesReturn("arrayShiftWithArgument", models = catalog(model))) val call = calls.single() + assertEquals(assertNotNull(expectedArgument), call.arguments.single().resolved) assertEquals(TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, call.failureReason) assertNotNull(call.receiver?.resolved) assertEquals("arrayShiftWithArgument", call.callSite.location.method.name) diff --git a/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts b/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts index 4eefab2f4..b78f1321e 100644 --- a/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts +++ b/usvm-ts/src/test/resources/models/InstanceCallReceiver.ts @@ -55,6 +55,41 @@ export class InstanceCallReceiver { return result.length === 1 && result[0] === 20 && values.length === 2 ? 1 : -1; } + wrappedSliceReversed(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(1, 0); + return result.length === 0 && values.length === 2 ? 1 : -1; + } + + wrappedSlicePastEnd(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(1, 10); + return result.length === 1 && result[0] === 20 && values.length === 2 ? 1 : -1; + } + + wrappedSlicePastStart(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(10); + return result.length === 0 && values.length === 2 ? 1 : -1; + } + + wrappedSliceNegative(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(-10, -1); + return result.length === 1 && result[0] === 10 && values.length === 2 ? 1 : -1; + } + + wrappedSliceEmpty(): number { + const values = [10, 20]; + const box: any[] = [values, true]; + const result = box[0].slice(1, 1); + return result.length === 0 && values.length === 2 ? 1 : -1; + } + wrappedConcat(): number { const values = [10, 20]; const box: any[] = [values, true]; From d0ac4fa71f52abae69e48a6418e1cdee61d3519c Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 16:49:39 +0300 Subject: [PATCH 14/18] Address model catalog review and preserve unresolved array storage --- usvm-ts/UNKNOWN_CALL_MODELS.md | 45 ++- .../main/kotlin/org/usvm/machine/TsContext.kt | 10 +- .../main/kotlin/org/usvm/machine/TsMachine.kt | 2 +- .../kotlin/org/usvm/machine/TsMethodCall.kt | 1 + .../main/kotlin/org/usvm/machine/TsOptions.kt | 4 +- .../call/TsBuiltInUnknownCallModels.kt | 19 +- .../usvm/machine/call/TsUnknownCallModel.kt | 16 - .../machine/call/TsUnknownCallModelCatalog.kt | 90 +++--- .../call/TsUnknownCallModelDispatcher.kt | 15 +- .../call/TsUnknownCallModelSelection.kt | 9 + .../intrinsic/TsArrayShiftIntrinsicModel.kt | 105 +------ .../intrinsic/TsBuiltInUnknownCallModel.kt | 6 + .../usvm/machine/expr/CallApproximations.kt | 281 +++++++---------- .../org/usvm/machine/types/FakeExprUtil.kt | 2 +- .../machine/types/TsUnresolvedArrayKind.kt | 35 +-- .../usvm/machine/types/TsUnresolvedValue.kt | 5 +- .../main/kotlin/org/usvm/util/ArrayStorage.kt | 42 +++ .../call/TsArrayShiftIntrinsicModelTest.kt | 2 +- .../machine/call/TsArrayShiftReplayTest.kt | 292 ++++++++++++++++++ .../call/TsUnknownCallDispatcherTest.kt | 2 +- .../call/TsUnknownCallModelCatalogTest.kt | 83 ++++- 21 files changed, 679 insertions(+), 387 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelSelection.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index ea5de4779..f598c5c99 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -35,27 +35,31 @@ Unknown-call behavior is configured directly in `TsOptions`: ```kotlin TsOptions( - enabledUnknownCallModelIds = setOf("ts.array.shift"), + unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf("ts.array.shift")), unknownCallFallback = TsResidualCallPolicy.STOP_PATH, ) ``` -### `enabledUnknownCallModelIds` +### `unknownCallModelSelection` This is the only model-selection setting. | Value | Meaning | | --- | --- | -| `null` | Enable every built-in model. This is the default. | -| `emptySet()` | Disable every built-in model. | -| `setOf("id", ...)` | Enable exactly the listed built-in model IDs. | +| `TsUnknownCallModelSelection.All` | Enable every built-in model. This is the default. | +| `TsUnknownCallModelSelection.Only(emptySet())` | Disable every built-in model. | +| `TsUnknownCallModelSelection.Only(setOf("id", ...))` | Enable exactly the listed built-in model IDs. | -Unknown IDs are rejected when the machine creates its immutable per-run catalog. The input set is copied at that -point, so later mutations cannot change an active run. +Unknown IDs are rejected when the machine creates its immutable per-run catalog. The selected models are captured at that +point, so later mutations of the selection set cannot change an active run. Use the model's `id`, for example `ts.array.shift`. A target method name, class name, source filename, or fingerprint is not a model ID. +Built-ins are `object` implementations of the sealed `TsBuiltInUnknownCallModel` interface in the +`org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; adding a +model requires no manual registry entry. Discovery and the default catalog are computed once. + The built-in catalog currently contains one model: | ID | Implementation | Accepted calls | @@ -133,7 +137,10 @@ TsUnknownCallTarget( ``` Only `methodName` is required. Add `enclosingClassName` or `failureReason` when the method name alone is too broad. -The catalog rejects overlapping enabled targets before execution, so catalog order is never a priority rule. +The catalog indexes method names, failure reasons, and enclosing classes. Overlapping enabled targets fail while +building that index; lookup returns either one model or no match, and never hides ambiguity. Catalog order is never +a priority rule. IDs and their SHA-256 fingerprint are computed once; byte-length prefixes distinguish ID sequences +such as `["ab", "c"]` and `["a", "bc"]`. The target identifies a call family. State-dependent checks, such as the receiver's symbolic runtime type, belong in `apply`. @@ -176,13 +183,21 @@ An intrinsic directly builds guarded successors and symbolic-memory operations i that TypeScript cannot express without losing symbolic efficiency or correctness. `Array.shift` is the built-in example because shifting a symbolic array is naturally represented by symbolic-memory -`memcpy` operations. A resolved element sort uses one array region. A symbolic array with an unresolved element sort -copies three payload regions (boolean, number, and address) and three boolean runtime-kind selector regions. -Selectors belong to input elements and move with their payloads, so repeated shifts preserve the constraints needed -to reconstruct and replay the original input. Allocated unresolved arrays store fake-value wrappers in the address -region. The removed element is materialized before forking so the exactly-one type constraint and updated solver -models are inherited by every successor. - +`memcpy` operations. A resolved element sort uses one canonical array region. Unresolved elements use three +payload regions (boolean, number, and address) and two boolean kind selectors. Reference kind is derived as +`!(booleanKind || numberKind)`; the exactly-one constraint excludes both primitive selectors being true. Default +allocated slots therefore represent references, including undefined. `Unknown[]` names the canonical reference +storage region, not a claim that every TypeScript array has unresolved elements. + +`copyArrayElements` moves all five regions for unresolved arrays, including allocated arrays created by `slice` or +`concat`. `reverse` applies the same index permutation to every region. Scalar writes store complete fake wrappers +in the reference region, overriding older payloads and selectors. Reads and test reconstruction use the same reader. +The removed `shift` element is materialized before forking so its kind constraint and updated solver models are +inherited by every successor. + +`concat` handles arrays with compatible storage sorts and scalar elements that fit the destination. Calls requiring +conversion between storage sorts, or runtime spreading of a fake/untyped argument, use normal call resolution and +fallback. Existing `fill` bounds and the finite `reverse`/`fill` caps remain approximation limitations. Array reads, writes, length access, and `shift` use the storage type known to symbolic memory when it is unique. Widening a local from `number[]` to `any[]` therefore keeps the same element and length regions. diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index 8596f6931..12f4cbde2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -198,10 +198,14 @@ class TsContext( return sort == addressSort && this is UConcreteHeapRef && address > MAGIC_OFFSET } - /** Returns whether this expression contains a fake-value wrapper as itself or as a conditional branch. */ - fun UExpr<*>.containsFakeObject(): Boolean = when { + /** + * Checks result alternatives for fake-wrapper identities. Address-region reads lift stored concrete references + * into ITE branches; wrappers in a guard or read key are dependencies, not possible results of the expression. + */ + fun UHeapRef.hasFakeValueBranch(): Boolean = when { isFakeObject() -> true - this is UIteExpr<*> -> trueBranch.containsFakeObject() || falseBranch.containsFakeObject() + this is UIteExpr<*> -> trueBranch.asExpr(addressSort).hasFakeValueBranch() || + falseBranch.asExpr(addressSort).hasFakeValueBranch() else -> false } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 15e480447..0e8e6d537 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -50,7 +50,7 @@ class TsMachine( private val resolvedUnknownCallModels = when { unknownCallDispatcher != null -> null unknownCallModels != null -> unknownCallModels - else -> TsBuiltInUnknownCallModels.catalog(tsOptions.enabledUnknownCallModelIds) + else -> TsBuiltInUnknownCallModels.catalog(tsOptions.unknownCallModelSelection) } /** Fingerprint of the model catalog used by this machine, or `null` for a custom dispatcher. */ diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMethodCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMethodCall.kt index e8f2ec65a..abb6d42cb 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMethodCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMethodCall.kt @@ -22,6 +22,7 @@ sealed interface TsMethodCall : EtsStmt { } } +/** [instance] is an extracted payload; the receiver's branch and runtime-kind guards are already asserted. */ class TsVirtualMethodCallStmt( override val call: EtsInstanceCallExpr, override val instance: UExpr<*>, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index c3213f3a8..aca3a3d54 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -1,12 +1,12 @@ package org.usvm.machine import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallModelSelection data class TsOptions( val interproceduralAnalysis: Boolean = true, val enableVisualization: Boolean = false, val maxArraySize: Int = 1_000, - /** `null` enables every built-in model; an empty set disables all models. */ - val enabledUnknownCallModelIds: Set? = null, + val unknownCallModelSelection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All, val unknownCallFallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, ) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt index 51fd7c34e..935d768c3 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt @@ -1,13 +1,18 @@ package org.usvm.machine.call -import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel +import org.usvm.machine.call.intrinsic.TsBuiltInUnknownCallModel -/** The intentionally small built-in semantic-model catalog. */ +/** Discovers built-in model objects from the sealed hierarchy. */ object TsBuiltInUnknownCallModels { - const val ARRAY_SHIFT_MODEL_ID: String = TsArrayShiftIntrinsicModel.MODEL_ID + private val models by lazy { + TsBuiltInUnknownCallModel::class.sealedSubclasses.map { modelClass -> + requireNotNull(modelClass.objectInstance) { + "Built-in semantic model must be an object: ${modelClass.qualifiedName}" + } + } + } + private val allModels by lazy { TsUnknownCallModelCatalog(models) } - fun catalog(enabledModelIds: Set? = null) = TsUnknownCallModelCatalog( - models = listOf(TsArrayShiftIntrinsicModel), - enabledModelIds = enabledModelIds, - ) + fun catalog(selection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All): TsUnknownCallModelCatalog = + if (selection == TsUnknownCallModelSelection.All) allModels else TsUnknownCallModelCatalog(models, selection) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt index 240f4f003..6334fa48a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -18,22 +18,6 @@ data class TsUnknownCallTarget( "Semantic model target class name must not be blank" } } - - internal fun matches(call: TsUnknownCall): Boolean = - call.callee.name == methodName && - (enclosingClassName == null || call.callee.enclosingClass.name == enclosingClassName) && - (failureReason == null || call.failureReason == failureReason) - - internal fun overlaps(other: TsUnknownCallTarget): Boolean { - val classNamesOverlap = enclosingClassName == null || - other.enclosingClassName == null || - enclosingClassName == other.enclosingClassName - val failureReasonsOverlap = failureReason == null || - other.failureReason == null || - failureReason == other.failureReason - - return methodName == other.methodName && classNamesOverlap && failureReasonsOverlap - } } /** diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt index dd74e9343..9a0694468 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt @@ -10,44 +10,38 @@ private const val BYTE_MASK = 0xff /** An immutable deterministic set of semantic models used by one machine run. */ class TsUnknownCallModelCatalog( models: Collection, - enabledModelIds: Set? = null, + selection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All, ) { - private val models: List + private val index: Map>> val modelIds: List - get() = models.map(TsUnknownCallModel::id) - val fingerprint: String init { - val allModels = models.sortedBy(TsUnknownCallModel::id) - val duplicateIds = allModels - .groupingBy(TsUnknownCallModel::id) - .eachCount() - .filterValues { count -> count > 1 } - .keys - .sorted() - - require(allModels.none { model -> model.id.isBlank() }) { "Semantic model ID must not be blank" } - require(duplicateIds.isEmpty()) { "Duplicate semantic model IDs: ${duplicateIds.joinToString()}" } - - val selectedIds = enabledModelIds?.toSet() - val knownIds = allModels.mapTo(mutableSetOf(), TsUnknownCallModel::id) - val unknownIds = selectedIds.orEmpty().subtract(knownIds).sorted() - - require(unknownIds.isEmpty()) { "Unknown semantic model IDs: ${unknownIds.joinToString()}" } - - this.models = when (selectedIds) { - null -> allModels - else -> allModels.filter { model -> model.id in selectedIds } + val modelsById = hashMapOf() + models.forEach { model -> + require(model.id.isNotBlank()) { "Semantic model ID must not be blank" } + require(modelsById.put(model.id, model) == null) { "Duplicate semantic model ID: ${model.id}" } } - validateUnambiguousTargets(this.models) - fingerprint = computeFingerprint(this.models) + val selectedModels = when (selection) { + TsUnknownCallModelSelection.All -> modelsById.values + is TsUnknownCallModelSelection.Only -> { + val unknownIds = selection.ids.subtract(modelsById.keys) + require(unknownIds.isEmpty()) { "Unknown semantic model IDs: ${unknownIds.sorted().joinToString()}" } + selection.ids.map(modelsById::getValue) + } + }.sortedBy(TsUnknownCallModel::id) + + modelIds = selectedModels.map(TsUnknownCallModel::id) + index = indexModels(selectedModels) + fingerprint = computeFingerprint(modelIds) } - internal fun select(call: TsUnknownCall): TsUnknownCallModel? = - models.singleOrNull { model -> model.target.matches(call) } + internal fun select(call: TsUnknownCall): TsUnknownCallModel? { + val candidates = index[call.callee.name]?.get(call.failureReason) ?: return null + return candidates[call.callee.enclosingClass.name] ?: candidates[null] + } fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { val model = select(call) ?: return TsUnknownCallModelApplication.NotApplicable @@ -60,31 +54,41 @@ class TsUnknownCallModelCatalog( } } -private fun validateUnambiguousTargets(models: List) { - models.forEachIndexed { index, model -> - val conflictingModel = models.drop(index + 1).firstOrNull { other -> - model.target.overlaps(other.target) - } ?: return@forEachIndexed - - error( - "Ambiguous semantic model targets: " + - listOf(model.id, conflictingModel.id).sorted().joinToString() - ) +private fun indexModels( + models: List, +): Map>> { + val index = hashMapOf>>() + models.forEach { model -> + val target = model.target + val methods = index.getOrPut(target.methodName) { hashMapOf() } + val reasons = target.failureReason?.let(::listOf) ?: TsUnknownCallFailureReason.entries + reasons.forEach { reason -> + val classes = methods.getOrPut(reason) { hashMapOf() } + val conflict = if (target.enclosingClassName == null) { + classes.values.firstOrNull() + } else { + classes[target.enclosingClassName] ?: classes[null] + } + if (conflict != null) { + error("Ambiguous semantic model targets: ${listOf(model.id, conflict.id).sorted().joinToString()}") + } + + classes[target.enclosingClassName] = model + } } + return index } -private fun computeFingerprint(models: List): String { +private fun computeFingerprint(modelIds: List): String { val digest = MessageDigest.getInstance("SHA-256") - - models.forEach { model -> - digest.updateLengthPrefixed(model.id) - } + modelIds.forEach { digest.updateLengthPrefixed(it) } return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and BYTE_MASK) } } +/** Length prefixes distinguish ID sequences such as ["ab", "c"] and ["a", "bc"]. */ private fun MessageDigest.updateLengthPrefixed(value: String) { val bytes = value.toByteArray(StandardCharsets.UTF_8) update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index cf4f285eb..3ede022c8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -1,5 +1,6 @@ package org.usvm.machine.call +import mu.KotlinLogging import org.usvm.UExpr import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall @@ -11,6 +12,8 @@ import org.usvm.machine.state.TsState import org.usvm.machine.state.newStmt import org.usvm.machine.types.mkFakeValue +private val logger = KotlinLogging.logger {} + /** The externally observable effect of an unknown-call decision. */ enum class TsUnknownCallOutcome { MODEL_APPLIED, @@ -59,7 +62,7 @@ class TsModelUnknownCallDispatcher( } } - observer?.onUnknownCallSafely(event(call, decision)) + reportFallback(call) return decision.outcome } @@ -117,7 +120,7 @@ class TsModelUnknownCallDispatcher( observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ModelApplied(application.modelId))) } if (freshResidualApplied || stoppedResidualIsSatisfiable) { - observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ResidualFallback(fallback))) + reportFallback(call) } return when { @@ -128,6 +131,14 @@ class TsModelUnknownCallDispatcher( } } + private fun reportFallback(call: TsUnknownCall) { + logger.debug { + "Unknown call ${call.callee} at ${call.callSite.location}: " + + "fallback=$fallback, reason=${call.failureReason}" + } + observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ResidualFallback(fallback))) + } + private fun modelStateChange( call: TsUnknownCall, successor: TsUnknownCallModelSuccessor, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelSelection.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelSelection.kt new file mode 100644 index 000000000..23131cd9c --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelSelection.kt @@ -0,0 +1,9 @@ +package org.usvm.machine.call + +/** Selects all registered models or an explicit set of model IDs; an empty set disables models. */ +sealed interface TsUnknownCallModelSelection { + /** Enables every discovered built-in model. */ + data object All : TsUnknownCallModelSelection + + data class Only(val ids: Set) : TsUnknownCallModelSelection +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt index dfe304ea7..e060ff41f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayShiftIntrinsicModel.kt @@ -2,32 +2,26 @@ package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType -import org.jacodb.ets.model.EtsBooleanType -import org.jacodb.ets.model.EtsNumberType -import org.jacodb.ets.model.EtsUnknownType import org.usvm.UAddressSort -import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.USort -import org.usvm.api.memcpy import org.usvm.machine.TsSizeSort import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.machine.call.TsUnknownCallModel import org.usvm.machine.call.TsUnknownCallModelCompletion import org.usvm.machine.call.TsUnknownCallModelExecution import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.state.TsState -import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.util.arrayStorageType +import org.usvm.util.copyArrayElements import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue /** Engine intrinsic for `Array.shift`, whose bulk move is implemented by symbolic-memory `memcpy`. */ -internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { +internal object TsArrayShiftIntrinsicModel : TsBuiltInUnknownCallModel { const val MODEL_ID: String = "ts.array.shift" override val id: String = MODEL_ID @@ -55,7 +49,14 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { guard = nonEmptyGuard, completion = firstElementCompletion, applyStateChanges = { - shiftElements(input, fromSrc = one, fromDst = zero, length = newLength) + copyArrayElements( + srcRef = input.array, + dstRef = input.array, + arrayType = input.arrayType, + fromSrc = one, + fromDst = zero, + length = newLength, + ) memory.write(lengthLValue, newLength, guard = trueExpr) }, ) @@ -105,92 +106,6 @@ internal object TsArrayShiftIntrinsicModel : TsUnknownCallModel { TsUnknownCallModelCompletion.Unresolved(firstElement) } - private fun TsState.shiftElements( - input: ArrayShiftInput, - fromSrc: UExpr, - fromDst: UExpr, - length: UExpr, - ) = with(ctx) { - if (input.elementSort !is TsUnresolvedSort) { - copyArrayRegion( - input = input, - arrayType = input.arrayType, - elementSort = input.elementSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - return@with - } - - if (input.array is UConcreteHeapRef) { - copyArrayRegion( - input = input, - arrayType = input.arrayType, - elementSort = addressSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - return@with - } - - copyArrayRegion( - input = input, - arrayType = EtsArrayType(EtsBooleanType, dimensions = 1), - elementSort = boolSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - copyArrayRegion( - input = input, - arrayType = EtsArrayType(EtsNumberType, dimensions = 1), - elementSort = fp64Sort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - copyArrayRegion( - input = input, - arrayType = EtsArrayType(EtsUnknownType, dimensions = 1), - elementSort = addressSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - TsUnresolvedArrayKind.entries.forEach { kind -> - memory.memcpy( - srcRef = input.array, - dstRef = input.array, - type = kind, - elementSort = boolSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - } - } - - private fun TsState.copyArrayRegion( - input: ArrayShiftInput, - arrayType: EtsArrayType, - elementSort: USort, - fromSrc: UExpr, - fromDst: UExpr, - length: UExpr, - ) { - memory.memcpy( - srcRef = input.array, - dstRef = input.array, - type = ctx.arrayDescriptorOf(arrayType), - elementSort = elementSort, - fromSrc = fromSrc, - fromDst = fromDst, - length = length, - ) - } - private class ArrayShiftInput( val array: UExpr, val arrayType: EtsArrayType, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt new file mode 100644 index 000000000..ce29d46e5 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt @@ -0,0 +1,6 @@ +package org.usvm.machine.call.intrinsic + +import org.usvm.machine.call.TsUnknownCallModel + +/** Implement as an object in this package; the sealed hierarchy registers every built-in automatically. */ +internal sealed interface TsBuiltInUnknownCallModel : TsUnknownCallModel diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 26e1b382f..b912210ab 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -17,7 +17,8 @@ import org.usvm.api.allocateConcreteRef import org.usvm.api.initializeArray import org.usvm.api.makeSymbolicPrimitive import org.usvm.api.memcpy -import org.usvm.api.typeStreamOf +import org.usvm.api.readArrayIndex +import org.usvm.api.writeArrayIndex import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsSizeSort import org.usvm.machine.TsVirtualMethodCallStmt @@ -28,9 +29,13 @@ import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue +import org.usvm.machine.types.iteWriteIntoFakeObject +import org.usvm.machine.types.mkFakeValue +import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort -import org.usvm.types.first import org.usvm.util.arrayStorageType +import org.usvm.util.copyArrayElements +import org.usvm.util.forEachArrayStorageRegion import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods @@ -125,7 +130,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.unshift() method calls if (expr.callee.name == "unshift") { - return from(handleArrayUnshift(expr, instanceType, elementSort, array)) + return from(handleArrayUnshift(expr, instanceType, array)) } // Handle `Array.shift() method calls @@ -140,12 +145,12 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.slice() method calls if (expr.callee.name == "slice") { - return from(handleArraySlice(expr, instanceType, elementSort, array)) + return from(handleArraySlice(expr, instanceType, array)) } // Handle `Array.concat() method calls if (expr.callee.name == "concat") { - return from(handleArrayConcat(expr, instanceType, elementSort, array)) + return handleArrayConcat(stmt, instanceType, array) } // Handle `Array.indexOf() method calls @@ -160,7 +165,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.reverse() method calls if (expr.callee.name == "reverse") { - return from(handleArrayReverse(expr, instanceType, elementSort, array)) + return from(handleArrayReverse(expr, instanceType, array)) } } @@ -400,34 +405,34 @@ private fun TsExprResolver.handleArrayPop( "Array.pop() should have no arguments, but got ${expr.args.size}" } - checkNotFake(array) + removeArrayElement(array, arrayType, elementSort, first = false) +} +private fun TsExprResolver.removeArrayElement( + array: UHeapRef, + arrayType: EtsArrayType, + elementSort: USort, + first: Boolean, +): UExpr<*> = with(ctx) { scope.calcOnState { - // Read the length of the array val lengthLValue = mkArrayLengthLValue(array, arrayType) val length = memory.read(lengthLValue) + val empty = mkEq(length, mkBv(0)) + val newLength = mkIte(empty, mkBv(0), mkBvSubExpr(length, mkBv(1))) + val index = if (first) mkBv(0) else newLength + val removed = if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { + mkFakeValue(scope, readUnresolvedArrayElement(memory, array, index)) + } else { + memory.read(mkArrayIndexLValue(elementSort, array, index, arrayType)) + } + val result = iteWriteIntoFakeObject(scope, empty, mkUndefinedValue(), removed) - // Decrease the length of the array - // TODO: Only decrease the length if it is not zero. - // It is not an error/exception to pop from an empty array! - // If the array is empty, `pop` returns `undefined`. - val newLength = mkBvSubExpr(length, mkBv(1)) - - // Read the last element of the array (to be removed) - val lastIndexLValue = mkArrayIndexLValue( - sort = elementSort, - ref = array, - index = newLength, - type = arrayType, - ) - // TODO: If the array is empty, return `undefined` instead of the last element. - val removedElement = memory.read(lastIndexLValue) - - // Update the length of the array (AFTER reading the last element) + if (first) { + copyArrayElements(array, array, arrayType, fromSrc = mkBv(1), fromDst = mkBv(0), length = newLength) + } memory.write(lengthLValue, newLength, guard = trueExpr) - // Return the removed element - removedElement + result } } @@ -465,7 +470,12 @@ private fun TsExprResolver.handleArrayFill( check(expr.args.size >= 1 && expr.args.size <= 3) { "Array.fill() should have 1 to 3 arguments, but got ${expr.args.size}" } - val value = resolve(expr.args[0]) ?: return null + val resolvedValue = resolve(expr.args[0]) ?: return null + val value = if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { + resolvedValue.toFakeObject(scope) + } else { + resolvedValue + } // TODO: Support negative `start` and `end` indices. val start = if (expr.args.size > 1) { @@ -581,42 +591,7 @@ private fun TsExprResolver.handleArrayShift( "Array.shift() should have no arguments, but got ${expr.args.size}" } - scope.calcOnState { - // Store the first element of the array (to be removed) - // TODO: If the array is empty, return `undefined` instead of the first element. - val firstIndexLValue = mkArrayIndexLValue( - sort = elementSort, - ref = array, - index = mkBv(0), - type = arrayType, - ) - val firstElement = memory.read(firstIndexLValue) - - // Read the length of the array - val lengthLValue = mkArrayLengthLValue(array, arrayType) - val length = memory.read(lengthLValue) - - // Decrease the length of the array - // TODO: Only decrease the length if it is not zero. - // It is not an error/exception to shift an empty array! - // If the array is empty, `shift` returns `undefined`. - val newLength = mkBvSubExpr(length, mkBv(1)) - memory.write(lengthLValue, newLength, guard = trueExpr) - - // Shift elements to the left - memory.memcpy( - srcRef = array, - dstRef = array, - type = arrayType, - elementSort = elementSort, - fromSrc = mkBv(1), - fromDst = mkBv(0), - length = newLength, - ) - - // Return the removed element - firstElement - } + removeArrayElement(array, arrayType, elementSort, first = true) } /** @@ -640,7 +615,6 @@ private fun TsExprResolver.handleArrayShift( private fun TsExprResolver.handleArrayUnshift( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { // TODO: support vararg @@ -659,24 +633,18 @@ private fun TsExprResolver.handleArrayUnshift( memory.write(lengthLValue, newLength, guard = trueExpr) // Shift elements to the right - memory.memcpy( + copyArrayElements( srcRef = array, dstRef = array, - type = arrayType, - elementSort = elementSort, + arrayType = arrayType, fromSrc = mkBv(0), fromDst = mkBv(1), length = length, ) // Write the new element to the start of the array - val startIndexLValue = mkArrayIndexLValue( - sort = elementSort, - ref = array, - index = mkBv(0), - type = arrayType, - ) - memory.write(startIndexLValue, arg.asExpr(elementSort), guard = trueExpr) + assignToArrayIndex(scope, array, index = mkBv(0), expr = arg, arrayType = arrayType) + ?: return@calcOnState null // Return the new length of the array (as per ECMAScript spec for Array.unshift) mkBvToFpExpr( @@ -751,7 +719,6 @@ private const val ARRAY_JOIN_RESULT = "joined_array_result" private fun TsExprResolver.handleArraySlice( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { check(expr.args.size <= 2) { @@ -821,11 +788,10 @@ private fun TsExprResolver.handleArraySlice( val slicedArray = memory.allocConcrete(descriptor) // Copy the specified range from the original array to the new array - memory.memcpy( + copyArrayElements( srcRef = array, dstRef = slicedArray, - type = descriptor, - elementSort = elementSort, + arrayType = arrayType, fromSrc = from, fromDst = mkBv(0), length = newLength, @@ -857,85 +823,73 @@ private fun TsExprResolver.handleArraySlice( * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.concat */ private fun TsExprResolver.handleArrayConcat( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, -): UExpr<*>? = with(ctx) { - check(expr.args.isNotEmpty()) { - "Array.concat() should have at least one argument, but got ${expr.args.size}" +): TsExprApproximationResult = with(ctx) { + val elementSort = typeToSort(arrayType.elementType) + val arrayTypes = stmt.args.mapIndexed { index, arg -> + if (arg.sort == addressSort) { + val ref = arg.asExpr(addressSort) + // A fake or an untyped reference may itself contain an array; spreading requires runtime dispatch. + if (ref.hasFakeValueBranch()) return TsExprApproximationResult.NoApproximation + val type = scope.calcOnState { arrayStorageType(ref, stmt.call.args[index].type) } + if (typeToSort(type) is TsUnresolvedSort) return TsExprApproximationResult.NoApproximation + type as? EtsArrayType + } else { + null + } + } + if (arrayTypes.withIndex().any { (index, type) -> + if (type != null) { + typeToSort(type.elementType) != elementSort + } else { + elementSort !is TsUnresolvedSort && stmt.args[index].sort != elementSort + } + } + ) { + logger.debug { "Array.concat requires conversion between different element storage sorts" } + return TsExprApproximationResult.NoApproximation } - val args = expr.args.map { resolve(it) ?: return null } - - scope.calcOnState { - val descriptor = arrayDescriptorOf(arrayType) - - // Allocate a new array for the concatenated result - val resultArray = memory.allocConcrete(descriptor) - - // Read the length of the original array - val originalLengthLValue = mkArrayLengthLValue(array, arrayType) - val originalLength = memory.read(originalLengthLValue) - - // Copy the original array to the result array - memory.memcpy( - srcRef = array, - dstRef = resultArray, - type = descriptor, - elementSort = elementSort, - fromSrc = mkBv(0), - fromDst = mkBv(0), - length = originalLength, - ) + from( + scope.calcOnState { + val resultArray = memory.allocConcrete(arrayDescriptorOf(arrayType)) + val originalLength = memory.read(mkArrayLengthLValue(array, arrayType)) + copyArrayElements( + srcRef = array, + dstRef = resultArray, + arrayType = arrayType, + fromSrc = mkBv(0), + fromDst = mkBv(0), + length = originalLength, + ) - // Handle each argument in the `concat` call - var totalLength = originalLength - for (arg in args) { - // For array arguments, copy their elements to the result array - if (arg.sort == addressSort) { - // TODO: handle empty type stream - val argType = memory.typeStreamOf(arg.asExpr(addressSort)).first() - if (argType is EtsArrayType) { - val argLengthLValue = mkArrayLengthLValue(arg.asExpr(addressSort), argType) - val argLength = memory.read(argLengthLValue) - - // Copy the elements of the argument array to the result array - memory.memcpy( - srcRef = arg.asExpr(addressSort), + var totalLength = originalLength + stmt.args.forEachIndexed { index, arg -> + val argType = arrayTypes[index] + if (argType != null) { + val ref = arg.asExpr(addressSort) + val length = memory.read(mkArrayLengthLValue(ref, argType)) + copyArrayElements( + srcRef = ref, dstRef = resultArray, - type = descriptor, - elementSort = elementSort, + arrayType = argType, fromSrc = mkBv(0), fromDst = totalLength, - length = argLength, + length = length, ) - - // Add the length of the argument array to the total length - totalLength = mkBvAddExpr(totalLength, argLength) - - continue + totalLength = mkBvAddExpr(totalLength, length) + } else { + assignToArrayIndex(scope, resultArray, totalLength, arg, arrayType) ?: return@calcOnState null + totalLength = mkBvAddExpr(totalLength, mkBv(1)) } } + memory.write(mkArrayLengthLValue(resultArray, arrayType), totalLength, guard = trueExpr) - // For non-array arguments, treat them as a single element - val newIndexLValue = mkArrayIndexLValue( - sort = elementSort, - ref = resultArray, - index = totalLength, - type = arrayType, - ) - memory.write(newIndexLValue, arg.asExpr(elementSort), guard = trueExpr) - totalLength = mkBvAddExpr(totalLength, mkBv(1)) + resultArray } - - // Set the length of the result array - val resultLengthLValue = mkArrayLengthLValue(resultArray, arrayType) - memory.write(resultLengthLValue, totalLength, guard = trueExpr) - - // Return the new concatenated array - resultArray - } + ) } /** @@ -1067,7 +1021,6 @@ private fun TsExprResolver.handleArrayIncludes( private fun TsExprResolver.handleArrayReverse( expr: EtsInstanceCallExpr, arrayType: EtsArrayType, - elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { check(expr.args.isEmpty()) { @@ -1084,40 +1037,20 @@ private fun TsExprResolver.handleArrayReverse( val lengthLValue = mkArrayLengthLValue(array, arrayType) val length = memory.read(lengthLValue) - // Initialize the reversed array with symbolic elements - memory.initializeArray( - reversedArray, - descriptor, - elementSort, - sizeSort, - (0 until ARRAY_REVERSE_MAX_SIZE).asSequence().map { index -> - // reversedIndex := length - 1 - index + forEachArrayStorageRegion(arrayType) { region, sort -> + for (index in 0 until ARRAY_REVERSE_MAX_SIZE) { val reversedIndex = mkBvSubExpr(mkBvSubExpr(length, mkBv(1)), index.toBv()) - val elementLValue = mkArrayIndexLValue( - sort = elementSort, - ref = array, - index = reversedIndex, - type = arrayType, - ) - memory.read(elementLValue) + val value = memory.readArrayIndex(array, reversedIndex, region, sort) + memory.writeArrayIndex(reversedArray, index.toBv(), region, sort, value, guard = trueExpr) } - ) - - //! Note: `reversedArray` is a temporary object not used outside this function, - // so it is not necessary to set the "correct" length for it. - // Set the length of the reversed array - // val reversedLengthLValue = mkArrayLengthLValue(reversedArray, arrayType) - // memory.write(reversedLengthLValue, length, guard = trueExpr) - - // Copy the reversed array back to the original array (in-place modification) - memory.memcpy( + } + copyArrayElements( + arrayType = arrayType, srcRef = reversedArray, dstRef = array, - type = descriptor, - elementSort = elementSort, fromSrc = mkBv(0), fromDst = mkBv(0), - length = length, + length = length ) // Return the modified original array diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt index 532d7d929..0917e2dfc 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/FakeExprUtil.kt @@ -102,7 +102,7 @@ private fun TsState.materializeFakeValue( when { refValue.isFakeObject() -> refValue - !refValue.containsFakeObject() -> mkFakeValue( + !refValue.hasFakeValueBranch() -> mkFakeValue( scope = scope, boolValue = value.boolValue, fpValue = value.fpValue, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt index 7837aa67a..795c78106 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedArrayKind.kt @@ -6,18 +6,15 @@ import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsUnknownType import org.usvm.UExpr import org.usvm.UHeapRef -import org.usvm.collection.array.UArrayIndexLValue -import org.usvm.isAllocatedConcreteHeapRef +import org.usvm.api.readArrayIndex import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.memory.UReadOnlyMemory -import org.usvm.util.mkArrayIndexLValue /** Kind selectors live with input elements, so copying elements also preserves their runtime types. */ internal enum class TsUnresolvedArrayKind { BOOLEAN, NUMBER, - REFERENCE, } internal fun TsContext.readUnresolvedArrayElement( @@ -26,26 +23,20 @@ internal fun TsContext.readUnresolvedArrayElement( index: UExpr, ): TsUnresolvedValue { val unknownArrayType = EtsArrayType(EtsUnknownType, dimensions = 1) - val refValue = memory.read(mkArrayIndexLValue(addressSort, array, index, unknownArrayType)) - - // Allocated unresolved arrays store complete wrappers, including conditional writes, in the address region. - if (isAllocatedConcreteHeapRef(array)) { - return TsUnresolvedValue( - boolValue = falseExpr, - fpValue = mkFp64(0.0), - refValue = refValue, - type = EtsFakeType.mkRef(this), - ) - } - val boolArrayType = EtsArrayType(EtsBooleanType, dimensions = 1) val numberArrayType = EtsArrayType(EtsNumberType, dimensions = 1) - val boolKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.BOOLEAN)) - val fpKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.NUMBER)) - val refKind = memory.read(UArrayIndexLValue(boolSort, array, index, TsUnresolvedArrayKind.REFERENCE)) - val type = EtsFakeType(boolTypeExpr = boolKind, fpTypeExpr = fpKind, refTypeExpr = refKind) - val boolValue = memory.read(mkArrayIndexLValue(boolSort, array, index, boolArrayType)) - val fpValue = memory.read(mkArrayIndexLValue(fp64Sort, array, index, numberArrayType)) + val boolKind = memory.readArrayIndex(array, index, TsUnresolvedArrayKind.BOOLEAN, boolSort) + val fpKind = memory.readArrayIndex(array, index, TsUnresolvedArrayKind.NUMBER, boolSort) + // Default allocated cells represent references (undefined), including cells written with complete fake wrappers. + val refKind = mkNot(mkOr(boolKind, fpKind)) + val type = EtsFakeType( + boolTypeExpr = boolKind, + fpTypeExpr = fpKind, + refTypeExpr = refKind, + ) + val boolValue = memory.readArrayIndex(array, index, boolArrayType, boolSort) + val fpValue = memory.readArrayIndex(array, index, numberArrayType, fp64Sort) + val refValue = memory.readArrayIndex(array, index, unknownArrayType, addressSort) return TsUnresolvedValue( boolValue = boolValue, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt index 19781e016..9e77ec777 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsUnresolvedValue.kt @@ -5,7 +5,10 @@ import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.UHeapRef -/** The backing payloads and kind selectors of a TypeScript value with an unresolved runtime kind. */ +/** + * A read-only snapshot of payloads and kind selectors, without a heap identity or allocation. + * [mkFakeValue] materializes it as a fake wrapper and constrains its kind through a live execution scope. + */ data class TsUnresolvedValue( val boolValue: UBoolExpr, val fpValue: UExpr, diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt new file mode 100644 index 000000000..a29d3f41b --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -0,0 +1,42 @@ +package org.usvm.util + +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.USort +import org.usvm.api.memcpy +import org.usvm.machine.TsContext +import org.usvm.machine.TsSizeSort +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedArrayKind + +/** Enumerates storage channels, independently of whether an array was allocated or came from the input. */ +internal inline fun TsContext.forEachArrayStorageRegion(arrayType: EtsArrayType, action: (Any, USort) -> Unit) { + val elementSort = typeToSort(arrayType.elementType) + if (elementSort !is TsUnresolvedSort) { + action(arrayDescriptorOf(arrayType), elementSort) + return + } + + action(EtsArrayType(EtsBooleanType, dimensions = 1), boolSort) + action(EtsArrayType(EtsNumberType, dimensions = 1), fp64Sort) + action(EtsArrayType(EtsUnknownType, dimensions = 1), addressSort) + TsUnresolvedArrayKind.entries.forEach { action(it, boolSort) } +} + +internal fun TsState.copyArrayElements( + srcRef: UHeapRef, + dstRef: UHeapRef, + arrayType: EtsArrayType, + fromSrc: UExpr, + fromDst: UExpr, + length: UExpr, +) { + ctx.forEachArrayStorageRegion(arrayType) { region, sort -> + memory.memcpy(srcRef, dstRef, region, sort, fromSrc, fromDst, length) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt index eaa60d4a3..ad65cc64a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftIntrinsicModelTest.kt @@ -259,7 +259,7 @@ class TsArrayShiftIntrinsicModelTest { val disabledResult = analyze( methodName = "nonEmptyArray", tsOptions = TsOptions( - enabledUnknownCallModelIds = emptySet(), + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, ), ) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 1cdfc0328..0fd33f8a2 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -131,10 +131,302 @@ class TsArrayShiftReplayTest { ) ) } + addAll(storageOperationCases()) addAll(pairCases()) addAll(typedCases()) } + private fun storageOperationCases(): List = listOf( + ReplayCase( + name = "any slice() retains all runtime kinds", + parameters = "values: any[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ReplayCase( + name = "any slice().reverse() retains all runtime kinds", + parameters = "values: any[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice().reverse(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ReplayCase( + name = "any sliced input mixed with appended wrapper", + parameters = "values: any[], index: number", + maxResult = 4, + body = """ + if (values.length !== 1) return 0; + const i = Math.floor(index); + if (i < 0 || i > 1) return 0; + const copy = values.slice(); + copy.push(true); + const value = copy[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), + ReplayCase( + name = "any copied payload moves through two shifts", + parameters = "values: any[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "any unshift preserves unread tail and pop kind", + parameters = "values: any[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const alias = values; + values.unshift(true); + const tail = alias.pop(); + if (alias[0] !== true || alias.length !== 1) return -1; + if (tail === 42) return 1; + if (tail === false) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "any reverse permutes payloads and kinds", + parameters = "values: any[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + copy.reverse(); + if (copy[0] === 42 && copy[1] === true) return 1; + if (copy[0] === false && copy[1] === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "any fill overrides input kind selectors", + parameters = "values: any[], index: number", + maxResult = 4, + body = """ + if (values.length !== 2) return 0; + const i = Math.floor(index); + if (i < 0 || i > 1) return 0; + values.fill(true, 1, 2); + const value = values[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), + ReplayCase( + name = "any concat copies unread input arrays", + parameters = "left: any[], right: any[]", + maxResult = 3, + body = """ + if (left.length !== 1 || right.length !== 1) return 0; + const copy = left.concat(right); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "any concat wraps scalar primitives", + parameters = "values: any[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const copy = values.concat(true); + if (copy[1] !== true) return -1; + if (copy[0] === 42) return 1; + if (copy[0] === false) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "any empty pop returns undefined and retains length", + parameters = "values: any[]", + maxResult = 1, + body = """ + if (values.length !== 0) return 0; + const result = values.pop(); + return result === undefined && values.length === 0 ? 1 : -1; + """.trimIndent(), + ), + ReplayCase( + name = "unknown slice() retains all runtime kinds", + parameters = "values: unknown[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ReplayCase( + name = "unknown slice().reverse() retains all runtime kinds", + parameters = "values: unknown[]", + maxResult = 6, + body = """ + if (values.length !== 1) return 0; + const copy = values.slice().reverse(); + const value = copy[0]; + if (value === 42) return 1; + if (value === true) return 2; + if (value === false) return 3; + if (value === null) return 4; + if (value === undefined) return 5; + return 6; + """.trimIndent(), + ), + ReplayCase( + name = "unknown sliced input mixed with appended wrapper", + parameters = "values: unknown[], index: number", + maxResult = 4, + body = """ + if (values.length !== 1) return 0; + const i = Math.floor(index); + if (i < 0 || i > 1) return 0; + const copy = values.slice(); + copy.push(true); + const value = copy[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), + ReplayCase( + name = "unknown copied payload moves through two shifts", + parameters = "values: unknown[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "unknown unshift preserves unread tail and pop kind", + parameters = "values: unknown[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const alias = values; + values.unshift(true); + const tail = alias.pop(); + if (alias[0] !== true || alias.length !== 1) return -1; + if (tail === 42) return 1; + if (tail === false) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "unknown reverse permutes payloads and kinds", + parameters = "values: unknown[]", + maxResult = 3, + body = """ + if (values.length !== 2) return 0; + const copy = values.slice(); + copy.reverse(); + if (copy[0] === 42 && copy[1] === true) return 1; + if (copy[0] === false && copy[1] === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "unknown fill overrides input kind selectors", + parameters = "values: unknown[], index: number", + maxResult = 4, + body = """ + if (values.length !== 2) return 0; + const i = Math.floor(index); + if (i < 0 || i > 1) return 0; + values.fill(true, 1, 2); + const value = values[i]; + if (i === 1 && value === true) return 1; + if (i === 0 && value === 42) return 2; + if (i === 0 && value === false) return 3; + return 4; + """.trimIndent(), + ), + ReplayCase( + name = "unknown concat copies unread input arrays", + parameters = "left: unknown[], right: unknown[]", + maxResult = 3, + body = """ + if (left.length !== 1 || right.length !== 1) return 0; + const copy = left.concat(right); + const first = copy.shift(); + const second = copy.shift(); + if (first === 42 && second === true) return 1; + if (first === false && second === 17) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "unknown concat wraps scalar primitives", + parameters = "values: unknown[]", + maxResult = 3, + body = """ + if (values.length !== 1) return 0; + const copy = values.concat(true); + if (copy[1] !== true) return -1; + if (copy[0] === 42) return 1; + if (copy[0] === false) return 2; + return 3; + """.trimIndent(), + ), + ReplayCase( + name = "unknown empty pop returns undefined and retains length", + parameters = "values: unknown[]", + maxResult = 1, + body = """ + if (values.length !== 0) return 0; + const result = values.pop(); + return result === undefined && values.length === 0 ? 1 : -1; + """.trimIndent(), + ), + ) + private fun pairCases(): List = buildList { val literals = listOf("42", "true", "false", "null", "undefined", "'left'") for (type in listOf("any", "unknown")) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index b6426bbbc..4abb82bb0 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -255,7 +255,7 @@ class TsUnknownCallDispatcherTest { @Test fun `TsOptions configures one fallback without profiles`() { assertEquals(TsResidualCallPolicy.STOP_PATH, TsOptions().unknownCallFallback) - assertNull(TsOptions().enabledUnknownCallModelIds) + assertEquals(TsUnknownCallModelSelection.All, TsOptions().unknownCallModelSelection) assertFalse(reachesReturn("declaredMethodWithoutBodyContinues")) assertTrue( diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index cde61a23c..6572e4d68 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -1,13 +1,23 @@ package org.usvm.machine.call +import io.mockk.mockk +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel import org.usvm.machine.state.TsState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertNotEquals import kotlin.test.assertTrue class TsUnknownCallModelCatalogTest { + private val callSite = mockk() + @Test fun `model IDs and target names must be non blank`() { assertFailsWith { @@ -32,7 +42,7 @@ class TsUnknownCallModelCatalogTest { ) } - assertEquals("Duplicate semantic model IDs: duplicate", error.message) + assertEquals("Duplicate semantic model ID: duplicate", error.message) } @Test @@ -58,7 +68,7 @@ class TsUnknownCallModelCatalogTest { val error = assertFailsWith { TsUnknownCallModelCatalog( models = listOf(model(id = "known")), - enabledModelIds = setOf("missing"), + selection = TsUnknownCallModelSelection.Only(setOf("missing")), ) } @@ -87,7 +97,7 @@ class TsUnknownCallModelCatalogTest { model(id = "a", methodName = "first"), model(id = "b", methodName = "second"), ) - val onlyA = TsUnknownCallModelCatalog(models, enabledModelIds = mutableIds) + val onlyA = TsUnknownCallModelCatalog(models, selection = TsUnknownCallModelSelection.Only(mutableIds)) mutableIds += "b" val both = TsUnknownCallModelCatalog(models) @@ -96,15 +106,82 @@ class TsUnknownCallModelCatalogTest { assertTrue(onlyA.fingerprint.matches(Regex("[0-9a-f]{64}"))) } + @Test + fun `class and reason wildcards reject exactly overlapping targets in either ID order`() { + val reasons = listOf(null) + TsUnknownCallFailureReason.entries + val classes = listOf(null, "A", "B") + for (leftReason in reasons) for (rightReason in reasons) { + for (leftClass in classes) for (rightClass in classes) { + val left = model(id = "a", methodName = "method", failureReason = leftReason, className = leftClass) + val right = model(id = "b", methodName = "method", failureReason = rightReason, className = rightClass) + val overlaps = (leftReason == null || rightReason == null || leftReason == rightReason) && + (leftClass == null || rightClass == null || leftClass == rightClass) + + if (overlaps) { + assertFailsWith { TsUnknownCallModelCatalog(listOf(left, right)) } + assertFailsWith { + TsUnknownCallModelCatalog(listOf( + model(id = "b", methodName = "method", failureReason = leftReason, className = leftClass), + model(id = "a", methodName = "method", failureReason = rightReason, className = rightClass), + )) + } + } else { + val catalog = TsUnknownCallModelCatalog(listOf(left, right)) + for (reason in TsUnknownCallFailureReason.entries) for (klass in listOf("A", "B", "C")) { + val expected = listOf(left, right).singleOrNull { + (it.target.failureReason == null || it.target.failureReason == reason) && + (it.target.enclosingClassName == null || it.target.enclosingClassName == klass) + } + assertSame(expected, catalog.select(call(klass, reason))) + } + } + } + } + } + + @Test + fun `built in models are discovered once and an explicit empty selection disables all`() { + val catalog = TsBuiltInUnknownCallModels.catalog() + + assertEquals(listOf(TsArrayShiftIntrinsicModel.MODEL_ID), catalog.modelIds) + assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) + assertTrue(TsBuiltInUnknownCallModels.catalog(TsUnknownCallModelSelection.Only(emptySet())).modelIds.isEmpty()) + } + + @Test + fun `fingerprints preserve ID boundaries and no match remains distinct from ambiguity`() { + val left = TsUnknownCallModelCatalog(listOf(model(id = "ab"), model(id = "c"))) + val right = TsUnknownCallModelCatalog(listOf(model(id = "a"), model(id = "bc"))) + + assertNotEquals(left.fingerprint, right.fingerprint) + assertNull(left.select(call(className = "A", reason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION))) + } + + private fun call(className: String, reason: TsUnknownCallFailureReason) = TsUnknownCall( + callee = EtsMethodSignature( + enclosingClass = EtsClassSignature.UNKNOWN.copy(name = className), + name = "method", + parameters = emptyList(), + returnType = EtsUnknownType, + ), + receiver = null, + arguments = emptyList(), + resultType = EtsUnknownType, + callSite = callSite, + failureReason = reason, + ) + private fun model( id: String, methodName: String = "target-$id", failureReason: TsUnknownCallFailureReason? = null, + className: String? = null, ): TsUnknownCallModel = FakeModel( id = id, target = TsUnknownCallTarget( methodName = methodName, failureReason = failureReason, + enclosingClassName = className, ), ) From 45431c043c4796c1fc1267c8996c064870d73144 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 17:00:04 +0300 Subject: [PATCH 15/18] Preserve typed removal results and validate array storage regressions --- .../src/main/kotlin/org/usvm/api/MemoryApi.kt | 4 +- .../usvm/collection/array/ArrayRegionApi.kt | 4 +- .../machine/call/TsUnknownCallModelCatalog.kt | 3 +- .../usvm/machine/expr/CallApproximations.kt | 63 +++-- .../machine/call/TsArrayShiftReplayTest.kt | 216 ++++-------------- .../call/TsUnknownCallModelCatalogTest.kt | 60 ++--- 6 files changed, 126 insertions(+), 224 deletions(-) diff --git a/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt b/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt index 9543c1ab8..3053652ad 100644 --- a/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt +++ b/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt @@ -93,14 +93,14 @@ fun UWritableMemory.mems memsetInternal(ref, type, sort, sizeSort, contents) } -fun UWritableMemory.initializeArrayLength( +fun UWritableMemory<*>.initializeArrayLength( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sizeSort: USizeSort, count: UExpr, ) = initializeArrayLengthInternal(arrayHeapRef, type, sizeSort, count) -fun UWritableMemory.initializeArray( +fun UWritableMemory<*>.initializeArray( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sort: Sort, diff --git a/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt b/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt index 3d0b4f99b..67349ef76 100644 --- a/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt +++ b/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt @@ -32,7 +32,7 @@ internal fun UWritableMemory<*>.mem setRegion(regionId, newRegion) } -internal fun UWritableMemory.initializeArray( +internal fun UWritableMemory<*>.initializeArray( arrayHeapRef: UConcreteHeapRef, type: ArrayType, elementSort: Sort, @@ -62,7 +62,7 @@ internal fun UWritableMemory UWritableMemory.initializeArrayLength( +internal fun UWritableMemory<*>.initializeArrayLength( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sizeSort: USizeSort, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt index 9a0694468..ee825bced 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt @@ -4,6 +4,7 @@ import org.usvm.machine.state.TsState import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.security.MessageDigest +import java.util.Collections private const val BYTE_MASK = 0xff @@ -33,7 +34,7 @@ class TsUnknownCallModelCatalog( } }.sortedBy(TsUnknownCallModel::id) - modelIds = selectedModels.map(TsUnknownCallModel::id) + modelIds = Collections.unmodifiableList(selectedModels.map(TsUnknownCallModel::id)) index = indexModels(selectedModels) fingerprint = computeFingerprint(modelIds) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index b912210ab..b00851e8c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -18,7 +18,7 @@ import org.usvm.api.initializeArray import org.usvm.api.makeSymbolicPrimitive import org.usvm.api.memcpy import org.usvm.api.readArrayIndex -import org.usvm.api.writeArrayIndex +import org.usvm.getIntValue import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsSizeSort import org.usvm.machine.TsVirtualMethodCallStmt @@ -29,7 +29,8 @@ import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue -import org.usvm.machine.types.iteWriteIntoFakeObject +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.newStmt import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort @@ -120,7 +121,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.pop() method calls if (expr.callee.name == "pop") { - return from(handleArrayPop(expr, instanceType, elementSort, array)) + return from(handleArrayPop(stmt, instanceType, elementSort, array)) } // Handle `Array.fill() method calls @@ -179,7 +180,7 @@ private fun TsExprResolver.handleArrayShiftCall( ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { - return from(handleArrayShift(stmt.call, instanceType, elementSort, stmt.instance.asExpr(ctx.addressSort))) + return from(handleArrayShift(stmt, instanceType, elementSort, stmt.instance.asExpr(ctx.addressSort))) } dispatcher.dispatch( @@ -396,43 +397,51 @@ private fun TsExprResolver.handleArrayPush( * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.pop */ private fun TsExprResolver.handleArrayPop( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.isEmpty()) { - "Array.pop() should have no arguments, but got ${expr.args.size}" + check(stmt.args.isEmpty()) { + "Array.pop() should have no arguments, but got ${stmt.args.size}" } - removeArrayElement(array, arrayType, elementSort, first = false) + removeArrayElement(stmt, array, arrayType, elementSort, first = false) } private fun TsExprResolver.removeArrayElement( + stmt: TsVirtualMethodCallStmt, array: UHeapRef, arrayType: EtsArrayType, elementSort: USort, first: Boolean, -): UExpr<*> = with(ctx) { +): UExpr<*>? = with(ctx) { + val lengthLValue = mkArrayLengthLValue(array, arrayType) + val length = scope.calcOnState { memory.read(lengthLValue) } + val nonEmpty = mkNot(mkEq(length, mkBv(0))) + scope.fork( + nonEmpty, + blockOnFalseState = { + methodResult = TsMethodResult.Success.MockedCall(mkUndefinedValue(), stmt.call.callee) + newStmt(stmt.returnSite) + }, + ) ?: return null + scope.calcOnState { - val lengthLValue = mkArrayLengthLValue(array, arrayType) - val length = memory.read(lengthLValue) - val empty = mkEq(length, mkBv(0)) - val newLength = mkIte(empty, mkBv(0), mkBvSubExpr(length, mkBv(1))) + val newLength = mkBvSubExpr(length, mkBv(1)) val index = if (first) mkBv(0) else newLength val removed = if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { mkFakeValue(scope, readUnresolvedArrayElement(memory, array, index)) } else { memory.read(mkArrayIndexLValue(elementSort, array, index, arrayType)) } - val result = iteWriteIntoFakeObject(scope, empty, mkUndefinedValue(), removed) if (first) { copyArrayElements(array, array, arrayType, fromSrc = mkBv(1), fromDst = mkBv(0), length = newLength) } memory.write(lengthLValue, newLength, guard = trueExpr) - result + removed } } @@ -527,7 +536,9 @@ private fun TsExprResolver.handleArrayFill( // Calculate the length of the range to fill val fillLength = mkBvSubExpr(endBv, startBv) - // TODO: check that `fillLength` is less than `ARRAY_FILL_MAX_SIZE` + // Concrete ranges need no unused entries in the temporary array. + val tempSize = getIntValue(fillLength)?.coerceIn(0, ARRAY_FILL_MAX_SIZE) ?: ARRAY_FILL_MAX_SIZE + // TODO: check that symbolic `fillLength` is less than `ARRAY_FILL_MAX_SIZE`. // Allocate a temporary array to hold the filled values val tempArray = memory.allocConcrete(descriptor) @@ -538,7 +549,7 @@ private fun TsExprResolver.handleArrayFill( descriptor, elementSort, sizeSort, - (0 until ARRAY_FILL_MAX_SIZE).asSequence().map { value.asExpr(elementSort) } + (0 until tempSize).asSequence().map { value.asExpr(elementSort) } ) // Copy the filled values to the specified range in the original array @@ -582,16 +593,16 @@ private const val ARRAY_FILL_MAX_SIZE = 10_000 * https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.shift */ private fun TsExprResolver.handleArrayShift( - expr: EtsInstanceCallExpr, + stmt: TsVirtualMethodCallStmt, arrayType: EtsArrayType, elementSort: USort, array: UHeapRef, ): UExpr<*>? = with(ctx) { - check(expr.args.isEmpty()) { - "Array.shift() should have no arguments, but got ${expr.args.size}" + check(stmt.args.isEmpty()) { + "Array.shift() should have no arguments, but got ${stmt.args.size}" } - removeArrayElement(array, arrayType, elementSort, first = true) + removeArrayElement(stmt, array, arrayType, elementSort, first = true) } /** @@ -881,8 +892,10 @@ private fun TsExprResolver.handleArrayConcat( ) totalLength = mkBvAddExpr(totalLength, length) } else { + val newLength = mkBvAddExpr(totalLength, mkBv(1)) + memory.write(mkArrayLengthLValue(resultArray, arrayType), newLength, guard = trueExpr) assignToArrayIndex(scope, resultArray, totalLength, arg, arrayType) ?: return@calcOnState null - totalLength = mkBvAddExpr(totalLength, mkBv(1)) + totalLength = newLength } } memory.write(mkArrayLengthLValue(resultArray, arrayType), totalLength, guard = trueExpr) @@ -1038,11 +1051,11 @@ private fun TsExprResolver.handleArrayReverse( val length = memory.read(lengthLValue) forEachArrayStorageRegion(arrayType) { region, sort -> - for (index in 0 until ARRAY_REVERSE_MAX_SIZE) { + val contents = (0 until ARRAY_REVERSE_MAX_SIZE).asSequence().map { index -> val reversedIndex = mkBvSubExpr(mkBvSubExpr(length, mkBv(1)), index.toBv()) - val value = memory.readArrayIndex(array, reversedIndex, region, sort) - memory.writeArrayIndex(reversedArray, index.toBv(), region, sort, value, guard = trueExpr) + memory.readArrayIndex(array, reversedIndex, region, sort) } + memory.initializeArray(reversedArray, region, sort, sizeSort, contents) } copyArrayElements( arrayType = arrayType, diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 0fd33f8a2..31b5c8d60 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -44,7 +44,7 @@ class TsArrayShiftReplayTest { } assertTrue(tests.isNotEmpty()) - val results = tests.map { assertIs(it.returnValue).number }.toSet() + val results = tests.map { assertIs(it.returnValue, message = it.toString()).number }.toSet() assertEquals((0..case.maxResult).map { it.toDouble() }.toSet(), results) val script = buildString { @@ -131,159 +131,33 @@ class TsArrayShiftReplayTest { ) ) } + add( + ReplayCase( + name = "typed pop payload remains usable as number and array index", + parameters = "values: number[]", + maxResult = 2, + body = """ + if (values.length !== 1) return 0; + const n = values.pop(); + if (n !== 1) return 1; + const target = [10, 20]; + return Math.floor(n) === 1 && target[n] === 20 ? 2 : -1; + """.trimIndent(), + ) + ) addAll(storageOperationCases()) addAll(pairCases()) addAll(typedCases()) } - private fun storageOperationCases(): List = listOf( - ReplayCase( - name = "any slice() retains all runtime kinds", - parameters = "values: any[]", - maxResult = 6, - body = """ - if (values.length !== 1) return 0; - const copy = values.slice(); - const value = copy[0]; - if (value === 42) return 1; - if (value === true) return 2; - if (value === false) return 3; - if (value === null) return 4; - if (value === undefined) return 5; - return 6; - """.trimIndent(), - ), - ReplayCase( - name = "any slice().reverse() retains all runtime kinds", - parameters = "values: any[]", - maxResult = 6, - body = """ - if (values.length !== 1) return 0; - const copy = values.slice().reverse(); - const value = copy[0]; - if (value === 42) return 1; - if (value === true) return 2; - if (value === false) return 3; - if (value === null) return 4; - if (value === undefined) return 5; - return 6; - """.trimIndent(), - ), - ReplayCase( - name = "any sliced input mixed with appended wrapper", - parameters = "values: any[], index: number", - maxResult = 4, - body = """ - if (values.length !== 1) return 0; - const i = Math.floor(index); - if (i < 0 || i > 1) return 0; - const copy = values.slice(); - copy.push(true); - const value = copy[i]; - if (i === 1 && value === true) return 1; - if (i === 0 && value === 42) return 2; - if (i === 0 && value === false) return 3; - return 4; - """.trimIndent(), - ), - ReplayCase( - name = "any copied payload moves through two shifts", - parameters = "values: any[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - const copy = values.slice(); - const first = copy.shift(); - const second = copy.shift(); - if (first === 42 && second === true) return 1; - if (first === false && second === 17) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "any unshift preserves unread tail and pop kind", - parameters = "values: any[]", - maxResult = 3, - body = """ - if (values.length !== 1) return 0; - const alias = values; - values.unshift(true); - const tail = alias.pop(); - if (alias[0] !== true || alias.length !== 1) return -1; - if (tail === 42) return 1; - if (tail === false) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "any reverse permutes payloads and kinds", - parameters = "values: any[]", - maxResult = 3, - body = """ - if (values.length !== 2) return 0; - const copy = values.slice(); - copy.reverse(); - if (copy[0] === 42 && copy[1] === true) return 1; - if (copy[0] === false && copy[1] === 17) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "any fill overrides input kind selectors", - parameters = "values: any[], index: number", - maxResult = 4, - body = """ - if (values.length !== 2) return 0; - const i = Math.floor(index); - if (i < 0 || i > 1) return 0; - values.fill(true, 1, 2); - const value = values[i]; - if (i === 1 && value === true) return 1; - if (i === 0 && value === 42) return 2; - if (i === 0 && value === false) return 3; - return 4; - """.trimIndent(), - ), - ReplayCase( - name = "any concat copies unread input arrays", - parameters = "left: any[], right: any[]", - maxResult = 3, - body = """ - if (left.length !== 1 || right.length !== 1) return 0; - const copy = left.concat(right); - const first = copy.shift(); - const second = copy.shift(); - if (first === 42 && second === true) return 1; - if (first === false && second === 17) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "any concat wraps scalar primitives", - parameters = "values: any[]", - maxResult = 3, - body = """ - if (values.length !== 1) return 0; - const copy = values.concat(true); - if (copy[1] !== true) return -1; - if (copy[0] === 42) return 1; - if (copy[0] === false) return 2; - return 3; - """.trimIndent(), - ), - ReplayCase( - name = "any empty pop returns undefined and retains length", - parameters = "values: any[]", - maxResult = 1, - body = """ - if (values.length !== 0) return 0; - const result = values.pop(); - return result === undefined && values.length === 0 ? 1 : -1; - """.trimIndent(), - ), + private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> + copyCases(type) + mutationCases(type) + concatCases(type) + } + + private fun copyCases(type: String): List = listOf( ReplayCase( - name = "unknown slice() retains all runtime kinds", - parameters = "values: unknown[]", + name = "$type slice() retains all runtime kinds", + parameters = "values: $type[]", maxResult = 6, body = """ if (values.length !== 1) return 0; @@ -298,8 +172,8 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown slice().reverse() retains all runtime kinds", - parameters = "values: unknown[]", + name = "$type slice().reverse() retains all runtime kinds", + parameters = "values: $type[]", maxResult = 6, body = """ if (values.length !== 1) return 0; @@ -314,13 +188,13 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown sliced input mixed with appended wrapper", - parameters = "values: unknown[], index: number", + name = "$type sliced input mixed with appended wrapper", + parameters = "values: $type[], index: number", maxResult = 4, body = """ if (values.length !== 1) return 0; const i = Math.floor(index); - if (i < 0 || i > 1) return 0; + if (!(i >= 0 && i <= 1)) return 0; const copy = values.slice(); copy.push(true); const value = copy[i]; @@ -331,8 +205,8 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown copied payload moves through two shifts", - parameters = "values: unknown[]", + name = "$type copied payload moves through two shifts", + parameters = "values: $type[]", maxResult = 3, body = """ if (values.length !== 2) return 0; @@ -344,9 +218,12 @@ class TsArrayShiftReplayTest { return 3; """.trimIndent(), ), + ) + + private fun mutationCases(type: String): List = listOf( ReplayCase( - name = "unknown unshift preserves unread tail and pop kind", - parameters = "values: unknown[]", + name = "$type unshift preserves unread tail and pop kind", + parameters = "values: $type[]", maxResult = 3, body = """ if (values.length !== 1) return 0; @@ -360,8 +237,8 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown reverse permutes payloads and kinds", - parameters = "values: unknown[]", + name = "$type reverse permutes payloads and kinds", + parameters = "values: $type[]", maxResult = 3, body = """ if (values.length !== 2) return 0; @@ -373,13 +250,13 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown fill overrides input kind selectors", - parameters = "values: unknown[], index: number", + name = "$type fill overrides input kind selectors", + parameters = "values: $type[], index: number", maxResult = 4, body = """ if (values.length !== 2) return 0; const i = Math.floor(index); - if (i < 0 || i > 1) return 0; + if (!(i >= 0 && i <= 1)) return 0; values.fill(true, 1, 2); const value = values[i]; if (i === 1 && value === true) return 1; @@ -388,9 +265,12 @@ class TsArrayShiftReplayTest { return 4; """.trimIndent(), ), + ) + + private fun concatCases(type: String): List = listOf( ReplayCase( - name = "unknown concat copies unread input arrays", - parameters = "left: unknown[], right: unknown[]", + name = "$type concat copies unread input arrays", + parameters = "left: $type[], right: $type[]", maxResult = 3, body = """ if (left.length !== 1 || right.length !== 1) return 0; @@ -403,8 +283,8 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown concat wraps scalar primitives", - parameters = "values: unknown[]", + name = "$type concat wraps scalar primitives", + parameters = "values: $type[]", maxResult = 3, body = """ if (values.length !== 1) return 0; @@ -416,8 +296,8 @@ class TsArrayShiftReplayTest { """.trimIndent(), ), ReplayCase( - name = "unknown empty pop returns undefined and retains length", - parameters = "values: unknown[]", + name = "$type empty pop returns undefined and retains length", + parameters = "values: $type[]", maxResult = 1, body = """ if (values.length !== 0) return 0; diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index 6572e4d68..658e1e0bd 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -10,9 +10,9 @@ import org.usvm.machine.state.TsState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertSame -import kotlin.test.assertNotEquals import kotlin.test.assertTrue class TsUnknownCallModelCatalogTest { @@ -109,33 +109,39 @@ class TsUnknownCallModelCatalogTest { @Test fun `class and reason wildcards reject exactly overlapping targets in either ID order`() { val reasons = listOf(null) + TsUnknownCallFailureReason.entries - val classes = listOf(null, "A", "B") - for (leftReason in reasons) for (rightReason in reasons) { - for (leftClass in classes) for (rightClass in classes) { - val left = model(id = "a", methodName = "method", failureReason = leftReason, className = leftClass) - val right = model(id = "b", methodName = "method", failureReason = rightReason, className = rightClass) - val overlaps = (leftReason == null || rightReason == null || leftReason == rightReason) && - (leftClass == null || rightClass == null || leftClass == rightClass) - - if (overlaps) { - assertFailsWith { TsUnknownCallModelCatalog(listOf(left, right)) } - assertFailsWith { - TsUnknownCallModelCatalog(listOf( - model(id = "b", methodName = "method", failureReason = leftReason, className = leftClass), - model(id = "a", methodName = "method", failureReason = rightReason, className = rightClass), - )) - } - } else { - val catalog = TsUnknownCallModelCatalog(listOf(left, right)) - for (reason in TsUnknownCallFailureReason.entries) for (klass in listOf("A", "B", "C")) { - val expected = listOf(left, right).singleOrNull { - (it.target.failureReason == null || it.target.failureReason == reason) && - (it.target.enclosingClassName == null || it.target.enclosingClassName == klass) - } - assertSame(expected, catalog.select(call(klass, reason))) - } + val targets = reasons.flatMap { reason -> + listOf(null, "A", "B").map { klass -> + TsUnknownCallTarget(methodName = "method", failureReason = reason, enclosingClassName = klass) + } + } + for (left in targets) for (right in targets) { + val reasonOverlaps = left.failureReason == null || right.failureReason == null || + left.failureReason == right.failureReason + val classOverlaps = left.enclosingClassName == null || right.enclosingClassName == null || + left.enclosingClassName == right.enclosingClassName + val models = listOf(FakeModel(id = "a", target = left), FakeModel(id = "b", target = right)) + + if (reasonOverlaps && classOverlaps) { + assertFailsWith { TsUnknownCallModelCatalog(models) } + assertFailsWith { + TsUnknownCallModelCatalog( + listOf(FakeModel(id = "b", target = left), FakeModel(id = "a", target = right)) + ) } + } else { + assertSelections(models) + } + } + } + + private fun assertSelections(models: List) { + val catalog = TsUnknownCallModelCatalog(models) + for (reason in TsUnknownCallFailureReason.entries) for (klass in listOf("A", "B", "C")) { + val expected = models.singleOrNull { + (it.target.failureReason == null || it.target.failureReason == reason) && + (it.target.enclosingClassName == null || it.target.enclosingClassName == klass) } + assertSame(expected, catalog.select(call(klass, reason))) } } @@ -145,6 +151,8 @@ class TsUnknownCallModelCatalogTest { assertEquals(listOf(TsArrayShiftIntrinsicModel.MODEL_ID), catalog.modelIds) assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) + assertFailsWith { (catalog.modelIds as MutableList).clear() } + assertEquals(listOf(TsArrayShiftIntrinsicModel.MODEL_ID), TsBuiltInUnknownCallModels.catalog().modelIds) assertTrue(TsBuiltInUnknownCallModels.catalog(TsUnknownCallModelSelection.Only(emptySet())).modelIds.isEmpty()) } From 49c60cbced14e615cea34b6661c83cd078f7d6fa Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 17:05:59 +0300 Subject: [PATCH 16/18] Format replay failure diagnostics --- .../kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 31b5c8d60..2a844e606 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -44,7 +44,9 @@ class TsArrayShiftReplayTest { } assertTrue(tests.isNotEmpty()) - val results = tests.map { assertIs(it.returnValue, message = it.toString()).number }.toSet() + val results = tests.map { + assertIs(it.returnValue, message = it.toString()).number + }.toSet() assertEquals((0..case.maxResult).map { it.toDouble() }.toSet(), results) val script = buildString { From e72d8d0bbd9923b7ea9b1d2eecbd07f4b1fae3dc Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 17:14:21 +0300 Subject: [PATCH 17/18] Keep array initialization type bounds in core --- .../src/main/kotlin/org/usvm/api/MemoryApi.kt | 4 +- .../usvm/collection/array/ArrayRegionApi.kt | 4 +- .../usvm/machine/expr/CallApproximations.kt | 21 +++++++--- .../main/kotlin/org/usvm/util/ArrayStorage.kt | 41 +++++++++++++++++-- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt b/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt index 3053652ad..9543c1ab8 100644 --- a/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt +++ b/usvm-core/src/main/kotlin/org/usvm/api/MemoryApi.kt @@ -93,14 +93,14 @@ fun UWritableMemory.mems memsetInternal(ref, type, sort, sizeSort, contents) } -fun UWritableMemory<*>.initializeArrayLength( +fun UWritableMemory.initializeArrayLength( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sizeSort: USizeSort, count: UExpr, ) = initializeArrayLengthInternal(arrayHeapRef, type, sizeSort, count) -fun UWritableMemory<*>.initializeArray( +fun UWritableMemory.initializeArray( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sort: Sort, diff --git a/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt b/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt index 67349ef76..3d0b4f99b 100644 --- a/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt +++ b/usvm-core/src/main/kotlin/org/usvm/collection/array/ArrayRegionApi.kt @@ -32,7 +32,7 @@ internal fun UWritableMemory<*>.mem setRegion(regionId, newRegion) } -internal fun UWritableMemory<*>.initializeArray( +internal fun UWritableMemory.initializeArray( arrayHeapRef: UConcreteHeapRef, type: ArrayType, elementSort: Sort, @@ -62,7 +62,7 @@ internal fun UWritableMemory<*>.ini setRegion(regionId, newRegion) } -internal fun UWritableMemory<*>.initializeArrayLength( +internal fun UWritableMemory.initializeArrayLength( arrayHeapRef: UConcreteHeapRef, type: ArrayType, sizeSort: USizeSort, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index b00851e8c..841a7fc2b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -31,12 +31,14 @@ import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.newStmt +import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.copyArrayElements -import org.usvm.util.forEachArrayStorageRegion +import org.usvm.util.forEachArrayPayloadRegion +import org.usvm.util.initializeArrayKind import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods @@ -1050,13 +1052,22 @@ private fun TsExprResolver.handleArrayReverse( val lengthLValue = mkArrayLengthLValue(array, arrayType) val length = memory.read(lengthLValue) - forEachArrayStorageRegion(arrayType) { region, sort -> - val contents = (0 until ARRAY_REVERSE_MAX_SIZE).asSequence().map { index -> - val reversedIndex = mkBvSubExpr(mkBvSubExpr(length, mkBv(1)), index.toBv()) - memory.readArrayIndex(array, reversedIndex, region, sort) + val reversedIndices = (0 until ARRAY_REVERSE_MAX_SIZE).map { index -> + mkBvSubExpr(mkBvSubExpr(length, mkBv(1)), index.toBv()) + } + forEachArrayPayloadRegion(arrayType) { region, sort -> + val contents = reversedIndices.asSequence().map { index -> + memory.readArrayIndex(array, index, region, sort) } memory.initializeArray(reversedArray, region, sort, sizeSort, contents) } + if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { + TsUnresolvedArrayKind.entries.forEach { kind -> + val contents = reversedIndices.map { index -> memory.readArrayIndex(array, index, kind, boolSort) } + initializeArrayKind(reversedArray, kind, contents) + } + } + copyArrayElements( arrayType = arrayType, srcRef = reversedArray, diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt index a29d3f41b..3cd941ce4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -3,19 +3,25 @@ package org.usvm.util import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsType import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr +import org.usvm.UBoolSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort import org.usvm.api.memcpy +import org.usvm.collection.array.UArrayRegion +import org.usvm.collection.array.UArrayRegionId import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.state.TsState import org.usvm.machine.types.TsUnresolvedArrayKind -/** Enumerates storage channels, independently of whether an array was allocated or came from the input. */ -internal inline fun TsContext.forEachArrayStorageRegion(arrayType: EtsArrayType, action: (Any, USort) -> Unit) { +/** Enumerates payload regions independently of whether an array was allocated or came from the input. */ +internal inline fun TsContext.forEachArrayPayloadRegion(arrayType: EtsArrayType, action: (EtsType, USort) -> Unit) { val elementSort = typeToSort(arrayType.elementType) if (elementSort !is TsUnresolvedSort) { action(arrayDescriptorOf(arrayType), elementSort) @@ -25,7 +31,6 @@ internal inline fun TsContext.forEachArrayStorageRegion(arrayType: EtsArrayType, action(EtsArrayType(EtsBooleanType, dimensions = 1), boolSort) action(EtsArrayType(EtsNumberType, dimensions = 1), fp64Sort) action(EtsArrayType(EtsUnknownType, dimensions = 1), addressSort) - TsUnresolvedArrayKind.entries.forEach { action(it, boolSort) } } internal fun TsState.copyArrayElements( @@ -36,7 +41,35 @@ internal fun TsState.copyArrayElements( fromDst: UExpr, length: UExpr, ) { - ctx.forEachArrayStorageRegion(arrayType) { region, sort -> + ctx.forEachArrayPayloadRegion(arrayType) { region, sort -> memory.memcpy(srcRef, dstRef, region, sort, fromSrc, fromDst, length) } + if (ctx.typeToSort(arrayType.elementType) is TsUnresolvedSort) { + TsUnresolvedArrayKind.entries.forEach { kind -> + memory.memcpy(srcRef, dstRef, kind, ctx.boolSort, fromSrc, fromDst, length) + } + } +} + +/** Initializes a selector region; selectors have no separate array length or heap type. */ +internal fun TsState.initializeArrayKind( + array: UConcreteHeapRef, + kind: TsUnresolvedArrayKind, + values: List, +) { + val regionId = UArrayRegionId(kind, ctx.boolSort) + val region = memory.getRegion(regionId) + check(region is UArrayRegion) { + "Cannot initialize array kind in $region" + } + + val initialized = region.initializeAllocatedArray( + address = array.address, + arrayType = kind, + sort = ctx.boolSort, + content = values, + operationGuard = ctx.trueExpr, + ownership = memory.ownership, + ) + memory.setRegion(regionId, initialized) } From f4ec3ba4ef7225e1303d479e2d880e2ca23c9048 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 18 Sep 2026 18:57:24 +0300 Subject: [PATCH 18/18] fix(ts): warn before pruning unknown-call paths --- .../call/TsUnknownCallModelDispatcher.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index 3ede022c8..474494651 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -53,6 +53,10 @@ class TsModelUnknownCallDispatcher( when (fallback) { TsResidualCallPolicy.STOP_PATH -> { val falseExpr = scope.calcOnState { ctx.falseExpr } + logger.warn { + "Stopping path for unknown call ${call.callee} at ${call.callSite.location}: " + + "reason=${call.failureReason}" + } scope.assert(falseExpr) } @@ -114,6 +118,12 @@ class TsModelUnknownCallDispatcher( } } + if (stoppedResidualIsSatisfiable) { + logger.warn { + "Stopping residual path for unknown call ${call.callee} at ${call.callSite.location}: " + + "reason=${call.failureReason}" + } + } scope.forkMulti(guardedStateChanges) if (modelApplied) { @@ -132,9 +142,11 @@ class TsModelUnknownCallDispatcher( } private fun reportFallback(call: TsUnknownCall) { - logger.debug { - "Unknown call ${call.callee} at ${call.callSite.location}: " + - "fallback=$fallback, reason=${call.failureReason}" + if (fallback == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { + logger.debug { + "Unknown call ${call.callee} at ${call.callSite.location}: " + + "fallback=$fallback, reason=${call.failureReason}" + } } observer?.onUnknownCallSafely(event(call, TsUnknownCallDecision.ResidualFallback(fallback))) }