diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts index bcc0ba431b..3d7f8f67dd 100644 --- a/usvm-ts-calls/build.gradle.kts +++ b/usvm-ts-calls/build.gradle.kts @@ -30,6 +30,7 @@ val toolStatus = providers.exec { val generateBuildMetadata = tasks.register("generateBuildMetadata") { inputs.property("toolRevision", toolRevision) inputs.property("toolStatus", toolStatus) + inputs.property("jacodbVersion", Versions.jacodb) outputs.dir(generatedBuildMetadataDirectory) doLast { @@ -39,7 +40,11 @@ val generateBuildMetadata = tasks.register("generateBuildMetadata") { .file("org/usvm/ts/calls/build.properties") .asFile metadataFile.parentFile.mkdirs() - metadataFile.writeText("tool.revision=$buildIdentity\n", Charsets.UTF_8) + metadataFile.writeText( + "tool.revision=$buildIdentity\n" + + "native.frontend.revision=bundled:${Versions.jacodb}\n", + Charsets.UTF_8, + ) } } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 3746582ff3..9f01065411 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -6,7 +6,9 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.usvm.PathSelectionStrategy +import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.TypeScriptEntryPoint @@ -115,6 +117,7 @@ internal enum class CallsSymbolicStatus { UNREACHED, UNREPRESENTABLE, UNSUPPORTED, + RUNTIME_LIMITATION, TIMEOUT, TOOL_ERROR, UNMAPPED, @@ -131,6 +134,8 @@ internal data class CallsSymbolicSearchRequest( val expectedNativeFrontendRevision: String, val seed: Long, val budget: Duration, + val unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + val runtimeLimitationEventSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)? = null, ) internal data class CallsSymbolicSearchResult( @@ -175,6 +180,7 @@ internal data class CallsRunTargetIdentity( val functionId: String, val targetId: String, val siteId: String, + val targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, ) @Serializable @@ -187,6 +193,7 @@ internal data class CallsTargetResult( val functionId: String, val targetId: String, val siteId: String, + val targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, val profile: CallsExperimentProfile, val seed: Long, val symbolicStatus: CallsSymbolicStatus, @@ -244,16 +251,25 @@ internal object CallsExperimentJson { } internal object CallsBuildIdentity { - val toolRevision: String by lazy { + private val properties: Properties by lazy { val properties = Properties() val resource = checkNotNull(javaClass.getResourceAsStream("/org/usvm/ts/calls/build.properties")) { "Missing calls build identity" } resource.use(properties::load) + properties + } + + val toolRevision: String by lazy { checkNotNull(properties.getProperty("tool.revision")).takeIf(String::isNotBlank) ?: error("Missing tool revision in calls build identity") } + + val nativeFrontendRevision: String by lazy { + checkNotNull(properties.getProperty("native.frontend.revision")).takeIf(String::isNotBlank) + ?: error("Missing native frontend revision in calls build identity") + } } internal class CallsExperimentRunner( @@ -297,6 +313,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, ) } } @@ -370,6 +387,7 @@ internal class CallsExperimentRunner( target = target, seed = seed, profile = profile, + appendUnknownCall = { event -> append(rawOutput, event) }, ) append(rawOutput, result) @@ -386,18 +404,29 @@ internal class CallsExperimentRunner( target: CallsSourceTarget, seed: Long, profile: CallsExperimentProfile, + appendUnknownCall: (CallsRawRecord) -> Unit, ): CallsTargetResult { + val request = CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + frozenModelIds = manifest.modelSet.ids, + expectedNativeFrontendRevision = manifest.nativeFrontendRevision, + seed = seed, + budget = manifest.perTargetBudgetMillis.milliseconds, + ) val symbolic = symbolicEngine.search( - CallsSymbolicSearchRequest( - sourceRoot = sourceRoot, - project = project, - function = function, - target = target, - profile = profile, - frozenModelIds = manifest.modelSet.ids, - expectedNativeFrontendRevision = manifest.nativeFrontendRevision, - seed = seed, - budget = manifest.perTargetBudgetMillis.milliseconds, + request.copy( + unknownCallEventSink = callsUnknownCallEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ), + runtimeLimitationEventSink = callsRuntimeLimitationEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ), ), ) val replay = symbolic.inputs?.let { inputs -> @@ -418,6 +447,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, profile = profile, seed = seed, symbolicStatus = symbolic.status, @@ -488,7 +518,8 @@ internal object CallsRawResultsReader { identity != null && result.revision == identity.revision && result.development == identity.development && - result.siteId == identity.siteId + result.siteId == identity.siteId && + result.targetMode == identity.targetMode }, ) { "Result target identity does not match metadata" } val resultKeys = results.map { result -> diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt new file mode 100644 index 0000000000..e6e5b451c5 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt @@ -0,0 +1,237 @@ +package org.usvm.ts.calls + +import org.jacodb.ets.model.EtsAddExpr +import org.jacodb.ets.model.EtsEntity +import org.jacodb.ets.model.EtsExpExpr +import org.jacodb.ets.model.EtsFieldRef +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberConstant +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsRawEntity +import org.jacodb.ets.model.EtsRawStmt +import org.jacodb.ets.model.EtsStaticFieldRef +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.STATIC_INIT_METHOD_NAME +import org.jacodb.ets.utils.getDeclaredLocals +import org.jacodb.ets.utils.getOperands +import org.usvm.machine.TsGraph + +internal data class CallsIrReadinessIssue( + val reasonCode: CallsSymbolicPreflightReasonCode, + val diagnostic: String, +) + +internal fun callsIrReadinessIssue( + method: EtsMethod, + graph: TsGraph, + source: String, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): CallsIrReadinessIssue? { + val sourceFile = method.signature.enclosingClass.file + val pending = ArrayDeque().apply { addLast(method) } + val visited = hashSetOf() + + while (pending.isNotEmpty()) { + val currentMethod = pending.removeFirst() + if (!visited.add(currentMethod)) continue + + currentMethod.irReadinessIssue( + source = source, + admittedLexicalEnvironment = admittedLexicalEnvironment, + )?.let { issue -> return issue } + + currentMethod.sameFileCallees(graph = graph, sourceFile = sourceFile) + .forEach(pending::addLast) + currentMethod.sameFileStaticInitializers(graph = graph, sourceFile = sourceFile) + .forEach(pending::addLast) + } + + return null +} + +private fun EtsMethod.sameFileCallees( + graph: TsGraph, + sourceFile: EtsFileSignature, +): Sequence = cfg.stmts.asSequence() + .flatMap { statement -> + runCatching { graph.callees(statement).toList() } + .getOrDefault(emptyList()) + .asSequence() + } + .filter { callee -> callee.signature.enclosingClass.file == sourceFile } + +private fun EtsMethod.sameFileStaticInitializers( + graph: TsGraph, + sourceFile: EtsFileSignature, +): Sequence = cfg.stmts.asSequence() + .flatMap { it.walkEntities().asSequence() } + .filterIsInstance() + .map { it.field.enclosingClass } + .filter { it.file == sourceFile } + .distinct() + .mapNotNull { signature -> + val owner = graph.cp.projectClasses.singleOrNull { it.signature == signature } ?: return@mapNotNull null + owner.methods.singleOrNull { it.name == STATIC_INIT_METHOD_NAME } + ?: owner.takeIf { it.name == DEFAULT_ARK_CLASS_NAME } + ?.methods + ?.singleOrNull { it.name == DEFAULT_ARK_METHOD_NAME } + } + +private fun EtsMethod.irReadinessIssue( + source: String, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): CallsIrReadinessIssue? { + val statements = cfg.stmts + val entitiesByStatement = statements.associateWith { statement -> statement.walkEntities() } + val entities = entitiesByStatement.values.flatten() + + if (hasUnsupportedLexicalEnvironment(entities, admittedLexicalEnvironment)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_ENVIRONMENT_UNSUPPORTED, + diagnostic = "Reachable EtsIR contains a runtime lexical environment in $name", + ) + } + + val rawNodes = buildList { + entitiesByStatement.forEach { (statement, statementEntities) -> + val sourceSnippet = statement.sourceSnippet(source) + if (statement is EtsRawStmt) { + add(RawNode(kind = statement.kind, extra = statement.extra, sourceSnippet = sourceSnippet)) + } + statementEntities.filterIsInstance().forEach { entity -> + add(RawNode(kind = entity.kind, extra = entity.extra, sourceSnippet = sourceSnippet)) + } + } + } + rawNodes.firstOrNull(RawNode::isDestructuring)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.DESTRUCTURING_UNSUPPORTED, name) + } + rawNodes.firstOrNull(RawNode::isSpread)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.SPREAD_UNSUPPORTED, name) + } + rawNodes.firstOrNull(RawNode::isRegexLiteral)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, name) + } + rawNodes.firstOrNull()?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.RAW_ENTITY_UNSUPPORTED, name) + } + + if (entities.any(::isPrototypeAccess)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.PROTOTYPE_ACCESS_UNSUPPORTED, + diagnostic = "Reachable EtsIR accesses a prototype property in $name", + ) + } + + if (entities.any { it is EtsExpExpr }) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.EXPONENTIATION_UNSUPPORTED, + diagnostic = "Reachable EtsIR contains unsupported exponentiation in $name", + ) + } + + if (entities.any(::requiresSymbolicNumberToString)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + diagnostic = "Reachable EtsIR concatenates a string with a symbolic number in $name", + ) + } + + return null +} + +private fun EtsMethod.hasUnsupportedLexicalEnvironment( + entities: List, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): Boolean { + if (getDeclaredLocals().any { local -> + val type = local.type as? EtsLexicalEnvType + type != null && type != admittedLexicalEnvironment + } + ) { + return true + } + + return entities.any { entity -> + val functionType = entity.type as? EtsFunctionType ?: return@any false + val environment = functionType.signature.parameters.firstOrNull()?.type as? EtsLexicalEnvType + environment != null && environment != admittedLexicalEnvironment + } +} + +private fun org.jacodb.ets.model.EtsStmt.sourceSnippet(source: String): String { + val origin = location.origin ?: return "" + if (origin.startOffset !in 0..source.length || origin.endOffset !in origin.startOffset..source.length) return "" + + return source.substring(origin.startOffset, origin.endOffset) +} + +private fun org.jacodb.ets.model.EtsStmt.walkEntities(): List { + val result = mutableListOf() + val pending = ArrayDeque() + getOperands().forEach(pending::addLast) + while (pending.isNotEmpty()) { + val entity = pending.removeFirst() + result += entity + entity.getOperands().forEach(pending::addLast) + } + + return result +} + +private fun isPrototypeAccess(entity: EtsEntity): Boolean = + entity is EtsFieldRef && entity.field.name == "prototype" + +private fun requiresSymbolicNumberToString(entity: EtsEntity): Boolean { + val addition = entity as? EtsAddExpr ?: return false + if (addition.type != EtsStringType) return false + + val symbolicNumberOnLeft = addition.left.type == EtsNumberType && addition.left !is EtsNumberConstant + val symbolicNumberOnRight = addition.right.type == EtsNumberType && addition.right !is EtsNumberConstant + return symbolicNumberOnLeft || symbolicNumberOnRight +} + +private data class RawNode( + val kind: String, + val extra: Map, + val sourceSnippet: String, +) { + private val normalizedDescription: String = sequenceOf(kind) + .plus(extra.entries.sortedBy { (key, _) -> key }.flatMap { (key, value) -> sequenceOf(key, value.toString()) }) + .plus(sourceSnippet) + .joinToString(separator = " ") + .lowercase() + + fun matches(vararg markers: String): Boolean = markers.any(normalizedDescription::contains) + + fun isDestructuring(): Boolean = matches("destruct", "bindingpattern", "binding pattern") || + DESTRUCTURING_DECLARATION.containsMatchIn(sourceSnippet) || + DESTRUCTURING_ASSIGNMENT.containsMatchIn(sourceSnippet) + + fun isSpread(): Boolean = matches("spread") || sourceSnippet.contains("...") + + fun isRegexLiteral(): Boolean = matches("regex", "regexp", "regular expression") || + REGEX_LITERAL.containsMatchIn(sourceSnippet) + + fun issue(reasonCode: CallsSymbolicPreflightReasonCode, methodName: String): CallsIrReadinessIssue = issue( + reasonCode = reasonCode, + diagnostic = "Reachable EtsIR contains unsupported raw node kind $kind in $methodName", + ) +} + +private val DESTRUCTURING_DECLARATION = Regex("""\b(?:const|let|var)\s*[\[{]""") +private val DESTRUCTURING_ASSIGNMENT = Regex("""(?:^|[;{}])\s*[\[{][^=]*[\]}]\s*=""") +private val REGEX_LITERAL = Regex("""/(?:\\.|[^/\r\n])+/[a-z]*""") + +private fun issue( + reasonCode: CallsSymbolicPreflightReasonCode, + diagnostic: String, +): CallsIrReadinessIssue = CallsIrReadinessIssue( + reasonCode = reasonCode, + diagnostic = diagnostic, +) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index 4118cc6ade..d249b79596 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -9,12 +9,17 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.fastcheck.FastCheckBackend import org.usvm.ts.pbt.fastcheck.PbtBackendException +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.ExecutionKind import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyId import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TupleDomain import org.usvm.ts.pbt.model.TypeScriptEntryPoint import java.nio.file.Files import java.nio.file.LinkOption @@ -52,6 +57,12 @@ internal data class CallsSourcePosition( val column: Int, ) +@Serializable +internal enum class CallsSourceTargetMode { + ENTRY, + COMPLETED_RETURN, +} + @Serializable internal data class CallsSourceTarget( val targetId: String, @@ -61,7 +72,21 @@ internal data class CallsSourceTarget( val endOffset: Int, val start: CallsSourcePosition, val end: CallsSourcePosition, -) + val mode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + val returnExpressionStartOffset: Int? = null, + val returnExpressionEndOffset: Int? = null, +) { + init { + require((returnExpressionStartOffset == null) == (returnExpressionEndOffset == null)) { + "Return expression offsets must be both present or both absent" + } + if (mode == CallsSourceTargetMode.ENTRY) { + require(returnExpressionStartOffset == null) { + "Entry targets must not declare return expression offsets" + } + } + } +} @Serializable internal data class CallsSourceReplayResult( @@ -131,14 +156,21 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { val resolved = resolveTarget(sourceRoots = sourceRoots, sourcePath = target.sourcePath) val source = Files.readString(resolved.source) requireTargetCoordinates(source = source, target = target) + val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" + val instrumented = instrumentSource( + sourcePath = resolved.source, + source = source, + exportName = entryPoint.exportName, + target = target, + marker = marker, + ) ?: return CallsSourceReplayResult( + status = CallsReplayStatus.UNMAPPED, + reason = "TypeScript AST does not identify the requested completed return", + ) val workspace = Files.createTempDirectory("usvm-ts-calls-replay-") return try { val overlayRoot = workspace.resolve("source-overlay") - val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" - val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" - val instrumented = source.substring(0, target.startOffset) + markerStatement + - source.substring(target.startOffset) createOverlay( sourceRoot = resolved.sourceRoot, overlayRoot = overlayRoot, @@ -154,6 +186,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName = entryPoint.exportName, marker = marker, resultPath = resultPath, + targetMode = target.mode, ), ) val replayRoots = sourceRoots.mapIndexed { index, root -> @@ -163,7 +196,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { val property = PropertyDefinition( id = PropertyId("calls.source-target-replay"), inputs = replayInputs.mapIndexed { index, value -> - PropertyInput(name = "input$index", domain = ConstantDomain(value)) + PropertyInput(name = "input$index", domain = value.exactReplayDomain()) }, predicate = TypeScriptEntryPoint(module = wrapperName, exportName = REPLAY_EXPORT), ) @@ -225,14 +258,33 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } private fun requireTargetCoordinates(source: String, target: CallsSourceTarget) { - require(target.startOffset in 0..source.length && target.endOffset in target.startOffset..source.length) { - "Target offsets are outside the source file" - } - require(sourcePositionAt(source = source, offset = target.startOffset) == target.start) { - "Target start coordinate does not match its source offset" + val diagnostic = callsTargetCoordinateDiagnostic(source = source, target = target) + require(diagnostic == null) { requireNotNull(diagnostic) } + } + + private fun instrumentSource( + sourcePath: Path, + source: String, + exportName: String, + target: CallsSourceTarget, + marker: String, + ): String? = when (target.mode) { + CallsSourceTargetMode.ENTRY -> { + val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" + + source.substring(0, target.startOffset) + markerStatement + source.substring(target.startOffset) } - require(sourcePositionAt(source = source, offset = target.endOffset) == target.end) { - "Target end coordinate does not match its source offset" + + CallsSourceTargetMode.COMPLETED_RETURN -> { + TypeScriptSourceInspector.instrumentCompletedReturn( + source = sourcePath, + exportName = exportName, + startOffset = target.startOffset, + endOffset = target.endOffset, + expressionStartOffset = target.returnExpressionStartOffset, + expressionEndOffset = target.returnExpressionEndOffset, + marker = marker, + ) } } @@ -291,6 +343,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName: String, marker: String, resultPath: Path, + targetMode: CallsSourceTargetMode, ): String = """ import { writeFileSync } from 'node:fs'; import * as targetModule from ${jsString("./$sourcePath")}; @@ -318,7 +371,9 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { invocation = 'threw'; caught = error; } - const targetHit = (globalThis as Record)[${jsString(marker)}] === true; + const targetObserved = (globalThis as Record)[${jsString(marker)}] === true; + const targetHit = targetObserved && + (${targetMode == CallsSourceTargetMode.ENTRY} || invocation === 'returned'); const output: Record = { invocation, targetHit }; if (invocation === 'threw') { output.errorName = caught instanceof Error ? caught.name : typeof caught; @@ -344,6 +399,41 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } } +private fun JsConcreteValue.exactReplayDomain(): PropertyDomain = when (this) { + is JsConcreteValue.Array -> if (elements.isEmpty()) { + ArrayDomain(element = BooleanDomain, minLength = 0, maxLength = 0) + } else { + TupleDomain(elements.map { element -> element.exactReplayDomain() }) + } + + else -> ConstantDomain(this) +} + +internal fun callsTargetCoordinateDiagnostic(source: String, target: CallsSourceTarget): String? { + if (target.startOffset !in source.indices || target.endOffset !in 1..source.length) { + return "Target offsets are outside the frozen source" + } + if (target.startOffset >= target.endOffset) { + return "Target source range must be non-empty" + } + if (sourcePositionAt(source = source, offset = target.startOffset) != target.start) { + return "Target start coordinate does not match its frozen source offset" + } + if (sourcePositionAt(source = source, offset = target.endOffset) != target.end) { + return "Target end coordinate does not match its frozen source offset" + } + + val expressionStart = target.returnExpressionStartOffset + val expressionEnd = target.returnExpressionEndOffset + if (expressionStart != null && expressionEnd != null && + (expressionStart < target.startOffset || expressionStart >= expressionEnd || expressionEnd > target.endOffset) + ) { + return "Return expression offsets must identify a non-empty range inside the source target" + } + + return null +} + internal fun sourcePositionAt(source: String, offset: Int): CallsSourcePosition { var line = 0 var column = 0 diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt new file mode 100644 index 0000000000..56137b47c5 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt @@ -0,0 +1,385 @@ +package org.usvm.ts.calls + +import io.ksmt.expr.KBitVec16Value +import io.ksmt.sort.KBv16Sort +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.USort +import org.usvm.api.allocateConcreteRef +import org.usvm.api.initializeArrayLength +import org.usvm.api.makeSymbolicPrimitive +import org.usvm.machine.TsContext +import org.usvm.machine.TsSizeSort +import org.usvm.machine.expr.extractDouble +import org.usvm.machine.expr.extractInt +import org.usvm.machine.expr.toConcreteBoolValue +import org.usvm.machine.state.TsState +import org.usvm.model.UModelBase +import org.usvm.sizeSort +import org.usvm.ts.pbt.mapping.EtsInputBinding +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.PropertyDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.util.markDenseInputArray +import org.usvm.util.markStringMaxLength +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkFieldLValue +import org.usvm.util.mkRegisterStackLValue + +internal fun PropertyDomain.isSupportedCallsSymbolicDomain(): Boolean = when (this) { + BooleanDomain, is NumberDomain, is StringDomain -> true + is ArrayDomain -> element == BooleanDomain || element is NumberDomain + else -> false +} + +internal fun callsSymbolicInputPreflight(inputs: List): String? { + val unsupportedInput = inputs.firstOrNull { input -> !input.domain.isSupportedCallsSymbolicDomain() } + ?: return null + + return "Unsupported symbolic input domain for ${unsupportedInput.name}: ${unsupportedInput.domain}" +} + +internal class CallsSymbolicInputs( + inputs: List, + bindings: List, + private val lexicalEnvironment: EtsLexicalEnvironmentBinding?, +) { + private val boundInputs: List> + private var snapshots: List? = null + + init { + require(inputs.size == bindings.size) { "Property inputs do not match mapped EtsIR bindings" } + val bindingsByName = bindings.associateBy(EtsInputBinding::propertyInputName) + require(bindingsByName.size == bindings.size) { "Mapped EtsIR input names must be unique" } + + boundInputs = inputs.map { input -> + input to requireNotNull(bindingsByName[input.name]) { + "Missing EtsIR binding for property input ${input.name}" + } + } + } + + fun initialize(state: TsState) { + check(snapshots == null) { "Calls symbolic inputs were initialized more than once" } + lexicalEnvironment?.let(state::initializeLexicalEnvironment) + val initializedSnapshots = boundInputs.map { (input, binding) -> + state.initializeInput( + stackSlot = binding.stackSlot, + domain = input.domain, + ) + } + initializedSnapshots.filterIsInstance().forEach { snapshot -> + snapshot.markDenseInput(state) + } + snapshots = initializedSnapshots + } + + fun sortOverride(ctx: TsContext, stackSlot: Int): USort? { + if (lexicalEnvironment?.stackSlot == stackSlot) return ctx.addressSort + + val domain = boundInputs.singleOrNull { (_, binding) -> binding.stackSlot == stackSlot }?.first?.domain + ?: return null + + return with(ctx) { + when (domain) { + BooleanDomain -> boolSort + is NumberDomain -> fp64Sort + is StringDomain, is ArrayDomain -> addressSort + else -> null + } + } + } + + fun resolve(state: TsState): List { + val initializedSnapshots = checkNotNull(snapshots) { "Calls symbolic inputs were not initialized" } + val model = state.models.single() + + return initializedSnapshots.map { snapshot -> snapshot.resolve(model) } + } +} + +private fun TsState.initializeLexicalEnvironment(binding: EtsLexicalEnvironmentBinding): Unit = with(ctx) { + val environmentType = binding.parameter.type as? EtsLexicalEnvType + ?: error("Mapped lexical environment does not have a lexical-environment type") + val environmentRef = allocateConcreteRef() + for (captured in environmentType.closures) { + initializeBuiltinCapture(captured = captured, environmentRef = environmentRef) + } + memory.write( + mkRegisterStackLValue(addressSort, binding.stackSlot), + environmentRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(binding.stackSlot, addressSort) +} + +private fun TsState.initializeBuiltinCapture( + captured: EtsLocal, + environmentRef: UConcreteHeapRef, +): Unit = with(ctx) { + val value = when (captured.name) { + "Infinity" -> mkFpInf(signBit = false, sort = fp64Sort) + "NaN" -> mkFp64NaN() + else -> memory.allocConcrete( + EtsUnclearRefType( + name = captured.name, + typeParameters = emptyList(), + ), + ).asExpr(addressSort) + } + val expectedSort = typeToSort(captured.type).let { sort -> + if (sort == unresolvedSort) addressSort else sort + } + require(value.sort == expectedSort) { + "Builtin capture ${captured.name} has sort ${value.sort}, expected $expectedSort" + } + memory.write( + mkFieldLValue(expectedSort, environmentRef, captured.name), + value.asExpr(expectedSort), + guard = trueExpr, + ) +} + +private fun TsState.initializeInput( + stackSlot: Int, + domain: PropertyDomain, +): CallsInputSnapshot = with(ctx) { + when (domain) { + BooleanDomain -> { + val value: UBoolExpr = makeSymbolicPrimitive(boolSort) + memory.write( + mkRegisterStackLValue(boolSort, stackSlot), + value.asExpr(boolSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, boolSort) + BooleanInputSnapshot(value) + } + + is NumberDomain -> { + val value: UExpr = makeSymbolicPrimitive(fp64Sort) + pathConstraints += numberDomainConstraint(value, domain) + memory.write( + mkRegisterStackLValue(fp64Sort, stackSlot), + value.asExpr(fp64Sort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, fp64Sort) + NumberInputSnapshot(value) + } + + is StringDomain -> { + initializeStringInput(stackSlot = stackSlot, domain = domain) + } + + is ArrayDomain -> { + initializeArrayInput(stackSlot = stackSlot, domain = domain) + } + + else -> { + error("Unsupported calls symbolic domain: $domain") + } + } +} + +private fun TsState.initializeStringInput( + stackSlot: Int, + domain: StringDomain, +): StringInputSnapshot = with(ctx) { + val stringRef = memory.allocConcrete(EtsStringType) + val characterArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val descriptor = arrayDescriptorOf(characterArrayType) + val charactersRef = memory.allocConcrete(descriptor) + val length: UExpr = makeSymbolicPrimitive(sizeSort) + val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + memory.initializeArrayLength( + arrayHeapRef = charactersRef, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + codeUnits.forEachIndexed { index, codeUnit -> + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + memory.write( + mkArrayIndexLValue( + sort = bv16Sort, + ref = charactersRef, + index = mkBv(index), + type = characterArrayType, + ), + codeUnit, + guard = liveIndex, + ) + } + memory.write( + mkFieldLValue(addressSort, stringRef, "value"), + charactersRef.asExpr(addressSort), + guard = trueExpr, + ) + markStringMaxLength(string = stringRef, maxLength = domain.maxLength) + memory.write( + mkRegisterStackLValue(addressSort, stackSlot), + stringRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, addressSort) + + StringInputSnapshot(length = length, codeUnits = codeUnits) +} + +private fun TsState.initializeArrayInput( + stackSlot: Int, + domain: ArrayDomain, +): ArrayInputSnapshot = with(ctx) { + val runtimeType = when (domain.element) { + BooleanDomain -> EtsArrayType(EtsBooleanType, dimensions = 1) + is NumberDomain -> EtsArrayType(EtsNumberType, dimensions = 1) + else -> error("Unsupported calls symbolic array element domain: ${domain.element}") + } + val descriptor = arrayDescriptorOf(runtimeType) + val arrayRef = memory.allocConcrete(descriptor) + val length: UExpr = makeSymbolicPrimitive(sizeSort) + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + memory.initializeArrayLength( + arrayHeapRef = arrayRef, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + val elements = when (val elementDomain = domain.element) { + BooleanDomain -> List(domain.maxLength) { index -> + val value = makeSymbolicPrimitive(boolSort) + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + memory.write( + mkArrayIndexLValue(boolSort, arrayRef, mkBv(index), runtimeType), + value, + guard = liveIndex, + ) + BooleanInputSnapshot(value) + } + + is NumberDomain -> List(domain.maxLength) { index -> + val value = makeSymbolicPrimitive(fp64Sort) + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + pathConstraints += numberDomainConstraint(value, elementDomain) + memory.write( + mkArrayIndexLValue(fp64Sort, arrayRef, mkBv(index), runtimeType), + value, + guard = liveIndex, + ) + NumberInputSnapshot(value) + } + + else -> error("Unsupported calls symbolic array element domain: $elementDomain") + } + memory.write( + mkRegisterStackLValue(addressSort, stackSlot), + arrayRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, addressSort) + ArrayInputSnapshot( + length = length, + elements = elements, + arrayRef = arrayRef, + runtimeType = runtimeType, + ) +} + +private fun TsState.constrainLength( + length: UExpr, + minLength: Int, + maxLength: Int, +) = with(ctx) { + pathConstraints += mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)) + pathConstraints += mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)) +} + +private fun TsState.numberDomainConstraint( + value: UExpr, + domain: NumberDomain, +): UBoolExpr = with(ctx) { + val lowerBound = mkFp64(domain.min.toDouble()) + val upperBound = mkFp64(domain.max.toDouble()) + val inBounds = mkAnd( + mkFpLessOrEqualExpr(lowerBound, value), + mkFpLessOrEqualExpr(value, upperBound), + ) + + if (domain.allowNaN) mkOr(mkFpIsNaNExpr(value), inBounds) else inBounds +} + +private sealed interface CallsInputSnapshot { + fun resolve(model: UModelBase): JsConcreteValue +} + +private data class BooleanInputSnapshot( + val value: UBoolExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.Boolean(model.eval(value).toConcreteBoolValue()) +} + +private data class NumberInputSnapshot( + val value: UExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.number(model.eval(value).extractDouble()) +} + +private data class StringInputSnapshot( + val length: UExpr, + val codeUnits: List>, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue { + val concreteLength = model.eval(length).extractInt() + require(concreteLength in 0..codeUnits.size) { "Resolved string length is outside its symbolic domain" } + val value = buildString(concreteLength) { + codeUnits.take(concreteLength).forEach { codeUnit -> + val concreteCodeUnit = model.eval(codeUnit) as KBitVec16Value + append((concreteCodeUnit.shortValue.toInt() and UTF16_CODE_UNIT_MASK).toChar()) + } + } + + return JsConcreteValue.String(value) + } +} + +private data class ArrayInputSnapshot( + val length: UExpr, + val elements: List, + val arrayRef: UConcreteHeapRef, + val runtimeType: EtsArrayType, +) : CallsInputSnapshot { + fun markDenseInput(state: TsState) { + state.markDenseInputArray(array = arrayRef, type = runtimeType) + } + + override fun resolve(model: UModelBase): JsConcreteValue { + val concreteLength = model.eval(length).extractInt() + require(concreteLength in 0..elements.size) { "Resolved array length is outside its symbolic domain" } + + return JsConcreteValue.Array(elements.take(concreteLength).map { element -> element.resolve(model) }) + } +} + +private const val UTF16_CODE_UNIT_MASK = 0xffff diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt new file mode 100644 index 0000000000..046a265d6e --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt @@ -0,0 +1,194 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNamespaceSignature +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent + +@Serializable +internal data class CallsExperimentCellIdentity( + val experimentId: String, + val projectId: String, + val revision: String, + val development: Boolean, + val functionId: String, + val targetId: String, + val siteId: String, + val targetMode: CallsSourceTargetMode, + val profile: CallsExperimentProfile, + val seed: Long, +) + +@Serializable +internal data class CallsUnknownCallSite( + val sourcePath: String, + val statementIndex: Int, + val startOffset: Int? = null, + val endOffset: Int? = null, + val start: CallsSourcePosition? = null, + val end: CallsSourcePosition? = null, +) + +@Serializable +internal data class CallsUnknownCallCallee( + val calleeId: String, + val sourcePath: String, + val enclosingClass: String, + val name: String, + val parameterTypes: List, + val returnType: String, +) + +@Serializable +internal enum class CallsUnknownCallDecisionKind { + MODEL_APPLIED, + RESIDUAL_FALLBACK, +} + +@Serializable +@SerialName("unknown-call") +internal data class CallsUnknownCallRecord( + val cell: CallsExperimentCellIdentity, + val eventIndex: Int, + val callSite: CallsUnknownCallSite, + val callee: CallsUnknownCallCallee, + val failureReason: String, + val decision: CallsUnknownCallDecisionKind, + val outcome: String, + val modelId: String? = null, + val residualPolicy: String? = null, +) : CallsRawRecord { + init { + require(eventIndex >= 1) { "Unknown-call event index must be positive" } + require((decision == CallsUnknownCallDecisionKind.MODEL_APPLIED) == (modelId != null)) { + "Exactly a model-applied decision must carry a model ID" + } + require((decision == CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK) == (residualPolicy != null)) { + "Exactly a residual-fallback decision must carry a residual policy" + } + } +} + +@Serializable +@SerialName("runtime-limitation") +internal data class CallsRuntimeLimitationRecord( + val cell: CallsExperimentCellIdentity, + val eventIndex: Int, + val callSite: CallsUnknownCallSite, + val reason: String, + val detail: String, +) : CallsRawRecord + +internal fun callsRuntimeLimitationEventSink( + cell: CallsExperimentCellIdentity, + appendAndFlush: (CallsRuntimeLimitationRecord) -> Unit, +): (TsRuntimeFeatureLimitationEvent) -> Unit { + var eventIndex = 0 + return { event -> + eventIndex++ + val location = event.statement.location + val origin = location.origin + val containingMethod = location.method.signature + val callSite = CallsUnknownCallSite( + sourcePath = containingMethod.enclosingClass.file.fileName, + statementIndex = location.index, + startOffset = origin?.startOffset, + endOffset = origin?.endOffset, + start = origin?.let { CallsSourcePosition(line = it.startLine, column = it.startColumn) }, + end = origin?.let { CallsSourcePosition(line = it.endLine, column = it.endColumn) }, + ) + val record = CallsRuntimeLimitationRecord( + cell = cell, + eventIndex = eventIndex, + callSite = callSite, + reason = event.reason.name, + detail = event.detail, + ) + appendAndFlush(record) + } +} + +internal fun CallsSymbolicSearchRequest.cellIdentity(experimentId: String): CallsExperimentCellIdentity = + CallsExperimentCellIdentity( + experimentId = experimentId, + projectId = project.projectId, + revision = project.revision, + development = project.development, + functionId = function.functionId, + targetId = target.targetId, + siteId = target.siteId, + targetMode = target.mode, + profile = profile, + seed = seed, + ) + +internal fun CallsExperimentCellIdentity.unknownCallRecord( + event: TsUnknownCallEvent, + eventIndex: Int, +): CallsUnknownCallRecord { + val containingMethod = event.callSite.location.method.signature + val origin = event.callSite.location.origin + val decision = event.decision + + return CallsUnknownCallRecord( + cell = this, + eventIndex = eventIndex, + callSite = CallsUnknownCallSite( + sourcePath = containingMethod.enclosingClass.file.fileName, + statementIndex = event.callSite.location.index, + startOffset = origin?.startOffset, + endOffset = origin?.endOffset, + start = origin?.let { span -> CallsSourcePosition(line = span.startLine, column = span.startColumn) }, + end = origin?.let { span -> CallsSourcePosition(line = span.endLine, column = span.endColumn) }, + ), + callee = event.callee.toCallsUnknownCallCallee(), + failureReason = event.failureReason.name, + decision = when (decision) { + is TsUnknownCallDecision.ModelApplied -> CallsUnknownCallDecisionKind.MODEL_APPLIED + is TsUnknownCallDecision.ResidualFallback -> CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK + }, + outcome = event.outcome.name, + modelId = (decision as? TsUnknownCallDecision.ModelApplied)?.modelId, + residualPolicy = (decision as? TsUnknownCallDecision.ResidualFallback)?.policy?.serializedName, + ) +} + +internal fun callsUnknownCallEventSink( + cell: CallsExperimentCellIdentity, + appendAndFlush: (CallsUnknownCallRecord) -> Unit, +): (TsUnknownCallEvent) -> Unit { + var eventIndex = 0 + + return { event -> + eventIndex++ + appendAndFlush(cell.unknownCallRecord(event = event, eventIndex = eventIndex)) + } +} + +private fun EtsMethodSignature.toCallsUnknownCallCallee(): CallsUnknownCallCallee { + val parameterTypes = parameters.map { parameter -> parameter.type.toString() } + val namespace = enclosingClass.namespace?.qualifiedName() + val className = listOfNotNull(namespace, enclosingClass.name).joinToString(separator = "::") + val sourcePath = enclosingClass.file.fileName + val returnType = returnType.toString() + val signature = "$className:$name(${parameterTypes.joinToString(separator = ",")})->$returnType" + + return CallsUnknownCallCallee( + calleeId = "$sourcePath:$signature", + sourcePath = sourcePath, + enclosingClass = className, + name = name, + parameterTypes = parameterTypes, + returnType = returnType, + ) +} + +private fun EtsNamespaceSignature.qualifiedName(): String = + listOfNotNull(namespace?.qualifiedName(), name).joinToString(separator = "::") + +private val TsResidualCallPolicy.serializedName: String + get() = name diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index b7767ed426..66c4d9a3b7 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -1,9 +1,8 @@ package org.usvm.ts.calls -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsMethod -import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.utils.EtsIrProvider @@ -12,43 +11,102 @@ import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.machine.TsAnalysisStopReason +import org.usvm.machine.TsGraph +import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallModelSelection -import org.usvm.machine.expr.extractDouble -import org.usvm.machine.expr.toConcreteBoolValue +import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.statistics.UMachineObserver +import org.usvm.ts.pbt.fastcheck.TypeScriptCompletedReturnTargetKind +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.mapping.EtsInputBinding +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding import org.usvm.ts.pbt.mapping.EtsMappingStatus import org.usvm.ts.pbt.mapping.PropertyEtsMapper -import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.contains -import org.usvm.util.mkRegisterStackLValue import java.nio.file.Path import kotlin.time.TimeSource -internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { +internal data class CallsSymbolicPreflightRequest( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val target: CallsSourceTarget, + val expectedNativeFrontendRevision: String, +) + +internal enum class CallsSymbolicPreflightStatus { + ELIGIBLE, + UNSUPPORTED, + UNMAPPED, + AMBIGUOUS, + TOOL_ERROR, +} + +internal enum class CallsSymbolicPreflightReasonCode { + INPUT_DOMAIN_UNSUPPORTED, + IMPORTED_CALLEES_UNSUPPORTED, + ENTRY_MAPPING_UNSUPPORTED, + ENTRY_MAPPING_UNMAPPED, + ENTRY_MAPPING_AMBIGUOUS, + LEXICAL_CAPTURE_UNSUPPORTED, + LEXICAL_ENVIRONMENT_UNSUPPORTED, + DESTRUCTURING_UNSUPPORTED, + SPREAD_UNSUPPORTED, + REGEX_LITERAL_UNSUPPORTED, + RAW_ENTITY_UNSUPPORTED, + PROTOTYPE_ACCESS_UNSUPPORTED, + SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + EXPONENTIATION_UNSUPPORTED, + TARGET_ORIGIN_UNMAPPED, + TARGET_ORIGIN_UNSUPPORTED, + FRONTEND_OR_PREFLIGHT_ERROR, +} + +internal data class CallsSymbolicPreflightResult( + val status: CallsSymbolicPreflightStatus, + val reasonCode: CallsSymbolicPreflightReasonCode? = null, + val diagnostic: String? = null, +) { + init { + require((status == CallsSymbolicPreflightStatus.ELIGIBLE) == (reasonCode == null)) { + "Eligible preflight has no exclusion reason; rejected preflight has exactly one" + } + } +} + +internal class CurrentTsCallsSymbolicEngine( + private val environment: (String) -> String? = System::getenv, + private val bundledNativeFrontendRevision: String = CallsBuildIdentity.nativeFrontendRevision, +) : CallsSymbolicEngine { private val verifiedProjects = mutableMapOf() - private var verifiedNativeFrontend: Pair? = null + private val preparedTargets = mutableMapOf() + private var verifiedNativeFrontendIdentity: String? = null override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { val startedAt = TimeSource.Monotonic.markNow() - val unsupportedInput = request.function.inputs.firstOrNull { input -> - input.domain != BooleanDomain && input.domain !is NumberDomain - } - if (unsupportedInput != null) { + val preflightRequest = request.toPreflightRequest() + val preparation = prepareSafely(preflightRequest) + if (preparation is CallsTargetPreparation.Rejected) { return result( - status = CallsSymbolicStatus.UNSUPPORTED, + status = preparation.status, startedAt = startedAt, - diagnostic = "Only boolean and number input domains are supported; found ${unsupportedInput.domain}", + diagnostic = preparation.diagnostic, ) } return runCatching { - searchSupported(request = request, startedAt = startedAt) + searchSupported( + request = request, + startedAt = startedAt, + prepared = preparation as CallsTargetPreparation.Eligible, + ) }.getOrElse { error -> result( status = CallsSymbolicStatus.TOOL_ERROR, @@ -58,64 +116,28 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { } } + fun preflight(request: CallsSymbolicPreflightRequest): CallsSymbolicPreflightResult = + when (val preparation = prepareSafely(request)) { + is CallsTargetPreparation.Eligible -> CallsSymbolicPreflightResult( + status = CallsSymbolicPreflightStatus.ELIGIBLE, + ) + + is CallsTargetPreparation.Rejected -> CallsSymbolicPreflightResult( + status = preparation.status.toPreflightStatus(), + reasonCode = preparation.reasonCode, + diagnostic = preparation.diagnostic, + ) + } + @Suppress("LongMethod") private fun searchSupported( request: CallsSymbolicSearchRequest, startedAt: TimeSource.Monotonic.ValueTimeMark, + prepared: CallsTargetPreparation.Eligible, ): CallsSymbolicSearchResult { - verifyGitCheckoutOnce( - checkout = request.sourceRoot, - expectedRevision = request.project.revision, - cache = verifiedProjects, - ) - verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) - - val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() - require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } - - val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) - if (sourceFile.importInfos.isNotEmpty()) { - return result( - status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "Single-file symbolic replay does not support imported project callees", - ) - } - val scene = EtsScene(projectFiles = listOf(sourceFile)) - val frontendEntryPoint = request.function.entryPoint.copy( - module = requireNotNull(source.fileName).toString(), - ) - val propertyManifest = PropertyManifest( - propertyId = "calls.mapping", - inputs = request.function.inputs, - predicate = frontendEntryPoint, - ) - val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) - if (mapping.predicate.status != EtsMappingStatus.EXACT) { - return result( - status = mapping.predicate.status.toSymbolicStatus(), - startedAt = startedAt, - diagnostic = mapping.predicate.diagnostics.joinToString { diagnostic -> diagnostic.message }, - ) - } - - val method = mapping.predicate.targets.single().method - val exactTargetCandidates = exactTargetCandidates(method, request.target) - if (exactTargetCandidates.isEmpty()) { - return result( - status = CallsSymbolicStatus.UNMAPPED, - startedAt = startedAt, - diagnostic = "No EtsIR statement has the exact frozen source range", - ) - } - val statementEntry = sourceStatementEntry(method, request.target) - if (statementEntry == null) { - return result( - status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "EtsIR origins do not prove entry before evaluation of the frozen source statement", - ) - } + val scene = prepared.scene + val method = prepared.method + val targetObserver = prepared.targetObservation.createObserver(method) val modelSelection = if (request.profile.usesFrozenModels) { TsUnknownCallModelSelection.Only(request.frozenModelIds) @@ -136,37 +158,56 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { unknownCallModelSelection = modelSelection, unknownCallFallback = request.profile.fallback, ) - // Observe the unique CFG entry into the source statement's origin-contained lowering region. - // This matches the replay marker before statement evaluation, including nested constructor and call lowering. - val entryObserver = SourceStatementEntryObserver(statementEntry.statement) + val symbolicInputs = CallsSymbolicInputs( + inputs = request.function.inputs, + bindings = prepared.inputBindings, + lexicalEnvironment = prepared.lexicalEnvironment, + ) + val interpreterObserver = UnknownCallEventSinkObserver( + sink = request.unknownCallEventSink, + runtimeLimitationSink = request.runtimeLimitationEventSink, + ) val analysis = TsMachine( scene = scene, options = machineOptions, tsOptions = tsOptions, - machineObserver = entryObserver, + initialStateConfigurator = symbolicInputs::initialize, + initialParameterSortOverride = symbolicInputs::sortOverride, + machineObserver = targetObserver, + observer = interpreterObserver, ).use { machine -> val outcome = machine.analyzeWithOutcome(methods = listOf(method)) MachineResult( - states = entryObserver.reachedStates, + states = targetObserver.reachedStates, stopReason = outcome.stopReason, ) } val states = analysis.states if (states.isEmpty()) { val status = when (analysis.stopReason) { - TsAnalysisStopReason.EXHAUSTED -> CallsSymbolicStatus.UNREACHED + TsAnalysisStopReason.EXHAUSTED -> { + if (interpreterObserver.runtimeLimitations.isEmpty()) { + CallsSymbolicStatus.UNREACHED + } else { + CallsSymbolicStatus.RUNTIME_LIMITATION + } + } // The machine options above disable every stop condition except the per-target timeout. - TsAnalysisStopReason.STOPPED -> CallsSymbolicStatus.TIMEOUT + TsAnalysisStopReason.STOPPED -> { + CallsSymbolicStatus.TIMEOUT + } } return result( status = status, startedAt = startedAt, + diagnostic = interpreterObserver.runtimeLimitations.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Runtime feature limitations: "), ) } val inputs = states.asSequence() - .map { state -> resolveScalarInputs(state, method) } + .map(symbolicInputs::resolve) .firstOrNull { candidate -> candidate.zip(request.function.inputs).all { (value, input) -> value in input.domain } } @@ -183,8 +224,178 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { status = CallsSymbolicStatus.REACHED, inputs = inputs, startedAt = startedAt, - diagnostic = "source-statement-lowering-size=${statementEntry.loweringSize};" + - "exact-source-lowering-size=${exactTargetCandidates.size}", + diagnostic = "source-target-mode=${request.target.mode};" + + "source-statement-lowering-size=${targetObserver.loweringSize};" + + "exact-source-lowering-size=${prepared.exactTargetCandidateCount}", + ) + } + + private fun prepareSafely(request: CallsSymbolicPreflightRequest): CallsTargetPreparation { + preparedTargets[request]?.let { preparation -> return preparation } + + val preparation = runCatching { prepare(request) }.getOrElse { error -> + CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.TOOL_ERROR, + reasonCode = CallsSymbolicPreflightReasonCode.FRONTEND_OR_PREFLIGHT_ERROR, + diagnostic = error.message ?: error::class.java.name, + ) + } + preparedTargets[request] = preparation + + return preparation + } + + @Suppress("LongMethod") + private fun prepare(request: CallsSymbolicPreflightRequest): CallsTargetPreparation { + callsSymbolicInputPreflight(request.function.inputs)?.let { diagnostic -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, + diagnostic = diagnostic, + ) + } + + verifyGitCheckoutOnce( + checkout = request.sourceRoot, + expectedRevision = request.project.revision, + cache = verifiedProjects, + ) + verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) + + val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() + require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } + val sourceText = java.nio.file.Files.readString(source) + callsTargetCoordinateDiagnostic(source = sourceText, target = request.target)?.let { diagnostic -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNMAPPED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, + diagnostic = diagnostic, + ) + } + + val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + if (sourceFile.importInfos.isNotEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.IMPORTED_CALLEES_UNSUPPORTED, + diagnostic = "Single-file symbolic replay does not support imported project callees", + ) + } + + val scene = EtsScene(projectFiles = listOf(sourceFile)) + val frontendEntryPoint = request.function.entryPoint.copy( + module = requireNotNull(source.fileName).toString(), + ) + val propertyManifest = PropertyManifest( + propertyId = "calls.mapping", + inputs = request.function.inputs, + predicate = frontendEntryPoint, + ) + val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) + if (mapping.predicate.status != EtsMappingStatus.EXACT) { + return CallsTargetPreparation.Rejected( + status = mapping.predicate.status.toSymbolicStatus(), + reasonCode = mapping.predicate.status.toPreflightReasonCode(), + diagnostic = mapping.predicate.diagnostics.joinToString { diagnostic -> diagnostic.message }, + ) + } + + val mappedTarget = mapping.predicate.targets.single() + val lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment + if (lexicalEnvironment != null && lexicalEnvironment.parameter.type !is EtsLexicalEnvType) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, + diagnostic = "Mapped lexical environment does not have a lexical-environment type", + ) + } + + val unsupportedCaptures = (lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType) + ?.closures + ?.map { closure -> closure.name } + ?.filterNot { closure -> closure in SUPPORTED_BUILTIN_CAPTURES } + .orEmpty() + if (unsupportedCaptures.isNotEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, + diagnostic = "Symbolic entry point has unsupported runtime captures: " + + unsupportedCaptures.joinToString(), + ) + } + + val method = mappedTarget.method + callsIrReadinessIssue( + method = method, + graph = TsGraph(scene), + source = sourceText, + admittedLexicalEnvironment = lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType, + )?.let { issue -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = issue.reasonCode, + diagnostic = issue.diagnostic, + ) + } + val exactTargetCandidates = exactTargetCandidates(method, request.target) + val targetObservation = when (request.target.mode) { + CallsSourceTargetMode.ENTRY -> { + if (exactTargetCandidates.isEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNMAPPED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, + diagnostic = "No EtsIR statement has the exact frozen source range", + ) + } + + val statementEntry = sourceStatementEntry(method, request.target) + ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "EtsIR origins do not prove entry before evaluation " + + "of the frozen source statement", + ) + + SourceTargetObservation.Entry(statementEntry) + } + + CallsSourceTargetMode.COMPLETED_RETURN -> { + val completedReturnTargetKind = TypeScriptSourceInspector.completedReturnTargetKind( + source = source, + exportName = request.function.entryPoint.exportName, + startOffset = request.target.startOffset, + endOffset = request.target.endOffset, + expressionStartOffset = request.target.returnExpressionStartOffset, + expressionEndOffset = request.target.returnExpressionEndOffset, + ) ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "TypeScript AST does not identify the requested completed return", + ) + val returnStatement = completedReturnCandidate( + method = method, + target = request.target, + exactTargetCandidates = exactTargetCandidates, + allowsUniqueOriginlessReturn = { + completedReturnTargetKind == TypeScriptCompletedReturnTargetKind.EXPRESSION_ARROW + }, + ) ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "EtsIR does not identify one requested return statement", + ) + + SourceTargetObservation.CompletedReturn(returnStatement) + } + } + + return CallsTargetPreparation.Eligible( + scene = scene, + method = method, + inputBindings = mappedTarget.bindings.inputs, + lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment, + targetObservation = targetObservation, + exactTargetCandidateCount = exactTargetCandidates.size, ) } @@ -199,29 +410,77 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { origin.endColumn == target.end.column } - private fun resolveScalarInputs(state: TsState, method: EtsMethod): List = with(state.ctx) { - val model = state.models.single() + private fun completedReturnCandidate( + method: EtsMethod, + target: CallsSourceTarget, + exactTargetCandidates: List, + allowsUniqueOriginlessReturn: () -> Boolean, + ): EtsReturnStmt? { + val exactReturns = exactTargetCandidates.filterIsInstance() + if (exactReturns.isNotEmpty()) { + return exactReturns.singleOrNull() + } - method.parameters.mapIndexed { index, parameter -> - val stackIndex = index + 1 - when (parameter.type) { - EtsNumberType -> { - val lValue = mkRegisterStackLValue(fp64Sort, stackIndex) - val value = model.eval(state.memory.read(lValue).asExpr(fp64Sort)).extractDouble() - JsConcreteValue.number(value) - } + val expressionStart = target.returnExpressionStartOffset ?: return null + val expressionEnd = target.returnExpressionEndOffset ?: return null + val targetIsExpression = target.startOffset == expressionStart && target.endOffset == expressionEnd + if (!targetIsExpression) { + return null + } - EtsBooleanType -> { - val lValue = mkRegisterStackLValue(boolSort, stackIndex) - val value = model.eval(state.memory.read(lValue).asExpr(boolSort)).toConcreteBoolValue() - JsConcreteValue.Boolean(value) - } + val containingReturns = method.cfg.stmts.filterIsInstance().filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + } + val smallestContainingRange = containingReturns.minOfOrNull { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset + } + if (smallestContainingRange != null) { + val smallestReturns = containingReturns.filter { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset == smallestContainingRange + } + if (smallestReturns.isNotEmpty()) { + return smallestReturns.singleOrNull() + } + } - else -> { - error("Unsupported scalar parameter type: ${parameter.type}") - } + val containingStatements = method.cfg.stmts.filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + } + val smallestStatementRange = containingStatements.minOfOrNull { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset + } ?: return if (allowsUniqueOriginlessReturn()) { + method.cfg.stmts.filterIsInstance().singleOrNull() + } else { + null + } + val anchors = containingStatements.filter { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset == smallestStatementRange + } + + val reachableReturns = mutableSetOf() + val visited = mutableSetOf() + val pending = ArrayDeque() + pending.addAll(anchors) + while (pending.isNotEmpty()) { + val statement = pending.removeFirst() + if (!visited.add(statement)) { + continue + } + if (statement is EtsReturnStmt) { + reachableReturns += statement + continue } + + pending.addAll(method.cfg.successors(statement)) } + + return reachableReturns.singleOrNull() } private fun result( @@ -238,22 +497,34 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { diagnostic = diagnostic, ) - private fun verifyNativeFrontendOnce(expectedRevision: String) { - require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { + internal fun verifyNativeFrontendOnce(expectedRevision: String) { + val configuredScript = environment("ETS_FRONTEND_SCRIPT") + require(configuredScript == null) { "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" } - val configuredFrontend = requireNotNull(System.getenv("ETS_FRONTEND_DIR")) { + if (expectedRevision.startsWith(BUNDLED_FRONTEND_PREFIX)) { + require(environment("ETS_FRONTEND_DIR") == null) { + "ETS_FRONTEND_DIR must be unset when the bundled native frontend is selected" + } + require(expectedRevision == bundledNativeFrontendRevision) { + "Bundled native frontend revision $expectedRevision does not match running build " + + bundledNativeFrontendRevision + } + verifiedNativeFrontendIdentity = expectedRevision + return + } + + val configuredFrontend = requireNotNull(environment("ETS_FRONTEND_DIR")) { "ETS_FRONTEND_DIR is required to verify the frozen native frontend revision" } val frontendDirectory = Path.of(configuredFrontend).toRealPath() - val cached = verifiedNativeFrontend - val expectedIdentity = expectedRevision - if (cached == Pair(frontendDirectory, expectedIdentity)) { + val expectedIdentity = "$frontendDirectory@$expectedRevision" + if (verifiedNativeFrontendIdentity == expectedIdentity) { return } verifyCallsGitCheckout(frontendDirectory, expectedRevision) - verifiedNativeFrontend = frontendDirectory to expectedIdentity + verifiedNativeFrontendIdentity = expectedIdentity } private fun verifyGitCheckoutOnce( @@ -269,22 +540,132 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { cache[checkout] = expectedRevision } + private sealed interface CallsTargetPreparation { + data class Eligible( + val scene: EtsScene, + val method: EtsMethod, + val inputBindings: List, + val lexicalEnvironment: EtsLexicalEnvironmentBinding?, + val targetObservation: SourceTargetObservation, + val exactTargetCandidateCount: Int, + ) : CallsTargetPreparation + + data class Rejected( + val status: CallsSymbolicStatus, + val reasonCode: CallsSymbolicPreflightReasonCode, + val diagnostic: String, + ) : CallsTargetPreparation + } + + private sealed interface SourceTargetObservation { + fun createObserver(method: EtsMethod): SourceTargetObserver + + data class Entry(val entry: SourceStatementEntry) : SourceTargetObservation { + override fun createObserver(method: EtsMethod): SourceTargetObserver = + SourceStatementEntryObserver(entry) + } + + data class CompletedReturn(val statement: EtsReturnStmt) : SourceTargetObservation { + override fun createObserver(method: EtsMethod): SourceTargetObserver = + SourceCompletedReturnObserver(method = method, target = statement) + } + } + + private interface SourceTargetObserver : UMachineObserver { + val reachedStates: List + val loweringSize: Int + } + private class SourceStatementEntryObserver( - private val target: EtsStmt, - ) : UMachineObserver { - val reachedStates = mutableListOf() + private val target: SourceStatementEntry, + ) : SourceTargetObserver { + override val reachedStates = mutableListOf() + override val loweringSize: Int = target.loweringSize override fun onStatePeeked(state: TsState) { - if (state.currentStatement == target) { + if (state.currentStatement == target.statement) { reachedStates += state.clone() } } } + private class SourceCompletedReturnObserver( + private val method: EtsMethod, + private val target: EtsReturnStmt, + ) : SourceTargetObserver { + override val reachedStates = mutableListOf() + override val loweringSize: Int = 1 + + override fun onState(state: TsState, forks: Sequence) { + sequenceOf(state).plus(forks) + .filter { candidate -> candidate.currentStatement == target } + .filter { candidate -> candidate.callStack.isEmpty() } + .filter { candidate -> + val result = candidate.methodResult + result is TsMethodResult.Success.RegularCall && result.method == method + } + .mapTo(reachedStates) { candidate -> candidate.clone() } + } + } + + private class UnknownCallEventSinkObserver( + private val sink: ((TsUnknownCallEvent) -> Unit)?, + private val runtimeLimitationSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)?, + ) : TsInterpreterObserver { + val runtimeLimitations = linkedSetOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + sink?.invoke(event) + } + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + runtimeLimitations += event.reason.name + runtimeLimitationSink?.invoke(event) + } + } + private data class MachineResult( val states: List, val stopReason: TsAnalysisStopReason, ) + + private companion object { + const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" + + val SUPPORTED_BUILTIN_CAPTURES: Set = setOf( + "Array", + "Boolean", + "Date", + "Error", + "Infinity", + "Map", + "Math", + "NaN", + "Number", + "Object", + "RangeError", + "Set", + "String", + "TypeError", + "isNaN", + ) + } +} + +private fun CallsSymbolicSearchRequest.toPreflightRequest() = CallsSymbolicPreflightRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + expectedNativeFrontendRevision = expectedNativeFrontendRevision, +) + +private fun CallsSymbolicStatus.toPreflightStatus(): CallsSymbolicPreflightStatus = when (this) { + CallsSymbolicStatus.UNSUPPORTED -> CallsSymbolicPreflightStatus.UNSUPPORTED + CallsSymbolicStatus.UNMAPPED -> CallsSymbolicPreflightStatus.UNMAPPED + CallsSymbolicStatus.AMBIGUOUS -> CallsSymbolicPreflightStatus.AMBIGUOUS + CallsSymbolicStatus.TOOL_ERROR -> CallsSymbolicPreflightStatus.TOOL_ERROR + else -> error("Symbolic execution status $this is not a preflight result") } internal data class SourceStatementEntry( @@ -368,3 +749,10 @@ private fun EtsMappingStatus.toSymbolicStatus(): CallsSymbolicStatus = when (thi EtsMappingStatus.UNMAPPED -> CallsSymbolicStatus.UNMAPPED EtsMappingStatus.UNSUPPORTED -> CallsSymbolicStatus.UNSUPPORTED } + +private fun EtsMappingStatus.toPreflightReasonCode(): CallsSymbolicPreflightReasonCode = when (this) { + EtsMappingStatus.EXACT -> error("Exact mapping has no exclusion reason") + EtsMappingStatus.AMBIGUOUS -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_AMBIGUOUS + EtsMappingStatus.UNMAPPED -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_UNMAPPED + EtsMappingStatus.UNSUPPORTED -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_UNSUPPORTED +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index d4307c6080..97f4fdb237 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -2,8 +2,14 @@ package org.usvm.ts.calls import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput @@ -17,6 +23,82 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CallsExperimentTest { + @Test + fun `runner persists call events before search returns without counting them as results`(@TempDir directory: Path) { + val source = Path.of(checkNotNull(javaClass.getResource("/calls/SourceTargetReplayFixture.ts")).toURI()) + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val method = file.allClasses.flatMap { cls -> cls.methods } + .single { candidate -> candidate.name == "completesReturnExpression" } + val callSite = method.cfg.stmts.first { statement -> statement.location.origin != null } + val rawOutput = directory.resolve("results.jsonl") + val partialOutput = directory.resolve("results.jsonl.partial") + val event = TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH), + ) + val engine = CallsSymbolicEngine { request -> + checkNotNull(request.unknownCallEventSink).invoke(event) + val appended = readRecords(partialOutput).last() as CallsUnknownCallRecord + assertEquals(request.profile, appended.cell.profile) + assertEquals(1, appended.eventIndex) + + result(status = CallsSymbolicStatus.UNREACHED) + } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + val records = readRecords(rawOutput) + assertEquals(4, records.filterIsInstance().size) + assertEquals(4, CallsExperimentAggregator.summarize(rawOutput).resultRows) + } + + @Test + fun `legacy source target defaults to entry mode`() { + val encoded = """ + { + "targetId": "target", + "siteId": "site", + "sourcePath": "fixture.ts", + "startOffset": 0, + "endOffset": 6, + "start": { "line": 0, "column": 0 }, + "end": { "line": 0, "column": 6 } + } + """.trimIndent() + + val target = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(CallsSourceTargetMode.ENTRY, target.mode) + assertNull(target.returnExpressionStartOffset) + assertNull(target.returnExpressionEndOffset) + } + + @Test + fun `completed return target preserves explicit expression offsets`() { + val entryTarget = manifest(sourceRoot = ".", seeds = listOf(17L)) + .projects.single().functions.single().targets.single() + val completedReturn = entryTarget.copy( + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpressionStartOffset = 7, + returnExpressionEndOffset = 10, + ) + + val encoded = CallsExperimentJson.json.encodeToString(completedReturn) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(completedReturn, decoded) + } + @Test fun `manifest rejects breadth first search`() { val accepted = manifest(sourceRoot = ".", seeds = listOf(17L)) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt index 7f764c3a7c..5f485bd835 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt @@ -53,6 +53,185 @@ class CallsSourceReplayTest { assertEquals(true, invoked.invocation?.targetHit) } + @Test + fun `completed return marks only after its full expression evaluates`() { + val fixture = fixture() + val target = fixture.target( + functionName = "completesReturnExpression", + statement = "return /* before expression */ value.trim() /* after expression */", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value.trim()", + ) + + val replay = fixture.replay( + exportName = "completesReturnExpression", + inputs = listOf(JsConcreteValue.String(" value ")), + target = target, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("returned", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects when its expression throws`() { + val fixture = fixture() + val target = fixture.target( + functionName = "expressionThrowsBeforeReturnCompletes", + statement = "return throwFromExpression();", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "throwFromExpression()", + ) + + val replay = fixture.replay( + exportName = "expressionThrowsBeforeReturnCompletes", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(false, replay.invocation?.targetHit) + assertEquals("expression failed", replay.invocation?.errorMessage) + } + + @Test + fun `completed return rejects when a finally block throws`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyThrowsAfterReturnExpression", + statement = "return 'value'.trim();", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "'value'.trim()", + ) + + val replay = fixture.replay( + exportName = "finallyThrowsAfterReturnExpression", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(false, replay.invocation?.targetHit) + assertEquals("finally failed", replay.invocation?.errorMessage) + } + + @Test + fun `completed bare return tolerates comments and omitted semicolon`() { + val fixture = fixture() + val target = fixture.target( + functionName = "completesBareReturn", + statement = "return /* no expression */", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val replay = fixture.replay(exportName = "completesBareReturn", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("returned", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + } + + @Test + fun `completed bare return preserves an untaken unbraced branch`() { + val fixture = fixture() + val target = fixture.target( + functionName = "bareConditionalCompletedReturn", + statement = "return;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val replay = fixture.replay( + exportName = "bareConditionalCompletedReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return ignores a recursive child hit`() { + val fixture = fixture() + val target = fixture.target( + functionName = "recursiveCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "recursiveCompletedReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects a partial expression target`() { + val fixture = fixture() + val target = fixture.target( + functionName = "partialCaughtReturn", + statement = "value", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value", + ) + + val replay = fixture.replay( + exportName = "partialCaughtReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.UNMAPPED, replay.status, replay.toString()) + } + + @Test + fun `completed return rejects when a finally return overrides it`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyOverridesCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "finallyOverridesCompletedReturn", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects when finally breaks to implicit fallthrough`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyBreaksCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "finallyBreaksCompletedReturn", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + private fun fixture(): Fixture { val sourcePath = resourcePath("/calls/SourceTargetReplayFixture.ts") @@ -76,11 +255,28 @@ class CallsSourceReplayTest { val sourcePath: Path, val source: String, ) { - fun target(functionName: String, statement: String): CallsSourceTarget { + fun target( + functionName: String, + statement: String, + mode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + returnExpression: String? = null, + ): CallsSourceTarget { val functionStart = source.indexOf("function $functionName") - val startOffset = source.indexOf(statement, startIndex = functionStart) - check(functionStart >= 0 && startOffset >= 0) { "Missing $statement in $functionName" } + check(functionStart >= 0) { "Missing function $functionName" } + val functionBodyStart = source.indexOf('{', startIndex = functionStart) + val startOffset = source.indexOf(statement, startIndex = functionBodyStart) + check(functionBodyStart >= 0 && startOffset >= 0) { + "Missing $statement in $functionName" + } val endOffset = startOffset + statement.length + val returnExpressionStartOffset = returnExpression?.let { expression -> + source.indexOf(expression, startIndex = startOffset).also { expressionStart -> + check(expressionStart in startOffset until endOffset) { + "Missing return expression $expression in $statement" + } + } + } + val returnExpressionEndOffset = returnExpressionStartOffset?.plus(requireNotNull(returnExpression).length) return CallsSourceTarget( targetId = "$functionName#$statement", @@ -90,6 +286,9 @@ class CallsSourceReplayTest { endOffset = endOffset, start = sourcePositionAt(source = source, offset = startOffset), end = sourcePositionAt(source = source, offset = endOffset), + mode = mode, + returnExpressionStartOffset = returnExpressionStartOffset, + returnExpressionEndOffset = returnExpressionEndOffset, ) } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt new file mode 100644 index 0000000000..82004909cf --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt @@ -0,0 +1,85 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallOutcome +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CallsUnknownCallTelemetryTest { + @Test + fun `sink converts unknown call events into ordered serializable cell records`() { + val source = resourcePath("/calls/SourceTargetReplayFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val method = file.allClasses.flatMap { cls -> cls.methods } + .single { candidate -> candidate.name == "completesReturnExpression" } + val callSite = method.cfg.stmts.first { statement -> statement.location.origin != null } + val cell = CallsExperimentCellIdentity( + experimentId = "experiment", + projectId = "project", + revision = "revision", + development = true, + functionId = "function", + targetId = "target", + siteId = "site", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 17L, + ) + val records = mutableListOf() + val sink = callsUnknownCallEventSink(cell = cell, appendAndFlush = records::add) + + sink( + TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + decision = TsUnknownCallDecision.ModelApplied(modelId = "ts.array.isArray"), + ) + ) + sink( + TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH), + ) + ) + + assertEquals(listOf(1, 2), records.map { record -> record.eventIndex }) + val modeled = records.first() + assertEquals(cell, modeled.cell) + assertEquals(source.fileName.toString(), modeled.callSite.sourcePath) + assertNotNull(modeled.callSite.start) + assertEquals("PARTIAL_APPROXIMATION", modeled.failureReason) + assertEquals(CallsUnknownCallDecisionKind.MODEL_APPLIED, modeled.decision) + assertEquals(TsUnknownCallOutcome.MODEL_APPLIED.name, modeled.outcome) + assertEquals("ts.array.isArray", modeled.modelId) + assertNull(modeled.residualPolicy) + + val residual = records.last() + assertEquals(CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK, residual.decision) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED.name, residual.outcome) + assertNull(residual.modelId) + assertEquals(TsResidualCallPolicy.STOP_PATH.name, residual.residualPolicy) + + val encoded = CallsExperimentJson.json.encodeToString(modeled) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) + assertEquals(modeled, assertIs(decoded)) + } + + private fun resourcePath(name: String): Path { + val resource = checkNotNull(javaClass.getResource(name)) { "Missing test resource: $name" } + return Path.of(resource.toURI()) + } +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt new file mode 100644 index 0000000000..03bf910e9e --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -0,0 +1,940 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.io.TempDir +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +private const val ERROR_CONSTRUCTOR_MODEL_ID: String = "ts.error.constructor" + +@Suppress("LargeClass") +class CurrentTsCallsSymbolicEngineTest { + @TempDir + lateinit var directory: Path + + @Test + fun `runtime limitation is reported instead of an unreached target`() { + val fixture = fixture( + source = """ + export function writesNamedProperty(value: number): boolean { + const values = [1]; + values[0.5] = value; + return true; + } + """.trimIndent(), + exportName = "writesNamedProperty", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val limitations = mutableListOf() + + val result = fixture.search( + modelIds = emptySet(), + runtimeLimitationEventSink = limitations::add, + ) + + assertEquals(CallsSymbolicStatus.RUNTIME_LIMITATION, result.status, result.toString()) + assertEquals(TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, limitations.single().reason) + assertTrue(result.diagnostic.orEmpty().contains("ARRAY_NAMED_PROPERTY_WRITE")) + } + + @Test + fun `bundled frontend accepts only the revision baked into the running build`() { + val engine = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:published-jacodb", + ) + + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + + assertFailsWith { + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:different-jacodb") + } + } + + @Test + fun `bundled frontend rejects native frontend environment overrides`() { + val environment = mapOf("ETS_FRONTEND_DIR" to "/unused/frontend") + val engine = CurrentTsCallsSymbolicEngine( + environment = environment::get, + bundledNativeFrontendRevision = "bundled:published-jacodb", + ) + + assertFailsWith { + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + } + } + + @Test + fun `extracts nonempty generic number array containing zero and replays source`() { + val fixture = fixture( + source = """ + export function includesZero(values: T[], value: T): boolean { + if (value === 0 && values.indexOf(value) >= 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "includesZero", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + PropertyInput(name = "value", domain = NumberDomain()), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.indexOf", "ts.math.floor")) + + val inputs = assertNotNull(result.inputs, result.toString()) + val values = assertIs(inputs[0]).elements + val searchValue = assertIs(inputs[1]).toDouble() + assertTrue(values.isNotEmpty()) + assertTrue(searchValue == 0.0) + assertTrue(values.any { element -> assertIs(element).toDouble() == 0.0 }) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts nonempty number array with absent NaN and replays source`() { + val fixture = fixture( + source = """ + export function excludesNaN(values: number[], value: number): boolean { + if (values.length > 0 && Number.isNaN(value) && values.indexOf(value) < 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "excludesNaN", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + PropertyInput(name = "value", domain = NumberDomain()), + ), + targetStatement = "return true;", + ) + + val unknownCalls = mutableListOf() + val result = fixture.search( + modelIds = setOf("ts.array.indexOf", "ts.math.floor", "ts.number.isNaN"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertTrue(assertIs(inputs[0]).elements.isNotEmpty()) + assertTrue(assertIs(inputs[1]).toDouble().isNaN()) + assertTrue(unknownCalls.isNotEmpty()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts constrained UTF-16 string and replays source`() { + val fixture = fixture( + source = """ + export function isGrinningFace(value: string): boolean { + if (value.length === 2 && value.charCodeAt(0) === 0xD83D && value.charCodeAt(1) === 0xDE00) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "isGrinningFace", + inputs = listOf( + PropertyInput(name = "value", domain = StringDomain(minLength = 1, maxLength = 4)), + ), + targetStatement = "return true;", + ) + + val result = fixture.search( + modelIds = setOf( + "ts.string.charCodeAt", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.length", + ), + ) + + val inputs = assertNotNull(result.inputs, result.toString()) + val value = assertIs(inputs.single()).value + assertEquals(2, value.length) + assertEquals("😀", value) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts nonempty string required by value equality and replays source`() { + val fixture = fixture( + source = """ + export function equalsA(value: string): boolean { + if (value === "a") { + return true; + } + return false; + } + """.trimIndent(), + exportName = "equalsA", + inputs = listOf( + PropertyInput(name = "value", domain = StringDomain(minLength = 1, maxLength = 4)), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals("a", assertIs(inputs.single()).value) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `completed return observes normal expression-bodied arrow completion`() { + val expression = "value > 0" + val fixture = fixture( + source = "export const isPositive = (value: number): boolean => $expression;", + exportName = "isPositive", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = expression, + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = expression, + ) + + val preflight = fixture.preflight() + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + val result = fixture.search(modelIds = emptySet()) + + val inputs = assertNotNull(result.inputs, result.toString()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `completed return rejects a partial expression target without an origin`() { + val fixture = fixture( + source = "export const isPositive = (value: number): boolean => value > 0;", + exportName = "isPositive", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "0", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "0", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `completed return rejects the first line of a multiline arrow expression`() { + val fixture = fixture( + source = """ + export const increment = (value: number): number => value + + 1; + """.trimIndent(), + exportName = "increment", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "value\n", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value\n", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `completed return rejects a partial ordinary return expression`() { + val fixture = fixture( + source = "export function increment(value: number): number { return value + 1; }", + exportName = "increment", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "1", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "1", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `completed return accepts a module-local arrow exported by clause`() { + val expression = "value > 0" + val fixture = fixture( + source = "const aliasedArrow = (value: number): boolean => $expression; export { aliasedArrow };", + exportName = "aliasedArrow", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = expression, + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = expression, + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + } + + @Test + fun `preflight rejects source coordinates that do not match offsets`() { + val fixture = fixture( + source = "export function identity(value: number): number { return value; }", + exportName = "identity", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return value;", + ) + val malformedTarget = fixture.target.copy( + start = fixture.target.start.copy(column = fixture.target.start.column + 1), + ) + + val preflight = fixture.copy(target = malformedTarget).preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNMAPPED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, preflight.reasonCode) + } + + @Test + fun `preflight rejects return expression offsets outside the target`() { + val fixture = fixture( + source = "export function identity(value: number): number { return value; }", + exportName = "identity", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return value;", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + val malformedTarget = fixture.target.copy( + returnExpressionStartOffset = fixture.target.endOffset, + returnExpressionEndOffset = fixture.target.endOffset + 1, + ) + + val preflight = fixture.copy(target = malformedTarget).preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNMAPPED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, preflight.reasonCode) + } + + @Test + fun `rejects arbitrary lexical runtime capture before search`() { + val fixture = fixture( + source = """ + let capturesThreshold: (value: number) => boolean; + { + const threshold = 3; + capturesThreshold = (value: number): boolean => value > threshold; + } + export { capturesThreshold }; + """.trimIndent(), + exportName = "capturesThreshold", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "value > threshold", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, result.reasonCode) + assertTrue(result.diagnostic.orEmpty().contains("threshold")) + } + + @Test + fun `accepts genuine builtin lexical captures before search`() { + val fixture = fixture( + source = """ + export const usesBuiltinGlobals = (value: number): boolean => { + const map = new Map>([[value, new Set([value])]]); + const errors = [new Error(), new RangeError(), new TypeError()]; + if (Date.now() >= 0 || value === Infinity || value === NaN || isNaN(value)) { + return true; + } + return map.size === errors.length; + }; + """.trimIndent(), + exportName = "usesBuiltinGlobals", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight rejects regex raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function containsA(value: string): boolean { + if (/a/.test(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "containsA", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain(maxLength = 3))), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects regex raw entities in a reachable same-file helper`() { + val fixture = fixture( + source = """ + function helper(): boolean { + return /x/.test("x"); + } + + export function callsHelper(value: number): boolean { + return helper() && value > 0; + } + """.trimIndent(), + exportName = "callsHelper", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return helper() && value > 0;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight ignores regex raw entities in an unreachable same-file helper`() { + val fixture = fixture( + source = """ + function unusedHelper(): boolean { + return /x/.test("x"); + } + + export function ignoresHelper(value: number): boolean { + if (value > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "ignoresHelper", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight rejects spread raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function copies(values: number[]): boolean { + const copy = [...values]; + if (copy.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "copies", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.SPREAD_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects destructuring raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function readsHead(values: number[]): boolean { + const [head, ...tail] = values; + if (head === 1 && tail.length >= 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "readsHead", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.DESTRUCTURING_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects nested runtime lexical environments`() { + val fixture = fixture( + source = """ + export function invokesClosure(value: number): boolean { + const predicate = () => value > 0; + if (predicate()) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "invokesClosure", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.LEXICAL_ENVIRONMENT_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects prototype property access`() { + val fixture = fixture( + source = """ + export function readsPrototype(): boolean { + if (Object.prototype !== undefined) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "readsPrototype", + inputs = emptyList(), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.PROTOTYPE_ACCESS_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects symbolic number to string conversion`() { + val fixture = fixture( + source = """ + export function formatsValue(value: number): boolean { + const text = "value=" + value; + if (text.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "formatsValue", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects exponentiation before scheduling profiles`() { + val fixture = fixture( + source = """ + export function cubeRoot(value: number): boolean { + if (value ** (1 / 3) === 2) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "cubeRoot", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.EXPONENTIATION_UNSUPPORTED, result.reasonCode) + } + + @Test + fun `preflight checks a global regex initializer reached by a static field read`() { + val fixture = fixture( + source = """ + export const pattern = /abc/; + export function matches(value: string): boolean { + if (pattern.test(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "matches", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode) + } + + @Test + fun `initializes captured Math for modeled arrow search and replay`() { + val fixture = fixture( + source = """ + export const floorsToThree = (value: number): boolean => { + if (Math.floor(value) === 3) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "floorsToThree", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.math.floor"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + val value = assertIs(inputs.single()).toDouble() + assertEquals(3.0, kotlin.math.floor(value)) + assertTrue( + unknownCalls.any { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.math.floor" + }, + ) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `initializes captured Number for modeled arrow search and replay`() { + val fixture = fixture( + source = """ + export const isIntegerThree = (value: number): boolean => { + if (Number.isInteger(value) && value > 2 && value < 4) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "isIntegerThree", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.number.isInteger"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertEquals(3.0, assertIs(inputs.single()).toDouble()) + assertTrue( + unknownCalls.any { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.number.isInteger" + }, + ) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `does not model user defined Math and Number receivers`() { + val fixture = fixture( + source = """ + class UserMath { + floor(value: number): number { + return value + 1; + } + } + + class UserNumber { + isInteger(value: number): boolean { + return value === 2; + } + } + + export function usesShadowedGlobals(value: number): boolean { + const Math = new UserMath(); + const Number = new UserNumber(); + if (Math.floor(value) === 3 && Number.isInteger(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "usesShadowedGlobals", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.math.floor", "ts.number.isInteger"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertEquals(2.0, assertIs(inputs.single()).toDouble()) + assertTrue(unknownCalls.none { event -> event.decision is TsUnknownCallDecision.ModelApplied }) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts original array before mutating shift and replays source`() { + val fixture = fixture( + source = """ + export function removesSeventeen(values: number[]): boolean { + const removed = values.shift(); + if (removed === 17) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "removesSeventeen", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.shift")) + + val inputs = assertNotNull(result.inputs, result.toString()) + val values = assertIs(inputs.single()).elements + assertTrue(values.isNotEmpty()) + assertEquals(17.0, assertIs(values.first()).toDouble()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `Error constructor model reaches throw and native replay preserves the exception`() { + val fixture = fixture( + source = """ + export function rejectsZero(value: number): boolean { + if (value === 0) { + throw new Error('expected message'); + } + return false; + } + """.trimIndent(), + exportName = "rejectsZero", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "throw new Error('expected message');", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf(ERROR_CONSTRUCTOR_MODEL_ID), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertTrue(assertIs(inputs.single()).toDouble() == 0.0) + assertEquals( + listOf(ERROR_CONSTRUCTOR_MODEL_ID), + unknownCalls.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + }, + unknownCalls.toString(), + ) + + val replay = fixture.replay(inputs) + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + assertEquals("Error", replay.invocation?.errorName) + assertEquals("expected message", replay.invocation?.errorMessage) + } + + private fun fixture( + source: String, + exportName: String, + inputs: List, + targetStatement: String, + targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + returnExpression: String? = null, + ): SymbolicFixture { + val sourceRoot = Files.createDirectory(directory.resolve(exportName)) + val sourceFile = sourceRoot.resolve("Fixture.ts") + Files.writeString(sourceFile, source) + runGit(sourceRoot, "init") + runGit(sourceRoot, "config", "user.name", "USVM Tests") + runGit(sourceRoot, "config", "user.email", "usvm@example.test") + runGit(sourceRoot, "add", "Fixture.ts") + runGit(sourceRoot, "commit", "-m", "fixture") + val revision = runGit(sourceRoot, "rev-parse", "HEAD").trim() + val targetStart = source.indexOf(targetStatement) + check(targetStart >= 0) { "Missing target statement: $targetStatement" } + val targetEnd = targetStart + targetStatement.length + val returnExpressionStart = returnExpression?.let { expression -> + source.indexOf(expression, startIndex = targetStart).takeIf { offset -> + offset in targetStart until targetEnd + } + ?: error("Missing return expression inside target: $expression") + } + val target = CallsSourceTarget( + targetId = "$exportName#$targetStatement", + siteId = "$exportName:$targetStart:$targetEnd", + sourcePath = "Fixture.ts", + startOffset = targetStart, + endOffset = targetEnd, + start = sourcePositionAt(source = source, offset = targetStart), + end = sourcePositionAt(source = source, offset = targetEnd), + mode = targetMode, + returnExpressionStartOffset = returnExpressionStart, + returnExpressionEndOffset = returnExpressionStart?.plus(requireNotNull(returnExpression).length), + ) + val function = CallsFunctionCase( + functionId = exportName, + sourceFile = "Fixture.ts", + entryPoint = TypeScriptEntryPoint(module = "Fixture.ts", exportName = exportName), + inputs = inputs, + targets = listOf(target), + ) + val project = CallsProjectCase( + projectId = exportName, + revision = revision, + sourceRoot = ".", + development = true, + functions = listOf(function), + ) + + return SymbolicFixture( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + ) + } + + private fun runGit(directory: Path, vararg arguments: String): String { + val process = ProcessBuilder(listOf("git", "-C", directory.toString()) + arguments) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + check(process.waitFor() == 0) { "Git ${arguments.joinToString()} failed: $output" } + return output + } + + private data class SymbolicFixture( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val target: CallsSourceTarget, + ) { + private val engine = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:test", + ) + + fun preflight(): CallsSymbolicPreflightResult = engine.preflight( + CallsSymbolicPreflightRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + expectedNativeFrontendRevision = "bundled:test", + ) + ) + + fun search( + modelIds: Set, + unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + runtimeLimitationEventSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)? = null, + ): CallsSymbolicSearchResult = engine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = CallsExperimentProfile.FROZEN_STOP, + frozenModelIds = modelIds, + expectedNativeFrontendRevision = "bundled:test", + seed = 0, + budget = 10.seconds, + unknownCallEventSink = unknownCallEventSink, + runtimeLimitationEventSink = runtimeLimitationEventSink, + ) + ) + + fun assertReplayConfirmed(inputs: List) { + val replay = replay(inputs) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + } + + fun replay(inputs: List): CallsSourceReplayResult = + OriginalTypeScriptTargetReplayer().replay( + sourceRoots = listOf(sourceRoot), + entryPoint = function.entryPoint, + inputs = inputs, + target = target, + timeoutMillis = 10_000L, + ) + } +} diff --git a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index dd20e17a49..3cddbf802e 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -13,3 +13,69 @@ const importedTargetValue = importOnlyTarget(); export function skipsImportOnlyTarget(): number { return importedTargetValue; } + +export function completesReturnExpression(value: string): string { + return /* before expression */ value.trim() /* after expression */ +} + +function throwFromExpression(): never { + throw new Error('expression failed'); +} + +export function expressionThrowsBeforeReturnCompletes(): string { + return throwFromExpression(); +} + +export function finallyThrowsAfterReturnExpression(): string { + try { + return 'value'.trim(); + } finally { + throw new Error('finally failed'); + } +} + +export function completesBareReturn(): void { + return /* no expression */ +} + +export function bareConditionalCompletedReturn(value: number): void { + if (value > 0) return; +} + +export function recursiveCompletedReturn(value: number): number { + if (value === 0) { + return 11; + } + recursiveCompletedReturn(0); + return 22; +} + +function partialReturnFailure(): never { + throw new Error('partial return failed'); +} + +export function partialCaughtReturn(value: number): number { + try { + return value + partialReturnFailure(); + } catch { + return 0; + } +} + +export function finallyOverridesCompletedReturn(): number { + try { + return 11; + } finally { + return 22; + } +} + +export function finallyBreaksCompletedReturn(): number | undefined { + while (true) { + try { + return 11; + } finally { + break; + } + } +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts new file mode 100644 index 0000000000..9d1fd354f8 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts @@ -0,0 +1,314 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +const INSPECT_COMPLETED_RETURN = 'inspect-completed-return' +const INSTRUMENT_COMPLETED_RETURN = 'instrument-completed-return' +const NONE = '-' + +function fail(message: string): never { + process.stderr.write(`${message}\n`) + process.exit(2) +} + +function parseOffset(text: string | undefined, label: string): number { + const offset = Number(text) + if (!Number.isSafeInteger(offset)) fail(`${label} must be a safe integer`) + return offset +} + +function parseOptionalOffset(text: string | undefined, label: string): number | undefined { + if (text === NONE) return undefined + return parseOffset(text, label) +} + +const [operation, sourcePath, exportName, targetStartText, targetEndText, expressionStartText, expressionEndText, marker] = + process.argv.slice(2) +if ( + operation === undefined || sourcePath === undefined || exportName === undefined || targetStartText === undefined || + targetEndText === undefined || expressionStartText === undefined || expressionEndText === undefined +) { + fail('Expected operation, source path, export name, target offsets, and expression offsets') +} +if (operation !== INSPECT_COMPLETED_RETURN && operation !== INSTRUMENT_COMPLETED_RETURN) { + fail(`Unknown source-inspector operation: ${operation}`) +} +if (operation === INSTRUMENT_COMPLETED_RETURN && marker === undefined) { + fail('Completed-return instrumentation requires a marker') +} +const requiredSourcePath = sourcePath ?? fail('Missing source path') +const requiredExportName = exportName ?? fail('Missing export name') + +const targetStart = parseOffset(targetStartText, 'Target start offset') +const targetEnd = parseOffset(targetEndText, 'Target end offset') +const expressionStart = parseOptionalOffset(expressionStartText, 'Expression start offset') +const expressionEnd = parseOptionalOffset(expressionEndText, 'Expression end offset') +if ((expressionStart === undefined) !== (expressionEnd === undefined)) { + fail('Expression offsets must be both present or both absent') +} + +const source = readFileSync(requiredSourcePath, 'utf8') +const diagnostics = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.Latest }, + fileName: requiredSourcePath, + reportDiagnostics: true, +}).diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? [] +if (diagnostics.length > 0) { + fail(`Cannot parse TypeScript source: ${ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')}`) +} + +const sourceFile = ts.createSourceFile(requiredSourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + +type SupportedCallable = ts.FunctionDeclaration | ts.ArrowFunction + +interface CallableOwner { + callable: SupportedCallable + body: ts.ConciseBody + declaration: ts.Statement +} + +interface CompletedReturnTarget { + kind: 'expression-arrow' | 'return-statement' + owner: CallableOwner + returnStatement?: ts.ReturnStatement +} + +interface Edit { + start: number + end: number + replacement: string +} + +function hasExportModifier(node: ts.Node & { modifiers?: ts.NodeArray }): boolean { + return node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true +} + +function exportedLocalNames(name: string): Set { + const names = new Set() + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === name && hasExportModifier(statement)) { + names.add(name) + } + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + if (statement.declarationList.declarations.some((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === name + )) { + names.add(name) + } + } + if ( + ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause) + ) { + for (const specifier of statement.exportClause.elements) { + if (!specifier.isTypeOnly && specifier.name.text === name) { + names.add(specifier.propertyName?.text ?? specifier.name.text) + } + } + } + } + + return names +} + +function exportedCallableOwners(name: string): CallableOwner[] { + const localNames = exportedLocalNames(name) + const owners: CallableOwner[] = [] + + for (const statement of sourceFile.statements) { + if ( + ts.isFunctionDeclaration(statement) && statement.body !== undefined && statement.name !== undefined && + localNames.has(statement.name.text) + ) { + owners.push({ callable: statement, body: statement.body, declaration: statement }) + } + if (!ts.isVariableStatement(statement)) continue + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !localNames.has(declaration.name.text)) continue + if (declaration.initializer === undefined || !ts.isArrowFunction(declaration.initializer)) continue + + owners.push({ callable: declaration.initializer, body: declaration.initializer.body, declaration: statement }) + } + } + + return owners.filter((owner, index) => owners.findIndex((candidate) => candidate.callable === owner.callable) === index) +} + +function nestedFunction(node: ts.Node, owner: SupportedCallable): boolean { + return node !== owner && ts.isFunctionLike(node) +} + +function returnStatements(owner: CallableOwner): ts.ReturnStatement[] { + if (!ts.isBlock(owner.body)) return [] + + const returns: ts.ReturnStatement[] = [] + function visit(node: ts.Node): void { + if (nestedFunction(node, owner.callable)) return + if (ts.isReturnStatement(node)) { + returns.push(node) + return + } + ts.forEachChild(node, visit) + } + visit(owner.body) + + return returns +} + +function onlyTriviaAndOptionalSemicolon(start: number, end: number): boolean { + if (start > end) return false + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + true, + ts.LanguageVariant.Standard, + source.slice(start, end), + ) + let semicolons = 0 + while (true) { + const token = scanner.scan() + if (token === ts.SyntaxKind.EndOfFileToken) return true + if (token !== ts.SyntaxKind.SemicolonToken || ++semicolons > 1) return false + } +} + +function matchesReturnStatement(statement: ts.ReturnStatement): boolean { + if (statement.getStart(sourceFile, false) !== targetStart) return false + + const expression = statement.expression + if (expression === undefined) { + if (expressionStart !== undefined || expressionEnd !== undefined) return false + const returnKeywordEnd = targetStart + 'return'.length + return targetEnd >= returnKeywordEnd && onlyTriviaAndOptionalSemicolon(returnKeywordEnd, targetEnd) + } + if (expressionStart === undefined || expressionEnd === undefined) return false + if (expression.getStart(sourceFile, false) !== expressionStart || expression.getEnd() !== expressionEnd) return false + + return targetEnd >= expressionEnd && onlyTriviaAndOptionalSemicolon(expressionEnd, targetEnd) +} + +function findCompletedReturnTarget(): CompletedReturnTarget | undefined { + const owners = exportedCallableOwners(requiredExportName) + if (owners.length !== 1) return undefined + const owner = owners[0] + if (owner === undefined) return undefined + + if (!ts.isBlock(owner.body)) { + const body = owner.body + if ( + body.getStart(sourceFile, false) === targetStart && body.getEnd() === targetEnd && + expressionStart === targetStart && expressionEnd === targetEnd + ) { + return { kind: 'expression-arrow', owner } + } + return undefined + } + + const matches = returnStatements(owner).filter(matchesReturnStatement) + const matchingReturn = matches[0] + return matches.length === 1 && matchingReturn !== undefined + ? { kind: 'return-statement', owner, returnStatement: matchingReturn } + : undefined +} + +function completionExpression(expression: ts.Expression, depthName: string, markerName: string, hit: boolean): string { + const valueName = `${markerName}_value` + const expressionText = source.slice(expression.getStart(sourceFile, false), expression.getEnd()) + + return `(((${valueName}: any) => { if (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = ${hit}; ` + + `return ${valueName}; })((${expressionText})))` +} + +function instrumentReturn( + statement: ts.ReturnStatement, + target: ts.ReturnStatement, + depthName: string, + markerName: string, +): Edit { + const hit = statement === target + if (statement.expression !== undefined) { + return { + start: statement.expression.getStart(sourceFile, false), + end: statement.expression.getEnd(), + replacement: completionExpression(statement.expression, depthName, markerName, hit), + } + } + + return { + start: statement.getStart(sourceFile, false), + end: statement.getEnd(), + replacement: `{ if (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = ${hit}; return; }`, + } +} + +function firstNonDirectiveOffset(body: ts.Block): number { + const firstNonDirective = body.statements.find((statement) => + !ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression) + ) + return firstNonDirective?.getFullStart() ?? body.getEnd() - 1 +} + +function applyEdits(edits: Edit[]): string { + const ordered = [...edits].sort((left, right) => right.start - left.start || right.end - left.end) + let result = source + let previousStart = source.length + for (const edit of ordered) { + if (edit.start < 0 || edit.end < edit.start || edit.end > previousStart) { + fail('Source-inspector edits overlap or escape the source file') + } + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end) + previousStart = edit.start + } + return result +} + +function instrumentCompletedReturn(target: CompletedReturnTarget, markerName: string): string { + const depthName = `${markerName}_depth` + const declarationOffset = target.owner.declaration.getStart(sourceFile, false) + const edits: Edit[] = [{ start: declarationOffset, end: declarationOffset, replacement: `let ${depthName} = 0;\n` }] + const body = target.owner.body + + if (!ts.isBlock(body)) { + const expression = body + edits.push({ + start: expression.getStart(sourceFile, false), + end: expression.getEnd(), + replacement: `{ ${depthName}++; try { return ${completionExpression(expression, depthName, markerName, true)}; ` + + `} finally { ${depthName}--; } }`, + }) + return applyEdits(edits) + } + + const selectedReturn = target.returnStatement + if (selectedReturn === undefined) fail('Return-statement target is missing its return statement') + for (const statement of returnStatements(target.owner)) { + edits.push(instrumentReturn(statement, selectedReturn, depthName, markerName)) + } + + const guardedBodyStart = firstNonDirectiveOffset(body) + edits.push({ + start: guardedBodyStart, + end: guardedBodyStart, + replacement: `${depthName}++; try {\n`, + }) + edits.push({ + start: body.getEnd() - 1, + end: body.getEnd() - 1, + replacement: `\nif (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = false; ` + + `} finally { ${depthName}--; }\n`, + }) + + return applyEdits(edits) +} + +const target = findCompletedReturnTarget() +if (operation === INSPECT_COMPLETED_RETURN) { + process.stdout.write(target?.kind ?? 'unsupported') +} else if (target === undefined) { + process.stdout.write('unsupported\n') +} else { + process.stdout.write(`ok\n${instrumentCompletedReturn(target, marker ?? fail('Missing marker'))}`) +} diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt index c193295000..b5f63d8a63 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt @@ -12,6 +12,8 @@ internal object FastCheckRuntime { fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) + fun sourceInspectorEntryPoint(): Path = locateEntryPoint(SOURCE_INSPECTOR_CLI) + private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -49,5 +51,6 @@ internal object FastCheckRuntime { private const val EXECUTION_CLI = "execution-cli.js" private const val PROJECTION_CLI = "projection-cli.js" private const val PROCESS_SUPERVISOR = "process-supervisor.js" + private const val SOURCE_INSPECTOR_CLI = "source-inspector-cli.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt new file mode 100644 index 0000000000..78fae3df8c --- /dev/null +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt @@ -0,0 +1,126 @@ +package org.usvm.ts.pbt.fastcheck + +import java.nio.file.Path + +enum class TypeScriptCompletedReturnTargetKind { + EXPRESSION_ARROW, + RETURN_STATEMENT, +} + +/** Exact TypeScript-AST validation and instrumentation for completed-return source targets. */ +object TypeScriptSourceInspector { + fun completedReturnTargetKind( + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + nodeExecutable: String = "node", + ): TypeScriptCompletedReturnTargetKind? { + val output = invoke( + operation = INSPECT_COMPLETED_RETURN, + source = source, + exportName = exportName, + startOffset = startOffset, + endOffset = endOffset, + expressionStartOffset = expressionStartOffset, + expressionEndOffset = expressionEndOffset, + nodeExecutable = nodeExecutable, + ) + + return when (output) { + "expression-arrow" -> TypeScriptCompletedReturnTargetKind.EXPRESSION_ARROW + "return-statement" -> TypeScriptCompletedReturnTargetKind.RETURN_STATEMENT + "unsupported" -> null + else -> error("TypeScript source inspection returned an invalid response: $output") + } + } + + fun instrumentCompletedReturn( + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + marker: String, + nodeExecutable: String = "node", + ): String? { + val output = invoke( + operation = INSTRUMENT_COMPLETED_RETURN, + source = source, + exportName = exportName, + startOffset = startOffset, + endOffset = endOffset, + expressionStartOffset = expressionStartOffset, + expressionEndOffset = expressionEndOffset, + marker = marker, + nodeExecutable = nodeExecutable, + ) + + if (output == UNSUPPORTED_RESPONSE) return null + require(output.startsWith(SUCCESS_RESPONSE)) { + "TypeScript source instrumentation returned an invalid response" + } + + return output.removePrefix(SUCCESS_RESPONSE) + } + + private fun invoke( + operation: String, + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + nodeExecutable: String, + marker: String? = null, + ): String { + require((expressionStartOffset == null) == (expressionEndOffset == null)) { + "Return expression offsets must be both present or both absent" + } + val command = buildList { + add(nodeExecutable) + add(FastCheckRuntime.sourceInspectorEntryPoint().toString()) + add(operation) + add(source.toString()) + add(exportName) + add(startOffset.toString()) + add(endOffset.toString()) + add(expressionStartOffset?.toString() ?: MISSING_OFFSET) + add(expressionEndOffset?.toString() ?: MISSING_OFFSET) + if (marker != null) add(marker) + } + val output = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = MAX_REQUEST_BYTES, + maxStdoutBytes = MAX_STDOUT_BYTES, + maxStderrBytes = MAX_STDERR_BYTES, + shutdownGraceMillis = SHUTDOWN_GRACE_MILLIS, + ).invoke( + command = command, + request = "", + timeoutMillis = SOURCE_INSPECTION_TIMEOUT_MILLIS, + reportedTimeoutMillis = SOURCE_INSPECTION_TIMEOUT_MILLIS, + description = "TypeScript source inspection", + ) + require(output.exitCode == 0) { + "TypeScript source inspection failed with exit ${output.exitCode}: ${output.stderr.trim()}" + } + + return output.stdout + } + + private const val INSPECT_COMPLETED_RETURN = "inspect-completed-return" + private const val INSTRUMENT_COMPLETED_RETURN = "instrument-completed-return" + private const val MISSING_OFFSET = "-" + private const val UNSUPPORTED_RESPONSE = "unsupported\n" + private const val SUCCESS_RESPONSE = "ok\n" + private const val SOURCE_INSPECTION_TIMEOUT_MILLIS = 10_000L + private const val SHUTDOWN_GRACE_MILLIS = 250L + private const val MAX_REQUEST_BYTES = 1 + private const val MAX_STDOUT_BYTES = 16 * 1024 * 1024 + private const val MAX_STDERR_BYTES = 64 * 1024 +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt index 12cc28be18..bf0cfa01e1 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt @@ -7,8 +7,10 @@ import org.jacodb.ets.model.EtsExportInfo import org.jacodb.ets.model.EtsExportType import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef @@ -49,7 +51,8 @@ internal class EtsEntryPointResolver( val hasAmbiguousResolution = hasAmbiguousCandidateResolution || hasAmbiguousSourceResolution val entryPointName = "${entryPoint.module}#${entryPoint.exportName}" - if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { + val methodsWithBindings = methods.map { method -> method to method.bindingParameters() } + if (methodsWithBindings.any { (_, parameters) -> parameters.inputs.size != manifest.inputs.size }) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", @@ -63,10 +66,13 @@ internal class EtsEntryPointResolver( ) } - val targets = methods.map { method -> + val targets = methodsWithBindings.map { (method, parameters) -> EtsEntryPointTarget( method = method, - bindings = method.bindingsFor(manifest), + bindings = method.bindingsFor( + manifest = manifest, + parameters = parameters, + ), ) } @@ -231,16 +237,32 @@ internal class EtsEntryPointResolver( return modulePaths.any(filePaths::contains) } - private fun EtsMethod.bindingsFor(manifest: PropertyManifest): EtsEntryPointBindings { + private fun EtsMethod.bindingParameters(): EtsBindingParameters { + // The frontend lifts arrow-function captures into one leading lexical-environment parameter. + // It is not a source argument; the interpreter recognizes the same parameter by its semantic type. + val hiddenClosure = parameters.firstOrNull()?.takeIf { parameter -> + parameter.type is EtsLexicalEnvType + } + + return EtsBindingParameters( + lexicalEnvironment = hiddenClosure, + inputs = if (hiddenClosure == null) parameters else parameters.drop(1), + ) + } + + private fun EtsMethod.bindingsFor( + manifest: PropertyManifest, + parameters: EtsBindingParameters, + ): EtsEntryPointBindings { val receiverType = EtsClassType( signature = signature.enclosingClass, typeParameters = requireNotNull(enclosingClass).typeParameters, ) - val inputBindings = manifest.inputs.zip(parameters).mapIndexed { index, (input, parameter) -> + val inputBindings = manifest.inputs.zip(parameters.inputs).map { (input, parameter) -> EtsInputBinding( propertyInputName = input.name, parameter = parameter, - stackSlot = index + RECEIVER_STACK_SLOTS, + stackSlot = parameter.index + RECEIVER_STACK_SLOTS, ) } @@ -251,10 +273,21 @@ internal class EtsEntryPointResolver( ), inputs = inputBindings, result = EtsResultBinding(type = returnType), + lexicalEnvironment = parameters.lexicalEnvironment?.let { parameter -> + EtsLexicalEnvironmentBinding( + parameter = parameter, + stackSlot = parameter.index + RECEIVER_STACK_SLOTS, + ) + }, ) } } +private data class EtsBindingParameters( + val lexicalEnvironment: EtsMethodParameter?, + val inputs: List, +) + private data class ExportResolutionStep( val file: EtsFile, val exportName: String, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt index 5d0b3f9310..7fc3a5ca98 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt @@ -96,6 +96,12 @@ data class EtsReceiverBinding( val type: EtsType, ) +/** Preserves the frontend's hidden lexical-environment parameter separately from source arguments. */ +data class EtsLexicalEnvironmentBinding( + val parameter: EtsMethodParameter, + val stackSlot: Int, +) + /** Connects one ordered property input to the corresponding EtsIR parameter and stack slot. */ data class EtsInputBinding( val propertyInputName: String, @@ -113,6 +119,7 @@ data class EtsEntryPointBindings( val receiver: EtsReceiverBinding, val inputs: List, val result: EtsResultBinding, + val lexicalEnvironment: EtsLexicalEnvironmentBinding? = null, ) /** Resolved EtsIR method and its property-facing symbolic bindings. */ diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt index 401ce47bec..dd018f2a68 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -3,6 +3,7 @@ package org.usvm.ts.pbt.mapping import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef @@ -18,9 +19,65 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcePath import java.nio.file.Path import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class PropertyEtsExportResolutionTest { + @Test + fun `exported arrow binds source input after its hidden builtin capture`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "usesCapturedBuiltins"), + ) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val lexicalEnvironment = assertNotNull(target.bindings.lexicalEnvironment) + val hiddenCapture = lexicalEnvironment.parameter + val captureType = hiddenCapture.type as EtsLexicalEnvType + val input = target.bindings.inputs.single() + assertEquals(0, hiddenCapture.index) + assertEquals(1, lexicalEnvironment.stackSlot) + assertEquals(listOf("Number", "Error"), captureType.closures.map { closure -> closure.name }) + assertEquals("value", input.parameter.name) + assertEquals(1, input.parameter.index) + assertEquals(2, input.stackSlot) + } + + @Test + fun `arbitrary captured runtime value remains explicit in lexical environment binding`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "capturesModuleValue"), + ) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val bindings = artifact.predicate.targets.single().bindings + val lexicalEnvironment = assertNotNull(bindings.lexicalEnvironment) + val captureType = lexicalEnvironment.parameter.type as EtsLexicalEnvType + assertEquals(listOf("threshold"), captureType.closures.map { closure -> closure.name }) + assertEquals(1, lexicalEnvironment.stackSlot) + assertEquals(2, bindings.inputs.single().stackSlot) + } + + @Test + fun `hidden builtin capture does not conceal unmatched source parameters`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "capturedBuiltinsWithTwoInputs"), + ) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + @Test fun `named default declaration resolves only through the default export name`() { val source = testResourcePath("/mapping/exports/NamedDefaultDeclaration.ts") diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts new file mode 100644 index 0000000000..026cd06536 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts @@ -0,0 +1,19 @@ +export const usesCapturedBuiltins = (value: number): boolean => { + if (!Number.isInteger(value)) { + throw new Error('expected an integer') + } + + return value % 2 === 0 +} + +export const capturedBuiltinsWithTwoInputs = (left: number, right: number): boolean => + Number.isFinite(left) && left === right + +let capturesModuleValue: (value: number) => boolean + +{ + const threshold = 3 + capturesModuleValue = (value: number): boolean => value > threshold +} + +export { capturesModuleValue } diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index b4506e3f6f..1c38883085 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -48,7 +48,7 @@ This is the only model-selection setting. | --- | --- | | `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. | +| `TsUnknownCallModelSelection.Only(setOf("id", ...))` | Enable the listed built-in model IDs and their declared dependencies. | 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. @@ -56,16 +56,41 @@ point, so later mutations of the selection set cannot change an active run. Use the model's `id`, for example `ts.array.pop`. A target method name, class name, source filename, or artifact hash 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. +Built-ins are singleton models or families implementing the sealed `TsBuiltInUnknownCallModel` interface in the +`org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; a family +supplies its parameterized models without a separate registry. Discovery and the default catalog are computed once. -The built-in catalog currently contains: +The built-in catalog includes the following public APIs and their internal storage primitives. Enumerate +`TsBuiltInUnknownCallModels.catalog().modelIds` for the exact IDs in a build. | ID | Implementation | Accepted calls | | --- | --- | --- | | `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | +| `ts.array.isArray` | Kotlin runtime-type primitive | The genuine global Array predicate, including null, undefined and fake-value wrappers. | +| `ts.array.fromLength` | TypeScript/EtsIR body with a heap-allocation primitive | Genuine callable `Array(length)` with one numeric argument. Valid lengths up to 16 allocate holes that read as `undefined`; larger valid lengths use fallback. | | `ts.array.pop` | TypeScript/EtsIR body | Zero-argument `pop` on a definitely one-dimensional array that also satisfies the symbolic runtime type guard. | +| `ts.array.includes`, `ts.array.indexOf`, `ts.array.lastIndexOf` | TypeScript/EtsIR bodies | One-dimensional arrays and numeric positions, with the missing-slot exclusions below. | +| `ts.array.push`, `ts.array.fill`, `ts.array.reverse`, `ts.array.unshift`, `ts.array.slice`, `ts.array.concat` | TypeScript/EtsIR bodies with storage growth/allocation primitives | One-dimensional arrays with length/result at most 16. Push/unshift accept up to three arguments; resolved-sort arrays require matching element sorts, while mixed arrays retain runtime value kinds. Concat accepts one same-type array. Except push and full-range `fill(value)`, these operations require a current dense-array proof. | +| `ts.string.charAt`, `ts.string.charCodeAt`, `ts.string.includes`, `ts.string.indexOf`, `ts.string.lastIndexOf`, `ts.string.startsWith`, `ts.string.endsWith` | TypeScript/EtsIR bodies | Strings in initialized UTF-16 storage, including symbolic code units and numeric positions. | +| `ts.string.slice`, `ts.string.substring`, `ts.string.trim`, `ts.string.trimStart`, `ts.string.trimEnd` | TypeScript/EtsIR bodies | UTF-16 range copying; substring clamps/swaps bounds, and trim uses the ECMAScript whitespace set. | +| `ts.string.replaceAll` | TypeScript/EtsIR body | String receiver, string search and string replacement; non-overlapping UTF-16 matches, empty search and ECMAScript dollar substitutions for a dollar sign, the match, its prefix and its suffix. RegExp, callbacks and argument coercions use fallback. | +| `ts.string.toLowerCase`, `ts.string.toUpperCase` | TypeScript/EtsIR bodies | ASCII strings of at most 16 code units; other strings use residual fallback. | +| `ts.math.abs`, `ts.math.ceil`, `ts.math.floor`, `ts.math.max`, `ts.math.min`, `ts.math.round`, `ts.math.sqrt`, `ts.math.trunc` | Kotlin FP primitives | Numeric arguments; dynamic coercions use fallback. | +| `ts.number.isFinite`, `ts.number.isInteger`, `ts.number.isNaN`, `ts.number.isSafeInteger` | Kotlin FP/type primitives | Non-coercing Number predicates, including runtime-kind guards. | +| `ts.error.constructor` | TypeScript/EtsIR body | Genuine `new Error(message)` with one string argument; initializes `name` and `message`. Other arities, coercions, subclasses, `cause` and stack inspection are outside this model. | +| `ts.date.*` (38 IDs) | TypeScript/EtsIR bodies | Numeric Date construction, `UTC`, fixed-clock `now`, getters, setters, `valueOf`, and source `toISOString`; see the Date boundary below. | + +Matching standard calls are assumed to refer to genuine builtins. Monkey patching and prototype replacement are +outside this experiment; no runtime provenance protocol is imposed. Receiver and argument checks establish the +memory representation and supported input domain. + +The September 2026 corpus census contains 22,769 call/constructor sites. Its 49-API shortlist accounts for 1,026 +sites: Number/Math (275), new Array/String searches (130), existing pop/shift (52), and Date (569). Every shortlisted +API name has a catalog entry. Eleven adjacent APIs add 280 census sites; `setUTCMinutes` and `setUTCMilliseconds` +complete the numeric UTC setter family but have no sites in this census. These 1,306 associated sites are +an inventory count, **not executed or replay-confirmed coverage**: imports, input representation, fallback domains, +and other unsupported operations can still prevent execution. The wider research inventory contains 271 +unambiguous standard/host API names over 7,720 sites; most are not implemented by this catalog. 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 @@ -73,6 +98,26 @@ model, including through an `any` alias. An unknown or non-array receiver does n the method is named `shift`. A definitely-array receiver with an unresolved element sort remains applicable and uses the fake-value representation described below. +Dense input proofs retain snapshots of the array length and element storage regions. A later write invalidates the +proof conservatively, including writes to another array sharing a region. Consequently, a chain of individually +modeled array methods can still use fallback after its first mutation or copy. + +### Runtime limitations and experimental outcomes + +`TsInterpreterObserver.onRuntimeFeatureLimitation` records feasible paths stopped by bounded array storage, such as +named-property access, unsupported length growth, or assigning a runtime kind absent from a typed array's storage. +Reads may return an element or `undefined`; subsequent numeric operations and typed writes preserve that runtime-kind +guard. TypeScript `as` and angle-bracket assertions are erased and never change a value or constrain its runtime kind. +These events are separate from unknown-call model decisions. +The Calls runner writes them synchronously and reports `RUNTIME_LIMITATION` when search exhausts after such a stop +without reaching the target. An actual timeout remains `TIMEOUT`; a reached target still requires original-source replay. + +Calls preflight checks input binding, source/IR target mapping and known unsupported IR features before choosing a +model/fallback profile. Excluded functions remain in the corpus support ledger. Comparative runs use a frozen common +target set; failures discovered after that freeze remain scheduled failures. The analysis budget is 30 seconds per +target, profile and seed; JVM/frontend startup and original-source replay have separate recorded limits. Readiness +traverses reachable same-file callees and the initializers triggered by their static-field accesses. + ### `unknownCallFallback` The fallback is applied when: @@ -99,6 +144,7 @@ Every model implements `TsUnknownCallModel`: interface TsUnknownCallModel { val id: String val target: TsUnknownCallTarget + val requiredModelIds: Set fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? } @@ -127,6 +173,10 @@ The ID is used for configuration, observer events, and recursion prevention. Do Keep the same ID when an equivalent model moves from Kotlin to TypeScript. +Selecting models with `TsUnknownCallModelSelection.Only` expands `requiredModelIds` transitively and sorts the final +catalog by ID. This keeps a high-level source model usable when it calls helper models. Missing dependency IDs are +rejected while building the catalog; an explicitly empty selection remains empty. + ### Choosing a target `TsUnknownCallTarget` matches stable call metadata declaratively: @@ -146,11 +196,11 @@ a priority rule. The enabled model set is frozen and sorted by ID when the catal The target identifies a call family. State-dependent checks, such as the receiver's symbolic runtime type, belong in `apply` or in an EtsIR model's domain guard. -The built-in array targets intentionally combine 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` 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. Both models preserve the array's storage type, including reference and unresolved elements. +The built-in array targets combine the method name with `PARTIAL_APPROXIMATION`. Array and String methods with the +same name also use a canonical enclosing class at this boundary. The failure reason is emitted only after the regular +approximation path has classified the normalized receiver by its 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 @@ -228,8 +278,25 @@ Array indexing and `length` assignment use the receiver's storage type. Writing zero through the current length, within the configured array-size limit. Growth remains unsupported because the engine does not represent newly created holes; those paths are pruned. -The entry point must be static and have a non-empty body. Its parameter count must equal the resolved receiver plus -argument count. Unresolved inputs or an arity mismatch make the model not applicable. +`Array.pop`, `indexOf`, `includes`, and `lastIndexOf` share one source-model family. Search offsets accept numbers and +the standard omitted or explicit-`undefined` defaults; other dynamic coercions use fallback. Array memory has no slot +presence bit, so a hole can look like a typed default. Searches for `0` or `false` therefore use fallback, as do +`indexOf(undefined)` and `lastIndexOf(undefined)`. `includes(undefined)` is accepted only for address or unresolved +storage; numeric and boolean storage use fallback because their holes currently read as typed defaults. Symbolic +numeric and boolean search values use a guarded model branch outside the typed default and residual fallback on the +unsupported default. Fake-wrapped dynamic search values use fallback. Position normalization depends on +`ts.math.floor`. + +The String source family implements `charAt`, `charCodeAt`, `indexOf`, `lastIndexOf`, `includes`, `startsWith`, and +`endsWith`. Its TypeScript algorithms depend on atomic length, UTF-16 code-unit read, and one-code-unit construction +models, plus `ts.math.floor` for positions. Current symbolic String parameters do not initialize backing character +storage, so receivers and search strings must be initialized concrete constants. `charAt` also requires a concrete +index because a dynamically constructed one-code-unit String does not yet participate in value-based String equality. +Numeric-result and predicate methods can still use symbolic numeric positions over concrete strings. + +The entry point must be static and have a non-empty body. After its input adapter handles optional arguments or drops +non-semantic namespace receivers, its parameter count must equal the adapted input count. Unresolved required inputs +or an arity mismatch make the model not applicable. The domain guard has three useful outcomes: @@ -265,6 +332,24 @@ Widening a local from `number[]` to `any[]` therefore keeps the same element and In contrast, `Array.pop` is expressed as the TypeScript body shown above. +### Date experiment boundary + +The built-in Date family keeps Gregorian calendar arithmetic, component overflow, leap years, and TimeClip in +`DateModels.ts`. Kotlin only routes calls, injects the experiment clock, and exposes the model's numeric timestamp +slot on a Date receiver. Calendar division and truncation use the declared `ts.math.floor` dependency. + +The current experiment has these explicit limits: + +- local getters, setters, and numeric component constructors use UTC; `getTimezoneOffset()` returns zero for valid + dates and NaN for invalid dates. DST behavior is outside the model domain; +- `Date.now()` and `new Date()` require `TsOptions.dateNowMilliseconds`; one fixed value is reused throughout the + analysis, and both calls use fallback when it is absent; +- one-argument construction supports numeric timestamps only; string parsing and copying another Date are outside + the model domain; +- symbolic string formatting is not claimed: `toISOString()` is a source implementation for supported concrete + execution, while symbolic string conversion remains subject to the engine's string limitations. Invalid ISO + formatting reaches the unsupported nested `RangeError` constructor and the configured fallback. + Good intrinsic candidates include: - bulk symbolic-memory copy or fill; @@ -305,6 +390,9 @@ type guard. Unknown calls made inside a TypeScript model body use the same catalog and fallback as the original program. This lets source models compose with other source models and intrinsics. +Declare every nested semantic-model call in `requiredModelIds`. A selection containing only the high-level API then +expands to its helpers before the machine scene is materialized. + The state tracks each active model ID together with its call-stack depth. If the same model would redirect recursively, lookup declines that redirection and fallback is applied instead of entering an infinite loop. 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 e58f45f474..8f7d262074 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -61,6 +61,7 @@ class TsContext( val scene: EtsScene, components: TsComponents, internal val applicationAndSdkClasses: List = scene.projectAndSdkClasses, + internal val dateNowMilliseconds: Double? = null, ) : UContext(components) { val undefinedSort: TsUndefinedSort by lazy { TsUndefinedSort(this) } 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 132c6413be..413136a4a6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallExpr import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsThrowStmt import org.usvm.UBoolExpr import org.usvm.machine.call.TsUnknownCallEvent @@ -21,6 +22,11 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } + /** Called when a feasible path is intentionally stopped at a bounded runtime feature. */ + fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + // default empty implementation + } + fun onAssignStatement( simpleValueResolver: TsSimpleValueResolver, stmt: EtsAssignStmt, @@ -74,3 +80,20 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } } + +/** A feasible execution path stopped because the runtime model deliberately omits [reason]. */ +data class TsRuntimeFeatureLimitationEvent( + val statement: EtsStmt, + val reason: TsRuntimeFeatureLimitationReason, + val detail: String, +) + +/** Stable identifiers for bounded runtime features reported by [TsRuntimeFeatureLimitationEvent]. */ +enum class TsRuntimeFeatureLimitationReason { + ARRAY_NAMED_PROPERTY_READ, + ARRAY_NAMED_PROPERTY_WRITE, + ARRAY_INDEX_GROWTH, + ARRAY_ELEMENT_KIND_WRITE, + ARRAY_LENGTH_CAPACITY, + ARRAY_LENGTH_GROWTH, +} 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 5c5d344b3b..414ce7f5fc 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -8,6 +8,7 @@ import org.usvm.CoverageZone import org.usvm.StateCollectionStrategy import org.usvm.UMachine import org.usvm.UMachineOptions +import org.usvm.USort import org.usvm.api.targets.TsTarget import org.usvm.machine.call.TsBuiltInUnknownCallModels import org.usvm.machine.call.TsModelUnknownCallDispatcher @@ -59,6 +60,8 @@ class TsMachine( observer: TsInterpreterObserver? = null, unknownCallDispatcher: TsUnknownCallDispatcher? = null, unknownCallModels: TsUnknownCallModelCatalog? = null, + private val initialStateConfigurator: (TsState) -> Unit = {}, + private val initialParameterSortOverride: (TsContext, Int) -> USort? = { _, _ -> null }, ) : UMachine() { private val resolvedUnknownCallModels = when { unknownCallDispatcher != null -> null @@ -85,6 +88,7 @@ class TsMachine( scene = analysisScene, components = components, applicationAndSdkClasses = scene.projectAndSdkClasses, + dateNowMilliseconds = tsOptions.dateNowMilliseconds, ) private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsModelUnknownCallDispatcher( models = requireNotNull(resolvedUnknownCallModels), @@ -111,7 +115,14 @@ class TsMachine( targets: List = emptyList(), ): TsAnalysisResult { val initialStates = mutableMapOf() - methods.forEach { initialStates[it] = interpreter.getInitialState(it, targets) } + methods.forEach { method -> + initialStates[method] = interpreter.getInitialState( + method = method, + targets = targets, + configure = initialStateConfigurator, + parameterSortOverride = { stackSlot -> initialParameterSortOverride(ctx, stackSlot) }, + ) + } val methodsToTrackCoverage = when (options.coverageZone) { 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 aca3a3d540..a6b54d2d46 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -9,4 +9,20 @@ data class TsOptions( val maxArraySize: Int = 1_000, val unknownCallModelSelection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All, val unknownCallFallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, -) + /** Fixed experiment clock used by `Date.now()` and `new Date()`; `null` leaves those calls unsupported. */ + val dateNowMilliseconds: Double? = null, +) { + init { + val isValidDateNow = dateNowMilliseconds == null || + dateNowMilliseconds.isFinite() && + dateNowMilliseconds % 1.0 == 0.0 && + dateNowMilliseconds in -DATE_TIME_CLIP_BOUND_MILLIS..DATE_TIME_CLIP_BOUND_MILLIS + require(isValidDateNow) { + "The fixed Date clock must be an integral TimeClip-range millisecond timestamp" + } + } + + private companion object { + const val DATE_TIME_CLIP_BOUND_MILLIS = 8_640_000_000_000_000.0 + } +} 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 935d768c3e..5db429b4d2 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,15 +1,24 @@ package org.usvm.machine.call import org.usvm.machine.call.intrinsic.TsBuiltInUnknownCallModel +import org.usvm.machine.call.intrinsic.TsBuiltInUnknownCallModelFamily /** Discovers built-in model objects from the sealed hierarchy. */ object TsBuiltInUnknownCallModels { private val models by lazy { - TsBuiltInUnknownCallModel::class.sealedSubclasses.map { modelClass -> + val individualModels = TsBuiltInUnknownCallModel::class.sealedSubclasses.map { modelClass -> requireNotNull(modelClass.objectInstance) { "Built-in semantic model must be an object: ${modelClass.qualifiedName}" } } + val modelFamilies = TsBuiltInUnknownCallModelFamily::class.sealedSubclasses.flatMap { familyClass -> + val family = requireNotNull(familyClass.objectInstance) { + "Built-in semantic model family must be an object: ${familyClass.qualifiedName}" + } + family.models + } + + individualModels + modelFamilies } private val allModels by lazy { TsUnknownCallModelCatalog(models) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt new file mode 100644 index 0000000000..288fc23f13 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt @@ -0,0 +1,43 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType +import org.jacodb.ets.model.EtsUnknownType +import org.jacodb.ets.model.EtsValue + +internal fun hasBuiltinGlobalOwner( + owner: EtsValue, + callee: EtsMethodSignature, + expectedName: String, +): Boolean = owner.type.isBuiltinGlobalType(expectedName) || + owner.isLegacyBuiltinGlobal(expectedName = expectedName, callee = callee) + +internal fun EtsType.isBuiltinGlobalType(expectedName: String): Boolean = when (expectedName) { + "Math" -> this is EtsUnclearRefType && name == expectedName + "Number" -> this is EtsFunctionType && isBuiltinNumberType() + else -> false +} + +private fun EtsFunctionType.isBuiltinNumberType(): Boolean { + val parameter = signature.parameters.singleOrNull() ?: return false + return signature.enclosingClass == EtsClassSignature.UNKNOWN && + signature.name.isEmpty() && + signature.returnType == EtsNumberType && + parameter.type == EtsAnyType && + parameter.isOptional && + !parameter.isRest +} + +private fun EtsValue.isLegacyBuiltinGlobal( + expectedName: String, + callee: EtsMethodSignature, +): Boolean = this is EtsLocal && + name == expectedName && + type == EtsUnknownType && + callee.enclosingClass == EtsClassSignature.UNKNOWN.copy(name = expectedName) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt index c0b11f8be2..a55c40d2e2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt @@ -111,11 +111,17 @@ class TsEtsIrUnknownCallModel( override val target: TsUnknownCallTarget, val artifact: TsEtsIrUnknownCallModelArtifact, val domainGuard: TsEtsIrUnknownCallModelDomainGuard = TsEtsIrUnknownCallModelDomainGuard.ALWAYS, + val inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, + requiredModelIds: Set = emptySet(), ) : TsUnknownCallModel, TsMachineLocalUnknownCallModel { override val additionalSceneFiles: List = listOf(artifact.file) + override val requiredModelIds: Set = requiredModelIds.toSet() override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { - val inputs = call.resolvedInputs() ?: return null + val inputs = inputAdapter.adapt( + state = state, + call = call, + ) ?: return null if (inputs.size != artifact.entryPoint.parameters.size) { return null } @@ -154,10 +160,24 @@ class TsEtsIrUnknownCallModel( target = target, artifact = materializedArtifact, domainGuard = domainGuard, + inputAdapter = inputAdapter, + requiredModelIds = requiredModelIds, ) } } +/** Adapts resolved call inputs to the parameters of a TypeScript model entry point. */ +fun interface TsEtsIrUnknownCallModelInputAdapter { + fun adapt( + state: TsState, + call: TsUnknownCall, + ): List>? + + companion object { + val IDENTITY = TsEtsIrUnknownCallModelInputAdapter { _, call -> call.resolvedInputs() } + } +} + /** Builds the symbolic input guard for one TypeScript model body. */ fun interface TsEtsIrUnknownCallModelDomainGuard { fun evaluate( 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 a46c7d9d6d..d3bbab1aad 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 @@ -1,20 +1,27 @@ package org.usvm.machine.call +import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsCallExpr +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType import org.jacodb.ets.model.EtsValue import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UExpr import org.usvm.api.mockMethodCall +import org.usvm.api.typeStreamOf import org.usvm.machine.TsConcreteMethodCallStmt import org.usvm.machine.TsVirtualMethodCallStmt 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.types.singleOrNull /** * A call that the regular TypeScript execution pipeline could not execute. @@ -141,19 +148,59 @@ internal fun TsUnknownCallDispatcher.dispatch( is EtsPtrCallExpr -> call.ptr else -> null } - return dispatch( - scope, - TsUnknownCall( - callee = callee, - receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, - arguments = call.args.zip(resolvedArguments) { source, resolved -> - TsUnknownCallValue(source, resolved) - }, - resultType = call.type, - callSite = callSite, - failureReason = failureReason, - ), + val receiverIsDate = (call as? EtsInstanceCallExpr)?.let { instanceCall -> + scope.calcOnState { isDateReceiver(instanceCall, resolvedReceiver) } + } ?: false + val normalizedCallee = call.canonicalizeDateCallee(callee, receiverIsDate) + val unknownCall = TsUnknownCall( + callee = normalizedCallee, + receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, + arguments = call.args.zip(resolvedArguments) { source, resolved -> + TsUnknownCallValue(source, resolved) + }, + resultType = call.type, + callSite = callSite, + failureReason = failureReason, ) + + return dispatch(scope, unknownCall) +} + +internal fun EtsInstanceCallExpr.hasDateReceiver(): Boolean = + instance.name == "Date" || when (val type = instance.type) { + is EtsClassType -> type.signature.name == "Date" + is EtsUnclearRefType -> type.typeName == "Date" + else -> false + } + +internal fun TsState.isDateReceiver( + call: EtsInstanceCallExpr, + receiver: UExpr<*>?, +): Boolean { + if (call.hasDateReceiver()) { + return true + } + if (receiver?.sort != ctx.addressSort) { + return false + } + + val runtimeType = memory.typeStreamOf(receiver.asExpr(ctx.addressSort)).singleOrNull() + return (runtimeType as? EtsClassType)?.signature?.name == "Date" +} + +private fun EtsCallExpr.canonicalizeDateCallee( + callee: EtsMethodSignature, + receiverIsDate: Boolean, +): EtsMethodSignature { + if (callee.enclosingClass.name == "Date") { + return callee + } + + if (!receiverIsDate) { + return callee + } + + return callee.copy(enclosingClass = EtsClassSignature.UNKNOWN.copy(name = "Date")) } internal fun TsUnknownCallDispatcher.dispatch( 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 15d6f5c334..60feb720dc 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 @@ -32,6 +32,8 @@ data class TsUnknownCallTarget( interface TsUnknownCallModel { val id: String val target: TsUnknownCallTarget + val requiredModelIds: Set + get() = emptySet() /** EtsIR files that must be visible to the interpreter while this model is enabled. */ val additionalSceneFiles: List 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 ff7db78c35..c55afe0251 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 @@ -23,13 +23,28 @@ class TsUnknownCallModelCatalog( require(model.id.isNotBlank()) { "Semantic model ID must not be blank" } require(modelsById.put(model.id, model) == null) { "Duplicate semantic model ID: ${model.id}" } } + modelsById.values.forEach { model -> + val missingDependencies = model.requiredModelIds.subtract(modelsById.keys) + require(missingDependencies.isEmpty()) { + "Semantic model ${model.id} requires unknown model IDs: ${missingDependencies.sorted().joinToString()}" + } + } 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) + val expandedIds = linkedSetOf() + fun addWithDependencies(id: String) { + if (!expandedIds.add(id)) { + return + } + + modelsById.getValue(id).requiredModelIds.sorted().forEach(::addWithDependencies) + } + selection.ids.sorted().forEach(::addWithDependencies) + expandedIds.map(modelsById::getValue) } }.sortedBy(TsUnknownCallModel::id) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt new file mode 100644 index 0000000000..9bde8deffa --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -0,0 +1,688 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import io.ksmt.utils.cast +import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.api.initializeArray +import org.usvm.api.initializeArrayLength +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +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.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.expr.mkFpToUint32AfterValidation +import org.usvm.machine.expr.mkValidArrayLength +import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedArrayKind +import org.usvm.sizeSort +import org.usvm.util.arrayStorageType +import org.usvm.util.initializeArrayKind +import org.usvm.util.isUnmodifiedDenseInputArray +import org.usvm.util.mkArrayLengthLValue + +/** Built-in Array algorithms implemented by ordinary TypeScript bodies. */ +internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val CLASS_NAME = "ArrayModels" + private const val PRIMITIVES_CLASS_NAME = "ArrayModelPrimitives" + private const val RESOURCE_NAME = "/org/usvm/machine/call/models/ArrayModels.ts" + private const val MAX_SOURCE_ARRAY_LENGTH = 16 + private const val MAX_VARIADIC_ARGUMENTS = 3 + private const val MAX_FILL_ARGUMENTS = 3 + + private val baseArtifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE_NAME, + sourceFileName = "ArrayModels.ts", + entryPointClassName = CLASS_NAME, + entryPointMethodName = "pop", + ) + } + + private val arrayDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiver = inputs.firstOrNull() + val staticType = call.receiver?.source?.type + if (staticType == null || receiver?.sort != addressSort) { + falseExpr + } else { + val array = receiver.asExpr(addressSort) + val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType + val searchElement = inputs.getOrNull(1) + val searchRef = searchElement + ?.takeIf { it.sort == addressSort } + ?.asExpr(addressSort) + if ( + array.hasFakeValueBranch() || receiverType?.dimensions != 1 || + searchRef?.hasFakeValueBranch() == true + ) { + falseExpr + } else { + val elementSort = typeToSort(receiverType.elementType) + val hasNoMissingSlots = array is UConcreteHeapRef && + state.isUnmodifiedDenseInputArray(array, receiverType) + val excludesMissingSlot = when { + searchElement == mkUndefinedValue() && + ( + call.callee.name in setOf("indexOf", "lastIndexOf") || + elementSort == fp64Sort || elementSort == boolSort + ) -> mkBool(hasNoMissingSlots) + + elementSort == fp64Sort && searchElement?.sort == fp64Sort -> { + val searchNumber = searchElement.asExpr(fp64Sort) + val zero = mkFp64(0.0) + + mkOr(mkBool(hasNoMissingSlots), mkNot(mkFpEqualExpr(searchNumber, zero))) + } + + elementSort == boolSort && searchElement?.sort == boolSort -> { + mkOr(mkBool(hasNoMissingSlots), searchElement.asExpr(boolSort)) + } + + else -> trueExpr + } + + mkAnd( + state.memory.types.evalIsSubtype(array, receiverType), + excludesMissingSlot, + ) + } + } + } + } + + private val pushDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val arguments = call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + if (!state.argumentsMatchArrayType(arrayType, arguments)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH - arguments.size) + } + + private val fillDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val value = call.arguments.firstOrNull()?.resolved + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val valueMatchesArrayType = state.argumentsMatchArrayType(arrayType, listOf(value)) + val fillsWholeArray = call.arguments.size == 1 + val hasKnownSlotValues = fillsWholeArray || state.isUnmodifiedDenseInputArray(array, arrayType) + if (!valueMatchesArrayType || !hasKnownSlotValues) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH) + } + + private val denseReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + if (!state.isUnmodifiedDenseInputArray(array, arrayType)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH) + } + + private val unshiftDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val arguments = call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + if ( + !state.argumentsMatchArrayType(arrayType, arguments) || + !state.isUnmodifiedDenseInputArray(array, arrayType) + ) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH - arguments.size) + } + + private val concatDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val other = inputs.getOrNull(1) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val otherStaticType = call.arguments.singleOrNull()?.source?.type + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val otherType = state.arrayStorageType(other, otherStaticType) as? EtsArrayType + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + if ( + arrayType != otherType || + !state.isUnmodifiedDenseInputArray(array, arrayType) || + !state.isUnmodifiedDenseInputArray(other, otherType) + ) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + with(state.ctx) { + val firstLength = state.memory.read(mkArrayLengthLValue(array, arrayType)) + val secondLength = state.memory.read(mkArrayLengthLValue(other, otherType)) + val firstIsBounded = state.boundedArrayGuard( + array, + arrayType, + maximumLength = MAX_SOURCE_ARRAY_LENGTH, + ) + val secondIsBounded = state.boundedArrayGuard( + other, + otherType, + maximumLength = MAX_SOURCE_ARRAY_LENGTH, + ) + val resultLength = mkBvAddExpr(firstLength, secondLength) + val maximumResultLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val resultFits = mkBvSignedLessOrEqualExpr(resultLength, maximumResultLength) + mkAnd( + firstIsBounded, + secondIsBounded, + resultFits, + ) + } + } + + private val optionalFromIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> { + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } + + call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + } + + private val optionalLastIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 -> inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> { + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } + + call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + } + + private val variadicMutationAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > MAX_VARIADIC_ARGUMENTS) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + buildList { + add(inputs.first()) + addAll(arguments) + repeat(MAX_VARIADIC_ARGUMENTS - arguments.size) { + add(state.ctx.mkUndefinedValue()) + } + add(state.ctx.mkFp64(arguments.size.toDouble())) + } + } + + private val fillAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.isEmpty() || arguments.size > MAX_FILL_ARGUMENTS) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + if ( + arguments.drop(1).any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + ) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val start = arguments.getOrNull(1) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFp64(0.0) + val end = arguments.getOrNull(2) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + listOf(inputs.first(), arguments.first(), start, end) + } + + private val sliceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > 2 || arguments.any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + ) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val start = arguments.getOrNull(0) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFp64(0.0) + val end = arguments.getOrNull(1) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + listOf(inputs.first(), start, end) + } + + private val noArgumentsAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.isEmpty()) call.resolvedInstanceInputs() else null + } + + private val arrayConstructorAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + if (!call.hasGlobalArrayConstructorShape() || call.arguments.size != 1) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val length = call.arguments.single().resolved + ?.takeIf { value -> value.sort == state.ctx.fp64Sort } + ?: return@TsEtsIrUnknownCallModelInputAdapter null + listOf(length) + } + + override val models: List by lazy { + val arrayConstructorModel = TsEtsIrUnknownCallModel( + id = ARRAY_FROM_LENGTH_ID, + target = TsUnknownCallTarget( + methodName = "Array", + failureReason = TsUnknownCallFailureReason.POINTER_TARGET_NOT_FOUND, + ), + artifact = artifact("fromLength"), + inputAdapter = arrayConstructorAdapter, + requiredModelIds = setOf(PRIMITIVE_ALLOCATE_ID), + ) + + listOf( + arrayConstructorModel, + sourceModel( + id = "ts.array.pop", + methodName = "pop", + ), + sourceModel( + id = "ts.array.indexOf", + methodName = "indexOf", + inputAdapter = optionalFromIndexAdapter, + ), + sourceModel( + id = "ts.array.includes", + methodName = "includes", + inputAdapter = optionalFromIndexAdapter, + ), + sourceModel( + id = "ts.array.lastIndexOf", + methodName = "lastIndexOf", + inputAdapter = optionalLastIndexAdapter, + ), + sourceModel( + id = "ts.array.push", + methodName = "push", + inputAdapter = variadicMutationAdapter, + domainGuard = pushDomain, + ), + sourceModel( + id = "ts.array.fill", + methodName = "fill", + inputAdapter = fillAdapter, + domainGuard = fillDomain, + ), + sourceModel( + id = "ts.array.reverse", + methodName = "reverse", + inputAdapter = noArgumentsAdapter, + domainGuard = denseReceiverDomain, + ), + sourceModel( + id = "ts.array.unshift", + methodName = "unshift", + inputAdapter = variadicMutationAdapter, + domainGuard = unshiftDomain, + ), + sourceModel( + id = "ts.array.slice", + methodName = "slice", + inputAdapter = sliceAdapter, + domainGuard = denseReceiverDomain, + ), + sourceModel( + id = "ts.array.concat", + methodName = "concat", + domainGuard = concatDomain, + ), + primitiveModel( + methodName = "grow", + arity = 2, + implementation = ::growArray, + ), + primitiveModel( + methodName = "allocate", + arity = 1, + implementation = ::allocateArray, + ), + primitiveModel( + methodName = "allocateLike", + arity = 2, + implementation = ::allocateArrayLike, + ), + ) + } + + private fun sourceModel( + id: String, + methodName: String, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, + domainGuard: TsEtsIrUnknownCallModelDomainGuard = arrayDomain, + ): TsUnknownCallModel { + val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = "Array".takeUnless { methodName == "pop" }, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ) + + return TsEtsIrUnknownCallModel( + id = id, + target = target, + artifact = artifact(methodName), + domainGuard = domainGuard, + inputAdapter = inputAdapter, + requiredModelIds = buildSet { + if (methodName in setOf("indexOf", "includes", "lastIndexOf", "fill", "reverse", "slice")) { + add(MATH_FLOOR_MODEL_ID) + } + if (methodName in setOf("push", "unshift")) { + add(PRIMITIVE_GROW_ID) + } + if (methodName in setOf("slice", "concat")) { + add(PRIMITIVE_ALLOCATE_LIKE_ID) + } + }, + ) + } + + private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { + val artifact = baseArtifact + val entryPoint = artifact.file.allClasses + .single { it.name == CLASS_NAME } + .methods + .single { it.name == methodName } + + return artifact.copy(entryPoint = entryPoint) + } + + private fun TsState.concreteArray( + call: TsUnknownCall, + inputs: List>, + ): Pair? { + val receiver = inputs.firstOrNull() as? UConcreteHeapRef ?: return null + if (with(ctx) { receiver.hasFakeValueBranch() }) return null + + val staticType = call.receiver?.source?.type ?: return null + val arrayType = arrayStorageType(receiver, staticType) as? EtsArrayType ?: return null + if (arrayType.dimensions != 1) return null + + return receiver to arrayType + } + + private fun TsState.argumentsMatchArrayType( + arrayType: EtsArrayType, + arguments: List>, + ): Boolean = with(ctx) { + val elementSort = typeToSort(arrayType.elementType) + elementSort is TsUnresolvedSort || arguments.all { argument -> + argument.sort == elementSort && + (argument.sort != addressSort || !argument.asExpr(addressSort).hasFakeValueBranch()) + } + } + + private fun TsState.boundedArrayGuard( + array: UConcreteHeapRef, + arrayType: EtsArrayType, + maximumLength: Int, + ): UBoolExpr = with(ctx) { + val length = memory.read(mkArrayLengthLValue(array, arrayType)) + val minimumLength = mkBv(0) + val maximumLengthExpr = mkBv(maximumLength) + mkAnd( + memory.types.evalIsSubtype(array, arrayType), + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLengthExpr), + ) + } + + private fun primitiveModel( + methodName: String, + arity: Int, + implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ): TsUnknownCallModel = ArrayPrimitiveModel( + methodName = methodName, + arity = arity, + implementation = implementation, + ) + + private fun growArray( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef ?: return null + val fpLength = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val arrayType = state.arrayStorageType( + receiver, + EtsArrayType(EtsUnknownType, dimensions = 1), + ) as? EtsArrayType ?: return null + val length = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpLength, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val roundTrip = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.cast(), + signed = true, + ) + val receiverMatchesType = state.memory.types.evalIsSubtype(receiver, arrayType) + val minimumLength = mkBv(0) + val maximumLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val guard = mkAnd( + receiverMatchesType, + mkFpEqualExpr(roundTrip, fpLength), + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLength), + ) + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { + state.memory.write( + mkArrayLengthLValue(receiver, arrayType), + length, + guard = trueExpr, + ) + mkUndefinedValue() + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) + } + + private fun allocateArray( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val fpLength = inputs.singleOrNull() + ?.takeIf { value -> value.sort == fp64Sort } + ?.asExpr(fp64Sort) + ?: return null + val validLength = mkValidArrayLength(fpLength) + val maximumLength = mkFp64(MAX_SOURCE_ARRAY_LENGTH.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr( + fpLength, + maximumLength, + ) + val supportedLength = mkAnd(validLength, withinModelCapacity) + val length = mkFpToUint32AfterValidation(fpLength, validLength).asExpr(sizeSort) + val arrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + + // The model covers only valid, bounded one-number lengths. All other inputs remain residual. + val validSuccessor = TsUnknownCallModelSuccessor( + guard = supportedLength, + completion = TsUnknownCallModelCompletion.Normal { + val descriptor = arrayDescriptorOf(arrayType) + val result = memory.allocConcrete(descriptor) + val undefinedSlots = List(MAX_SOURCE_ARRAY_LENGTH) { mkUndefinedValue() } + memory.initializeArray( + arrayHeapRef = result, + type = descriptor, + sort = addressSort, + sizeSort = sizeSort, + contents = undefinedSlots.asSequence(), + ) + TsUnresolvedArrayKind.entries.forEach { kind -> + initializeArrayKind( + array = result, + kind = kind, + values = List(MAX_SOURCE_ARRAY_LENGTH) { falseExpr }, + ) + } + memory.initializeArrayLength( + arrayHeapRef = result, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + result + }, + ) + TsUnknownCallModelExecution( + successors = listOf(validSuccessor), + residualGuard = mkNot(supportedLength), + ) + } + + private fun allocateArrayLike( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef ?: return null + val fpLength = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val arrayType = state.arrayStorageType( + receiver, + EtsArrayType(EtsUnknownType, dimensions = 1), + ) as? EtsArrayType ?: return null + val length = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpLength, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val receiverMatchesType = state.memory.types.evalIsSubtype(receiver, arrayType) + val minimumLength = mkBv(0) + val maximumLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val guard = mkAnd( + receiverMatchesType, + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLength), + ) + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { + val descriptor = arrayDescriptorOf(arrayType) + val result = state.memory.allocConcrete(descriptor) + state.memory.initializeArrayLength( + arrayHeapRef = result, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + result + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) + } + + private fun TsUnknownCall.resolvedInstanceInputs(): List>? { + val resolvedReceiver = receiver?.resolved ?: return null + val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } + + return listOf(resolvedReceiver) + resolvedArguments + } + + private fun TsUnknownCall.hasGlobalArrayConstructorShape(): Boolean { + val owner = receiver?.source as? EtsLocal ?: return false + val ownerSignature = (owner.type as? EtsFunctionType)?.signature ?: return false + val ownerParameter = ownerSignature.parameters.singleOrNull() ?: return false + val ownerReturnType = ownerSignature.returnType as? EtsArrayType ?: return false + val callReturnType = resultType as? EtsArrayType ?: return false + val signatureFile = ownerSignature.enclosingClass.file + + return owner.name == "Array" && + signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT && + ownerSignature.name.isEmpty() && + ownerParameter.type == EtsNumberType && + ownerParameter.isOptional && + !ownerParameter.isRest && + ownerReturnType.elementType == EtsAnyType && + ownerReturnType.dimensions == 1 && + callReturnType.elementType == EtsAnyType && + callReturnType.dimensions == 1 + } + + private class ArrayPrimitiveModel( + methodName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ) : TsUnknownCallModel { + override val id: String = "ts.array.primitive.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (call.receiver != null || call.arguments.size != arity) { + return null + } + + val inputs = call.arguments.map { argument -> argument.resolved ?: return null } + return implementation(state, inputs) + } + } + + private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" + private const val ARRAY_FROM_LENGTH_ID = "ts.array.fromLength" + private const val PRIMITIVE_GROW_ID = "ts.array.primitive.grow" + private const val PRIMITIVE_ALLOCATE_ID = "ts.array.primitive.allocate" + private const val PRIMITIVE_ALLOCATE_LIKE_ID = "ts.array.primitive.allocateLike" + private const val UNKNOWN_SIGNATURE_COMPONENT = "%unk" +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt new file mode 100644 index 0000000000..a238d155ca --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt @@ -0,0 +1,90 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.machine.call.TsUnknownCall +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.state.TsState + +/** Exact `Array.isArray` semantics for the genuine global `Array` built-in. */ +internal object TsArrayIsArrayIntrinsicModel : TsBuiltInUnknownCallModel { + const val MODEL_ID: String = "ts.array.isArray" + + private val anyArrayType = EtsArrayType(elementType = EtsUnknownType, dimensions = 1) + + override val id: String = MODEL_ID + override val target = TsUnknownCallTarget( + methodName = "isArray", + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (!call.hasGlobalArrayOwner()) { + return null + } + + val value = when { + call.arguments.isEmpty() -> return state.normalExecution(state.ctx.falseExpr) + else -> call.arguments.first().resolved ?: return null + } + val result = state.isArray(value) + + return state.normalExecution(result) + } + + private fun TsUnknownCall.hasGlobalArrayOwner(): Boolean { + val owner = receiver?.source as? EtsLocal ?: return false + val ownerType = owner.type as? EtsFunctionType ?: return false + val signatureFile = ownerType.signature.enclosingClass.file + + return owner.name == "Array" && + ownerType.signature.returnType is EtsArrayType && + signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT + } + + private fun TsState.isArray(value: UExpr<*>) = with(ctx) { + when { + value.isFakeObject() -> { + val fakeType = value.getFakeType(memory) + val reference = value.extractRef(memory) + + mkAnd( + fakeType.refTypeExpr, + isNonNullArrayReference(reference), + ) + } + + value.sort == addressSort -> isNonNullArrayReference(value.asExpr(addressSort)) + else -> falseExpr + } + } + + private fun TsState.isNonNullArrayReference(reference: UHeapRef): UBoolExpr = with(ctx) { + val isUndefined = mkEq(reference, mkUndefinedValue()) + val isNull = mkEq(reference, mkTsNullValue()) + mkAnd( + mkNot(isUndefined), + mkNot(isNull), + memory.types.evalIsSubtype(reference, anyArrayType), + ) + } + + private fun TsState.normalExecution(result: UExpr<*>): TsUnknownCallModelExecution = with(ctx) { + val successor = TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + TsUnknownCallModelExecution(successors = listOf(successor)) + } + + private const val UNKNOWN_SIGNATURE_COMPONENT: String = "%unk" +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt deleted file mode 100644 index e6e0ad88c0..0000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.usvm.machine.call.intrinsic - -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsArrayType -import org.jacodb.ets.model.EtsFile -import org.usvm.machine.call.TsEtsIrUnknownCallModel -import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard -import org.usvm.machine.call.TsMachineLocalUnknownCallModel -import org.usvm.machine.call.TsUnknownCall -import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.machine.call.TsUnknownCallModel -import org.usvm.machine.call.TsUnknownCallTarget -import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact -import org.usvm.machine.state.TsState -import org.usvm.util.arrayStorageType -import java.util.IdentityHashMap - -/** Built-in `Array.pop` implemented by an ordinary TypeScript body. */ -internal object TsArrayPopEtsIrModel : TsBuiltInUnknownCallModel, TsMachineLocalUnknownCallModel { - override val id: String = "ts.array.pop" - override val target = TsUnknownCallTarget( - methodName = "pop", - failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, - ) - - private val model by lazy { - val artifact = loadBundledEtsIrUnknownCallModelArtifact( - resourceName = "/org/usvm/machine/call/models/ArrayModels.ts", - sourceFileName = "ArrayModels.ts", - entryPointClassName = "ArrayModels", - entryPointMethodName = "pop", - ) - val domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> - with(state.ctx) { - val receiver = inputs.singleOrNull() - val staticType = call.receiver?.source?.type - if (staticType == null || receiver?.sort != addressSort) { - falseExpr - } else { - val array = receiver.asExpr(addressSort) - val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType - if (array.hasFakeValueBranch() || receiverType?.dimensions != 1) { - falseExpr - } else { - state.memory.types.evalIsSubtype(array, receiverType) - } - } - } - } - - TsEtsIrUnknownCallModel( - id = id, - target = target, - artifact = artifact, - domainGuard = domainGuard, - ) - } - - override val additionalSceneFiles get() = model.additionalSceneFiles - - override fun apply(state: TsState, call: TsUnknownCall) = model.apply(state, call) - - override fun materializeForMachine( - materializedFiles: IdentityHashMap, - ): TsUnknownCallModel = model.materializeForMachine(materializedFiles) -} 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 index ce29d46e5b..7edace4767 100644 --- 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 @@ -4,3 +4,8 @@ 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 + +/** Supplies a parameterized family of built-in semantic models from one object. */ +internal sealed interface TsBuiltInUnknownCallModelFamily { + val models: List +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt new file mode 100644 index 0000000000..77f536ab64 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -0,0 +1,367 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.utils.CONSTRUCTOR_NAME +import org.usvm.UExpr +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.state.TsState + +/** Numeric `Date` API family implemented by an ordinary TypeScript source body. */ +internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val DATE_CLASS = "Date" + private const val MAX_CONSTRUCTOR_ARGUMENTS = 7 + private const val RESOURCE = "/org/usvm/machine/call/models/DateModels.ts" + + private val artifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE, + sourceFileName = "DateModels.ts", + entryPointClassName = "DateModels", + entryPointMethodName = "construct", + ) + } + + override val models: List by lazy { + buildList { + add(constructorModel()) + add(utcModel()) + add(nowModel()) + + for (methodName in listOf( + "getDate", + "getDay", + "getFullYear", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getSeconds", + "getTime", + "getTimezoneOffset", + "getUTCDate", + "getUTCDay", + "getUTCFullYear", + "getUTCHours", + "getUTCMilliseconds", + "getUTCMinutes", + "getUTCMonth", + "getUTCSeconds", + "toISOString", + "valueOf", + )) { + add(instanceModel(idSuffix = methodName, methodName = methodName, consumedArgs = 0)) + } + + add(instanceModel(idSuffix = "set-date", methodName = "setDate", consumedArgs = 1)) + add( + instanceArityModel( + idSuffix = "set-full-year", + methodName = "setFullYear", + entryPointName = "setFullYear", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-hours", + methodName = "setHours", + entryPointName = "setHours", + minArgs = 1, + maxArgs = 4, + ) + ) + add( + instanceModel( + idSuffix = "set-milliseconds", + methodName = "setMilliseconds", + consumedArgs = 1, + ) + ) + add( + instanceArityModel( + idSuffix = "set-minutes", + methodName = "setMinutes", + entryPointName = "setMinutes", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-month", + methodName = "setMonth", + entryPointName = "setMonth", + minArgs = 1, + maxArgs = 2, + ) + ) + add( + instanceArityModel( + idSuffix = "set-seconds", + methodName = "setSeconds", + entryPointName = "setSeconds", + minArgs = 1, + maxArgs = 2, + ) + ) + add(instanceModel(idSuffix = "set-time", methodName = "setTime", consumedArgs = 1)) + add(instanceModel(idSuffix = "set-utc-date", methodName = "setUTCDate", consumedArgs = 1)) + add( + instanceArityModel( + idSuffix = "set-utc-full-year", + methodName = "setUTCFullYear", + entryPointName = "setUTCFullYear", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-hours", + methodName = "setUTCHours", + entryPointName = "setUTCHours", + minArgs = 1, + maxArgs = 4, + ) + ) + add( + instanceModel( + idSuffix = "set-utc-milliseconds", + methodName = "setUTCMilliseconds", + consumedArgs = 1, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-minutes", + methodName = "setUTCMinutes", + entryPointName = "setUTCMinutes", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-month", + methodName = "setUTCMonth", + entryPointName = "setUTCMonth", + minArgs = 1, + maxArgs = 2, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-seconds", + methodName = "setUTCSeconds", + entryPointName = "setUTCSeconds", + minArgs = 1, + maxArgs = 2, + ) + ) + } + } + + private fun constructorModel(): TsUnknownCallModel = model( + idSuffix = "constructor", + methodName = CONSTRUCTOR_NAME, + entryPointName = "construct", + domainGuard = instanceGuard(minArgs = 0, numericArgs = MAX_CONSTRUCTOR_ARGUMENTS), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val nowMilliseconds = dateNowMilliseconds + if (call.arguments.isEmpty() && nowMilliseconds == null) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + val arguments = call.resolvedArguments(maxArgs = MAX_CONSTRUCTOR_ARGUMENTS)?.toMutableList() + ?: return@TsEtsIrUnknownCallModelInputAdapter null + while (arguments.size < MAX_CONSTRUCTOR_ARGUMENTS) { + arguments += mkFp64(0.0) + } + val argumentCount = mkFp64(call.arguments.size.toDouble()) + val clock = mkFp64(nowMilliseconds ?: 0.0) + + listOf( + receiver, + argumentCount, + clock, + ) + arguments + } + }, + ) + + private fun nowModel(): TsUnknownCallModel = model( + idSuffix = "now", + methodName = "now", + entryPointName = "now", + domainGuard = numericGuard(minArgs = 0, numericArgs = 0), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, _ -> + with(state.ctx) { + listOf(mkFp64(dateNowMilliseconds ?: return@TsEtsIrUnknownCallModelInputAdapter null)) + } + }, + ) + + private fun utcModel(): TsUnknownCallModel = model( + idSuffix = "utc", + methodName = "UTC", + entryPointName = "utc", + domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + if (call.hasNumericOrUndefinedArguments(maxArgs = MAX_CONSTRUCTOR_ARGUMENTS, state = state)) { + state.ctx.trueExpr + } else { + state.ctx.falseExpr + } + }, + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val arguments = call.arguments.take(MAX_CONSTRUCTOR_ARGUMENTS).map { argument -> + when (val resolved = argument.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null) { + mkUndefinedValue() -> mkFp64NaN() + else -> resolved + } + }.toMutableList() + while (arguments.size < MAX_CONSTRUCTOR_ARGUMENTS) { + arguments += mkFp64(0.0) + } + + listOf(mkFp64(call.arguments.size.toDouble())) + arguments + } + }, + ) + + private fun instanceModel( + idSuffix: String, + methodName: String, + consumedArgs: Int, + ): TsUnknownCallModel = model( + idSuffix = idSuffix, + methodName = methodName, + entryPointName = methodName, + domainGuard = instanceGuard(minArgs = 0, numericArgs = consumedArgs), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = call.resolvedArguments(consumedArgs) + ?: return@TsEtsIrUnknownCallModelInputAdapter null + listOf(receiver) + arguments + }, + ) + + private fun instanceArityModel( + idSuffix: String, + methodName: String, + entryPointName: String, + minArgs: Int, + maxArgs: Int, + ): TsUnknownCallModel = model( + idSuffix = idSuffix, + methodName = methodName, + entryPointName = entryPointName, + domainGuard = instanceGuard(minArgs = minArgs, numericArgs = maxArgs), + inputAdapter = arityAdapter(maxArgs = maxArgs), + ) + + private fun model( + idSuffix: String, + methodName: String, + entryPointName: String, + domainGuard: TsEtsIrUnknownCallModelDomainGuard, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter, + ) = TsEtsIrUnknownCallModel( + id = "ts.date.$idSuffix", + target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = DATE_CLASS, + ), + artifact = artifact.withEntryPoint(entryPointName), + domainGuard = domainGuard, + inputAdapter = inputAdapter, + requiredModelIds = setOf("ts.math.floor"), + ) + + private fun instanceGuard( + minArgs: Int, + numericArgs: Int, + ) = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + with(state.ctx) { + val receiver = call.receiver + val receiverValue = receiver?.resolved + if ( + call.callee.enclosingClass.name != DATE_CLASS || + receiverValue?.sort != addressSort || + receiverValue.asExpr(addressSort).hasFakeValueBranch() + ) { + falseExpr + } else if (!call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state)) { + falseExpr + } else { + trueExpr + } + } + } + + private fun numericGuard( + minArgs: Int, + numericArgs: Int, + ) = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + if (call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state)) { + state.ctx.trueExpr + } else { + state.ctx.falseExpr + } + } + + private fun arityAdapter(maxArgs: Int) = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = call.resolvedArguments(maxArgs)?.toMutableList() + ?: return@TsEtsIrUnknownCallModelInputAdapter null + while (arguments.size < maxArgs) { + arguments += mkFp64(0.0) + } + + listOf(receiver, mkFp64(call.arguments.size.toDouble())) + arguments + } + } + + private fun TsUnknownCall.resolvedArguments(maxArgs: Int): List>? = + arguments.take(maxArgs).map { argument -> + argument.resolved ?: return null + } + + private fun TsUnknownCall.hasNumericArguments( + minArgs: Int, + numericArgs: Int, + state: TsState, + ): Boolean { + if (arguments.size < minArgs) { + return false + } + + return arguments.take(numericArgs).all { argument -> argument.resolved?.sort == state.ctx.fp64Sort } + } + + private fun TsUnknownCall.hasNumericOrUndefinedArguments( + maxArgs: Int, + state: TsState, + ): Boolean = with(state.ctx) { + arguments.take(maxArgs).all { argument -> + val resolved = argument.resolved + resolved?.sort == fp64Sort || resolved == mkUndefinedValue() + } + } + + private fun TsEtsIrUnknownCallModelArtifact.withEntryPoint(methodName: String): TsEtsIrUnknownCallModelArtifact { + val modelClass = file.allClasses.single { it.name == "DateModels" } + val method = modelClass.methods.single { it.name == methodName } + return copy(entryPoint = method) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt new file mode 100644 index 0000000000..ff549a595b --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt @@ -0,0 +1,82 @@ +package org.usvm.machine.call.intrinsic + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.utils.CONSTRUCTOR_NAME +import org.usvm.UConcreteHeapRef +import org.usvm.api.evalTypeEquals +import org.usvm.api.typeStreamOf +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.types.single + +/** Narrow built-in `Error(message: string)` constructor model. */ +internal object TsErrorEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + const val CONSTRUCTOR_ID: String = "ts.error.constructor" + + private const val ERROR_CLASS = "Error" + private const val RESOURCE = "/org/usvm/machine/call/models/ErrorModels.ts" + + private val artifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE, + sourceFileName = "ErrorModels.ts", + entryPointClassName = "ErrorModels", + entryPointMethodName = "construct", + ) + } + + override val models: List by lazy { + listOf(constructorModel()) + } + + private fun constructorModel(): TsUnknownCallModel { + val target = TsUnknownCallTarget( + methodName = CONSTRUCTOR_NAME, + enclosingClassName = ERROR_CLASS, + failureReason = TsUnknownCallFailureReason.RECEIVER_CLASS_NOT_FOUND, + ) + + return TsEtsIrUnknownCallModel( + id = CONSTRUCTOR_ID, + target = target, + artifact = artifact, + domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val message = inputs.getOrNull(1) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val builtinSignature = EtsClassSignature.UNKNOWN.copy(name = ERROR_CLASS) + val builtinErrorType = EtsClassType(signature = builtinSignature) + val receiverRuntimeType = state.memory.typeStreamOf(receiver).single() + + if (call.callee.enclosingClass != builtinSignature) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + if (receiver.hasFakeValueBranch() || receiverRuntimeType != builtinErrorType) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + if (message.hasFakeValueBranch() || state.associatedFunction.containsKey(message)) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + + state.memory.types.evalTypeEquals(message, EtsStringType) + } + }, + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val message = call.arguments.singleOrNull()?.resolved + ?: return@TsEtsIrUnknownCallModelInputAdapter null + + listOf(receiver, message) + }, + ) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt new file mode 100644 index 0000000000..af17e12e8a --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt @@ -0,0 +1,364 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.expr.KFpRoundingMode +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.usvm.UBoolExpr +import org.usvm.UExpr +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.call.hasBuiltinGlobalOwner +import org.usvm.machine.state.TsState + +internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily { + const val MATH_ABS_ID: String = "ts.math.abs" + const val MATH_CEIL_ID: String = "ts.math.ceil" + const val MATH_FLOOR_ID: String = "ts.math.floor" + const val MATH_MAX_ID: String = "ts.math.max" + const val MATH_MIN_ID: String = "ts.math.min" + const val MATH_ROUND_ID: String = "ts.math.round" + const val MATH_SQRT_ID: String = "ts.math.sqrt" + const val MATH_TRUNC_ID: String = "ts.math.trunc" + const val NUMBER_IS_FINITE_ID: String = "ts.number.isFinite" + const val NUMBER_IS_INTEGER_ID: String = "ts.number.isInteger" + const val NUMBER_IS_NAN_ID: String = "ts.number.isNaN" + const val NUMBER_IS_SAFE_INTEGER_ID: String = "ts.number.isSafeInteger" + + private val mathAbsModel = NumericIntrinsicModel( + id = MATH_ABS_ID, + methodName = "abs", + implementation = { state, call -> + unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } + }, + ) + private val mathCeilModel = NumericIntrinsicModel( + id = MATH_CEIL_ID, + methodName = "ceil", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), + value = value, + ) + } + } + }, + ) + private val mathFloorModel = NumericIntrinsicModel( + id = MATH_FLOOR_ID, + methodName = "floor", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardNegative), + ) + private val mathMaxModel = NumericIntrinsicModel( + id = MATH_MAX_ID, + methodName = "max", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.NEGATIVE_INFINITY, + combine = state::mathMax, + ) + }, + ) + private val mathMinModel = NumericIntrinsicModel( + id = MATH_MIN_ID, + methodName = "min", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.POSITIVE_INFINITY, + combine = state::mathMin, + ) + }, + ) + private val mathRoundModel = NumericIntrinsicModel( + id = MATH_ROUND_ID, + methodName = "round", + implementation = { state, call -> unaryMathCall(state, call, state::mathRound) }, + ) + private val mathSqrtModel = NumericIntrinsicModel( + id = MATH_SQRT_ID, + methodName = "sqrt", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + state.ctx.mkFpSqrtExpr(state.ctx.fpRoundingModeSortDefaultValue(), value) + } + }, + ) + private val mathTruncModel = NumericIntrinsicModel( + id = MATH_TRUNC_ID, + methodName = "trunc", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardZero), + ) + private val numberIsFiniteModel = NumericIntrinsicModel( + id = NUMBER_IS_FINITE_ID, + methodName = "isFinite", + implementation = { state, call -> + numberPredicate(state, call) { value -> + with(state.ctx) { + val isNotNaN = mkFpIsNaNExpr(value).not() + val isNotInfinite = mkFpIsInfiniteExpr(value).not() + + mkAnd(isNotNaN, isNotInfinite) + } + } + }, + ) + private val numberIsIntegerModel = NumericIntrinsicModel( + id = NUMBER_IS_INTEGER_ID, + methodName = "isInteger", + implementation = { state, call -> numberPredicate(state, call, state::isInteger) }, + ) + private val numberIsNaNModel = NumericIntrinsicModel( + id = NUMBER_IS_NAN_ID, + methodName = "isNaN", + implementation = { state, call -> + numberPredicate(state, call) { value -> state.ctx.mkFpIsNaNExpr(value) } + }, + ) + private val numberIsSafeIntegerModel = NumericIntrinsicModel( + id = NUMBER_IS_SAFE_INTEGER_ID, + methodName = "isSafeInteger", + implementation = { state, call -> numberPredicate(state, call, state::isSafeInteger) }, + ) + + override val models: List = listOf( + mathAbsModel, + mathCeilModel, + mathFloorModel, + mathMaxModel, + mathMinModel, + mathRoundModel, + mathSqrtModel, + mathTruncModel, + numberIsFiniteModel, + numberIsIntegerModel, + numberIsNaNModel, + numberIsSafeIntegerModel, + ) +} + +private class NumericIntrinsicModel( + override val id: String, + methodName: String, + private val implementation: (TsState, TsUnknownCall) -> TsUnknownCallModelExecution?, +) : TsUnknownCallModel { + override val target = numericTarget(methodName) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? = + implementation(state, call) +} + +private fun numberPredicate( + state: TsState, + call: TsUnknownCall, + predicate: (UExpr) -> UBoolExpr, +): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Number")) { + return null + } + val value = call.arguments.firstOrNull()?.resolved + ?: return state.normalExecution(state.ctx.falseExpr) + val result = with(state.ctx) { + if (value.isFakeObject()) { + val type = value.getFakeType(state.memory) + mkAnd(type.fpTypeExpr, predicate(value.extractFp(state.memory))) + } else if (value.sort == fp64Sort) { + predicate(value.asExpr(fp64Sort)) + } else { + falseExpr + } + } + + return state.normalExecution(result) +} + +private fun roundingMathCall( + roundingMode: KFpRoundingMode, +): (TsState, TsUnknownCall) -> TsUnknownCallModelExecution? = { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(roundingMode), + value = value, + ) + } + } +} + +private fun numericTarget(methodName: String) = TsUnknownCallTarget( + methodName = methodName, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, +) + +private fun unaryMathCall( + state: TsState, + call: TsUnknownCall, + operation: (UExpr) -> UExpr, +): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Math")) { + return null + } + val argument = call.arguments.firstOrNull()?.resolved + ?: return state.normalExecution(state.ctx.mkFp(Double.NaN, state.ctx.fp64Sort)) + with(state.ctx) { + if (argument.isFakeObject()) { + val numberGuard = argument.getFakeType(state.memory).fpTypeExpr + val result = operation(argument.extractFp(state.memory)) + return state.normalExecution(result, guard = numberGuard) + } + } + if (argument.sort != state.ctx.fp64Sort) { + return null + } + + val result = operation(argument.asExpr(state.ctx.fp64Sort)) + return state.normalExecution(result) +} + +private fun variadicMathCall( + state: TsState, + call: TsUnknownCall, + identity: Double, + combine: (UExpr, UExpr) -> UExpr, +): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Math")) { + return null + } + val arguments = call.arguments.map { argument -> + val value = argument.resolved ?: return null + if (value.sort != state.ctx.fp64Sort) { + return null + } + + value.asExpr(state.ctx.fp64Sort) + } + val result = arguments.fold(state.ctx.mkFp(identity, state.ctx.fp64Sort), combine) + + return state.normalExecution(result) +} + +private fun TsUnknownCall.hasGlobalOwner(expectedName: String): Boolean { + val owner = receiver?.source ?: return false + return hasBuiltinGlobalOwner(owner = owner, callee = callee, expectedName = expectedName) +} + +private fun TsState.normalExecution( + result: UExpr<*>, + guard: UBoolExpr = ctx.trueExpr, +): TsUnknownCallModelExecution = with(ctx) { + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) +} + +private fun TsState.isInteger(value: UExpr) = with(ctx) { + val truncated = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = value, + ) + + mkAnd( + mkFpIsNaNExpr(value).not(), + mkFpIsInfiniteExpr(value).not(), + mkFpEqualExpr(value, truncated), + ) +} + +private fun TsState.isSafeInteger(value: UExpr) = with(ctx) { + val absoluteValue = mkFpAbsExpr(value) + val maxSafeInteger = mkFp(MAX_SAFE_INTEGER, fp64Sort) + val isInSafeRange = mkFpLessOrEqualExpr(absoluteValue, maxSafeInteger) + + mkAnd( + isInteger(value), + isInSafeRange, + ) +} + +private fun TsState.mathMin( + left: UExpr, + right: UExpr, +): UExpr = with(ctx) { + val zero = mkFp(0.0, fp64Sort) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val leftIsNegative = mkFpIsNegativeExpr(left) + val rightIsNegative = mkFpIsNegativeExpr(right) + val eitherNegative = mkOr(leftIsNegative, rightIsNegative) + val signedZero = mkIte(eitherNegative, negativeZero, zero) + val leftIsZero = mkFpIsZeroExpr(left) + val rightIsZero = mkFpIsZeroExpr(right) + val bothZero = mkAnd(leftIsZero, rightIsZero) + val equalResult = mkIte(bothZero, signedZero, left) + val rightLessResult = mkIte(mkFpLessExpr(right, left), right, equalResult) + val leftLessResult = mkIte(mkFpLessExpr(left, right), left, rightLessResult) + val rightNaNResult = mkIte(mkFpIsNaNExpr(right), right, leftLessResult) + + mkIte(mkFpIsNaNExpr(left), left, rightNaNResult) +} + +private fun TsState.mathMax( + left: UExpr, + right: UExpr, +): UExpr = with(ctx) { + val zero = mkFp(0.0, fp64Sort) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val leftIsPositive = mkFpIsPositiveExpr(left) + val rightIsPositive = mkFpIsPositiveExpr(right) + val eitherPositive = mkOr(leftIsPositive, rightIsPositive) + val signedZero = mkIte(eitherPositive, zero, negativeZero) + val leftIsZero = mkFpIsZeroExpr(left) + val rightIsZero = mkFpIsZeroExpr(right) + val bothZero = mkAnd(leftIsZero, rightIsZero) + val equalResult = mkIte(bothZero, signedZero, left) + val rightGreaterResult = mkIte(mkFpGreaterExpr(right, left), right, equalResult) + val leftGreaterResult = mkIte(mkFpGreaterExpr(left, right), left, rightGreaterResult) + val rightNaNResult = mkIte(mkFpIsNaNExpr(right), right, leftGreaterResult) + + mkIte(mkFpIsNaNExpr(left), left, rightNaNResult) +} + +private fun TsState.mathRound(value: UExpr): UExpr = with(ctx) { + val roundingMode = fpRoundingModeSortDefaultValue() + val floor = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative), + value = value, + ) + val fraction = mkFpSubExpr(roundingMode, value, floor) + val half = mkFp(ROUNDING_THRESHOLD, fp64Sort) + val useFloor = mkFpLessExpr(fraction, half) + val increment = mkFp(ROUNDING_INCREMENT, fp64Sort) + val incrementedFloor = mkFpAddExpr(roundingMode, floor, increment) + val rounded = mkIte(useFloor, floor, incrementedFloor) + val isNegative = mkFpIsNegativeExpr(value) + val roundedIsZero = mkFpIsZeroExpr(rounded) + val returnsNegativeZero = mkAnd(isNegative, roundedIsZero) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val signedRounded = mkIte(returnsNegativeZero, negativeZero, rounded) + val preserveInput = mkOr( + mkFpIsNaNExpr(value), + mkFpIsInfiniteExpr(value), + mkFpIsZeroExpr(value), + ) + + mkIte(preserveInput, value, signedRounded) +} + +private const val MAX_SAFE_INTEGER: Double = 9_007_199_254_740_991.0 +private const val NEGATIVE_ZERO: Double = -0.0 +private const val ROUNDING_THRESHOLD: Double = 0.5 +private const val ROUNDING_INCREMENT: Double = 1.0 diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt new file mode 100644 index 0000000000..c72b65eb9f --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -0,0 +1,570 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.expr.KFp64Value +import io.ksmt.utils.asExpr +import io.ksmt.utils.cast +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.api.evalTypeEquals +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +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.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.state.TsState +import org.usvm.sizeSort +import org.usvm.util.copyStringRange +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue +import org.usvm.util.mkFieldLValue +import org.usvm.util.stringFromCodeUnit + +/** Built-in String algorithms implemented by ordinary TypeScript bodies. */ +internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val CLASS_NAME = "StringModels" + private const val PRIMITIVES_CLASS_NAME = "StringModelPrimitives" + private const val RESOURCE_NAME = "/org/usvm/machine/call/models/StringModels.ts" + private const val REPLACE_ALL_INPUT_COUNT = 3 + + private val characterArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + + private val baseArtifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE_NAME, + sourceFileName = "StringModels.ts", + entryPointClassName = CLASS_NAME, + entryPointMethodName = "charAt", + ) + } + + private val optionalIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.isEmpty() -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> { + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } + + call.arguments.size == 1 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + } + + private val optionalPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> { + inputs + state.ctx.mkFp64(0.0) + } + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last() == state.ctx.mkUndefinedValue() -> { + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last().sort == state.ctx.fp64Sort -> inputs + + else -> null + } + } + } + + private val optionalEndPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> { + inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + } + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last() == state.ctx.mkUndefinedValue() -> { + inputs.dropLast(1) + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + } + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last().sort == state.ctx.fp64Sort -> inputs + + else -> null + } + } + } + + private val sliceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val receiver = inputs.firstOrNull()?.takeIf { it.sort == state.ctx.addressSort } + ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > 2) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + fun numericOrDefault(index: Int, default: UExpr<*>): UExpr<*> { + val value = arguments.getOrNull(index) ?: return default + return when { + value == state.ctx.mkUndefinedValue() -> default + value.sort == state.ctx.fp64Sort -> value + else -> return default + } + } + + val start = numericOrDefault(index = 0, default = state.ctx.mkFp64(0.0)) + val end = numericOrDefault( + index = 1, + default = state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort), + ) + if (arguments.any { value -> value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort }) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + listOf(receiver, start, end) + } + + private val noArgumentsAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.isNotEmpty()) { + null + } else { + call.resolvedInstanceInputs() + } + } + + private val receiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val receiver = inputs.firstOrNull() + if (receiver !is UConcreteHeapRef || receiver.hasFakeValueBranch() || + state.associatedFunction.containsKey(receiver) + ) { + falseExpr + } else { + state.memory.types.evalTypeEquals(receiver, EtsStringType) + } + } + } + + private val receiverAndSearchDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val receiver = inputs.getOrNull(0) + val searchString = inputs.getOrNull(1) + val receiverIsConstant = receiver is UConcreteHeapRef && !receiver.hasFakeValueBranch() && + !state.associatedFunction.containsKey(receiver) + val searchStringIsConstant = searchString is UConcreteHeapRef && !searchString.hasFakeValueBranch() && + !state.associatedFunction.containsKey(searchString) + + if (!receiverIsConstant || !searchStringIsConstant) { + falseExpr + } else { + mkAnd( + state.memory.types.evalTypeEquals(receiver, EtsStringType), + state.memory.types.evalTypeEquals(searchString, EtsStringType), + ) + } + } + } + + private val replaceAllAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.size == 2) call.resolvedInstanceInputs() else null + } + + private val replaceAllDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val strings = inputs.filterIsInstance() + if (strings.size != REPLACE_ALL_INPUT_COUNT || + strings.any { it.hasFakeValueBranch() || state.associatedFunction.containsKey(it) } + ) { + falseExpr + } else { + val guards = strings.map { state.memory.types.evalTypeEquals(it, EtsStringType) } + mkAnd(guards) + } + } + } + + private val asciiReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiverGuard = receiverDomain.evaluate(state, call, inputs) + val receiver = inputs.firstOrNull() as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + .asExpr(addressSort) + val length = state.memory.read(mkArrayLengthLValue(characters, characterArrayType)) + val characterGuards = (0 until MAX_ASCII_CASE_LENGTH).map { index -> + val symbolicIndex = mkBv(index) + val codeUnit = state.memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = symbolicIndex, + type = characterArrayType, + ) + ) + val isOutsideLength = mkBvSignedGreaterOrEqualExpr(symbolicIndex, length) + val asciiMax = mkBv(ASCII_MAX_CODE_UNIT, bv16Sort) + val isAscii = mkBvUnsignedLessOrEqualExpr(codeUnit, asciiMax) + mkOr( + isOutsideLength, + isAscii, + ) + } + + val maximumLength = mkBv(MAX_ASCII_CASE_LENGTH) + val withinLengthLimit = mkBvSignedLessOrEqualExpr(length, maximumLength) + mkAnd( + receiverGuard, + withinLengthLimit, + *characterGuards.toTypedArray(), + ) + } + } + + override val models: List by lazy { + listOf( + sourceModel( + id = "ts.string.charAt", + methodName = "charAt", + inputAdapter = optionalIndexAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.indexOf", + methodName = "indexOf", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.includes", + methodName = "includes", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.charCodeAt", + methodName = "charCodeAt", + inputAdapter = optionalIndexAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.startsWith", + methodName = "startsWith", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.endsWith", + methodName = "endsWith", + inputAdapter = optionalEndPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.lastIndexOf", + methodName = "lastIndexOf", + inputAdapter = optionalEndPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.slice", + methodName = "slice", + inputAdapter = sliceAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.substring", + methodName = "substring", + inputAdapter = sliceAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.replaceAll", + methodName = "replaceAll", + inputAdapter = replaceAllAdapter, + domainGuard = replaceAllDomain, + ), + sourceModel( + id = "ts.string.trim", + methodName = "trim", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.trimStart", + methodName = "trimStart", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.trimEnd", + methodName = "trimEnd", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.toUpperCase", + methodName = "toUpperCase", + inputAdapter = noArgumentsAdapter, + domainGuard = asciiReceiverDomain, + ), + sourceModel( + id = "ts.string.toLowerCase", + methodName = "toLowerCase", + inputAdapter = noArgumentsAdapter, + domainGuard = asciiReceiverDomain, + ), + primitiveModel( + methodName = "length", + arity = 1, + implementation = ::stringLength, + ), + primitiveModel( + methodName = "codeUnitAt", + arity = 2, + implementation = ::stringCodeUnitAt, + ), + primitiveModel( + methodName = "fromCodeUnit", + arity = 1, + implementation = ::stringFromCodeUnit, + ), + primitiveModel( + methodName = "copyRange", + arity = 3, + implementation = ::stringCopyRange, + ), + ) + } + + private fun sourceModel( + id: String, + methodName: String, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter, + domainGuard: TsEtsIrUnknownCallModelDomainGuard, + ): TsUnknownCallModel = TsEtsIrUnknownCallModel( + id = id, + target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = "String", + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ), + artifact = artifact(methodName), + domainGuard = domainGuard, + inputAdapter = inputAdapter, + requiredModelIds = buildSet { + add(MATH_FLOOR_MODEL_ID) + add(PRIMITIVE_LENGTH_ID) + add(PRIMITIVE_CODE_UNIT_AT_ID) + if (methodName in setOf("charAt", "toUpperCase", "toLowerCase", "replaceAll")) { + add(PRIMITIVE_FROM_CODE_UNIT_ID) + } + if (methodName in setOf("slice", "substring", "trim", "trimStart", "trimEnd", "replaceAll")) { + add(PRIMITIVE_COPY_RANGE_ID) + } + }, + ) + + private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { + val artifact = baseArtifact + val entryPoint = artifact.file.allClasses + .single { it.name == CLASS_NAME } + .methods + .single { it.name == methodName } + + return artifact.copy(entryPoint = entryPoint) + } + + private fun primitiveModel( + methodName: String, + arity: Int, + implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ): TsUnknownCallModel = StringPrimitiveModel( + methodName = methodName, + arity = arity, + implementation = implementation, + ) + + private fun stringLength( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.singleOrNull()?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + val length = state.memory.read(mkArrayLengthLValue(characters, characterArrayType)) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val result = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.cast(), + signed = true, + ) + + singleSuccessor( + state = state, + guard = receiverIsString, + result = result, + ) + } + + private fun stringCodeUnitAt( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0)?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + val fpIndex = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val index = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpIndex, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + val codeUnit = state.memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = index, + type = characterArrayType, + ) + ) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val result = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = codeUnit.cast(), + signed = false, + ) + + singleSuccessor( + state = state, + guard = receiverIsString, + result = result, + ) + } + + private fun stringFromCodeUnit( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val code = inputs.singleOrNull()?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val successor = TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { + if (code is KFp64Value) { + mkInitializedStringConstant(code.value.toInt().toChar().toString()) + } else { + stringFromCodeUnit(code) + } + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + ) + } + + private fun stringCopyRange( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0)?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + val start = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val end = inputs.getOrNull(2)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val from = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = start, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val to = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = end, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val length = mkBvSubExpr(to, from) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val successor = TsUnknownCallModelSuccessor( + guard = receiverIsString, + completion = TsUnknownCallModelCompletion.Normal { + copyStringRange(receiver = receiver, from = from, length = length) + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = receiverIsString.takeUnless { it == trueExpr }?.let(::mkNot), + ) + } + + private fun singleSuccessor( + state: TsState, + guard: org.usvm.UBoolExpr, + result: UExpr<*>, + ): TsUnknownCallModelExecution { + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == state.ctx.trueExpr }?.let(state.ctx::mkNot), + ) + } + + private fun TsUnknownCall.resolvedInstanceInputs(): List>? { + val resolvedReceiver = receiver?.resolved ?: return null + val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } + + return listOf(resolvedReceiver) + resolvedArguments + } + + private class StringPrimitiveModel( + methodName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ) : TsUnknownCallModel { + override val id: String = "ts.string.primitive.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (call.receiver != null || call.arguments.size != arity) { + return null + } + + val inputs = call.arguments.map { argument -> argument.resolved ?: return null } + return implementation(state, inputs) + } + } + + private const val PRIMITIVE_LENGTH_ID = "ts.string.primitive.length" + private const val PRIMITIVE_CODE_UNIT_AT_ID = "ts.string.primitive.codeUnitAt" + private const val PRIMITIVE_FROM_CODE_UNIT_ID = "ts.string.primitive.fromCodeUnit" + private const val PRIMITIVE_COPY_RANGE_ID = "ts.string.primitive.copyRange" + private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" + private const val MAX_ASCII_CASE_LENGTH = 16 + private const val ASCII_MAX_CODE_UNIT = 0x7f +} 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 385cd38679..255905be13 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,6 +7,7 @@ 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.EtsStringType import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr @@ -25,11 +26,14 @@ import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModelDispatcher import org.usvm.machine.call.dispatch +import org.usvm.machine.call.hasBuiltinGlobalOwner +import org.usvm.machine.call.isDateReceiver 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.TsMethodResult +import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.machine.types.mkFakeValue @@ -44,6 +48,7 @@ import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods private val logger = KotlinLogging.logger {} +private val legacyArrayMethods = setOf("concat", "fill", "join", "push", "reverse", "slice", "unshift") internal fun TsExprResolver.tryApproximateGlobalInstanceCall( expr: EtsInstanceCallExpr, @@ -53,10 +58,16 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( return from(mkUndefinedValue()) } - // Handle `Number.isNaN()` calls - if (expr.instance.name == "Number") { - if (expr.callee.name == "isNaN") { - return from(handleNumberIsNaN(expr)) + // Handle `Number` calls. + if (hasBuiltinGlobalOwner(owner = expr.instance, callee = expr.callee, expectedName = "Number")) { + when (expr.callee.name) { + "isFinite", "isInteger", "isSafeInteger" -> { + return tryDispatchNumericBuiltin(expr) + ?: TsExprApproximationResult.NoApproximation + } + + "isNaN" -> return tryDispatchNumericBuiltin(expr) + ?: from(handleNumberIsNaN(expr)) } } @@ -77,16 +88,46 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( } } - // Handle `Math` method calls - if (expr.instance.name == "Math") { - if (expr.callee.name == "floor") { - return from(handleMathFloor(expr)) + // Handle `Math` method calls. + if (hasBuiltinGlobalOwner(owner = expr.instance, callee = expr.callee, expectedName = "Math")) { + when (expr.callee.name) { + "abs", "ceil", "max", "min", "round", "sqrt", "trunc" -> { + return tryDispatchNumericBuiltin(expr) + ?: TsExprApproximationResult.NoApproximation + } + + "floor" -> return tryDispatchNumericBuiltin(expr) + ?: from(handleMathFloor(expr)) } } return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.tryDispatchNumericBuiltin( + expr: EtsInstanceCallExpr, +): TsExprApproximationResult? { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return null + } + val resolvedArguments = buildList { + for (argument in expr.args) { + add(resolve(argument) ?: return TsExprApproximationResult.ResolveFailure) + } + } + + dispatcher.dispatch( + scope = scope, + call = expr, + callSite = scope.calcOnState { lastStmt }, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + resolvedArguments = resolvedArguments, + ) + + return TsExprApproximationResult.ResolveFailure +} + internal fun TsExprResolver.tryApproximateInstanceCall( stmt: TsVirtualMethodCallStmt, ): TsExprApproximationResult = with(ctx) { @@ -95,6 +136,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Mock `.toString()` method calls if (expr.callee.name == "toString") { + if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { + dispatchLegacyInstanceCall(stmt) + return TsExprApproximationResult.ResolveFailure + } + if (expr.args.isNotEmpty()) { logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } } @@ -103,7 +149,10 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `.valueOf()` method calls if (expr.callee.name == "valueOf") { - return from(handleValueOf(expr, instance)) + val receiverIsDate = scope.calcOnState { isDateReceiver(expr, instance) } + if (!receiverIsDate) { + return from(handleValueOf(expr, instance)) + } } if (instance.sort != addressSort) return TsExprApproximationResult.NoApproximation @@ -116,6 +165,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( .takeIf { it !is TsUnresolvedSort } ?: addressSort + if (expr.callee.name in legacyArrayMethods && unknownCallDispatcher is TsUnknownCallModelDispatcher) { + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure + } + // Handle 'Array.push()' method calls if (expr.callee.name == "push") { return from(handleArrayPush(expr, instanceType, elementSort, array)) @@ -156,14 +210,14 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return handleArrayConcat(stmt, instanceType, array) } - // Handle `Array.indexOf() method calls - if (expr.callee.name == "indexOf") { - return from(handleArrayIndexOf(expr, instanceType, elementSort, array)) + // Handle Array search and indexed access method calls. + if (expr.callee.name in setOf("indexOf", "lastIndexOf")) { + return handleArrayIndexSearchCall(stmt, instanceType, elementSort, array) } // Handle `Array.includes() method calls if (expr.callee.name == "includes") { - return from(handleArrayIncludes(expr)) + return handleArrayIncludesCall(stmt) } // Handle `Array.reverse() method calls @@ -172,9 +226,102 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } } + val modeledStringMethods = setOf( + "replaceAll", + "substring", + "trim", + "trimStart", + "trimEnd", + "charAt", + "charCodeAt", + "endsWith", + "includes", + "indexOf", + "lastIndexOf", + "slice", + "startsWith", + "toLowerCase", + "toUpperCase", + ) + if (instanceType is EtsStringType && expr.callee.name in modeledStringMethods) { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return TsExprApproximationResult.NoApproximation + } + + dispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + callee = stmt.call.callee.withEnclosingClassName("String"), + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) + + return TsExprApproximationResult.ResolveFailure + } + return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.handleArrayIndexSearchCall( + stmt: TsVirtualMethodCallStmt, + instanceType: EtsArrayType, + elementSort: USort, + array: UHeapRef, +): TsExprApproximationResult { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + if (stmt.call.callee.name != "indexOf") { + return TsExprApproximationResult.NoApproximation + } + + return from(handleArrayIndexOf(stmt.call, instanceType, elementSort, array)) + } + + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure +} + +private fun TsExprResolver.handleArrayIncludesCall( + stmt: TsVirtualMethodCallStmt, +): TsExprApproximationResult { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return from(handleArrayIncludes(stmt.call)) + } + + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure +} + +private fun TsExprResolver.dispatchArrayModel(stmt: TsVirtualMethodCallStmt) { + unknownCallDispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + callee = stmt.call.callee.withEnclosingClassName("Array"), + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) +} + +private fun TsExprResolver.dispatchLegacyInstanceCall(stmt: TsVirtualMethodCallStmt) { + unknownCallDispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) +} + +private fun EtsMethodSignature.withEnclosingClassName(name: String): EtsMethodSignature = + copy(enclosingClass = enclosingClass.copy(name = name)) + private fun TsExprResolver.handleArrayPopCall( stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt new file mode 100644 index 0000000000..aa7a453b8b --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt @@ -0,0 +1,164 @@ +package org.usvm.machine.expr + +import io.ksmt.expr.KFpRoundingMode +import io.ksmt.sort.KBvSort +import io.ksmt.sort.KFp64Sort +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.USort +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.types.extractValue + +private const val UINT32_MODULUS = 4_294_967_296.0 +private const val UINT32_MAX_VALUE = 4_294_967_295.0 + +internal data class TsNumericArrayIndex( + val value: UExpr, + val isNumeric: UBoolExpr, + val hasUnsupportedReadKey: UBoolExpr, +) + +/** Classifies an index that may be wrapped with another runtime type. */ +internal fun TsContext.extractNumericArrayIndex( + scope: TsStepScope, + index: UExpr, +): TsNumericArrayIndex { + val runtimeValues = scope.calcOnState { + with(ctx) { + val (_, boolGuard) = extractValue(index, boolSort, ::getIntermediateBoolLValue) + val (fpValue, fpGuard) = extractValue(index, fp64Sort, ::getIntermediateFpLValue) + val (reference, refGuard) = extractValue(index, addressSort, ::getIntermediateRefLValue) + + IndexRuntimeValues( + boolTypeGuard = boolGuard, + numericValue = fpValue, + numericTypeGuard = fpGuard, + refValue = reference, + refTypeGuard = refGuard, + ) + } + } + val refIsKnownMissingKey = if (runtimeValues.refValue == null) { + falseExpr + } else { + val refIsNull = mkHeapRefEq(runtimeValues.refValue, mkTsNullValue()) + val refIsUndefined = mkHeapRefEq(runtimeValues.refValue, mkUndefinedValue()) + val refIsNullish = mkOr(refIsNull, refIsUndefined) + + mkAnd( + runtimeValues.refTypeGuard, + refIsNullish, + ) + } + val hasSupportedRuntimeType = mkOr( + runtimeValues.boolTypeGuard, + runtimeValues.numericTypeGuard, + refIsKnownMissingKey, + ) + + return TsNumericArrayIndex( + value = runtimeValues.numericValue ?: mkFp64(0.0), + isNumeric = runtimeValues.numericTypeGuard, + hasUnsupportedReadKey = mkNot(hasSupportedRuntimeType), + ) +} + +private data class IndexRuntimeValues( + val boolTypeGuard: UBoolExpr, + val numericValue: UExpr?, + val numericTypeGuard: UBoolExpr, + val refValue: UHeapRef?, + val refTypeGuard: UBoolExpr, +) + +/** ECMAScript ToUint32, represented as the common 32-bit bit pattern used by ToInt32. */ +internal fun TsContext.mkEcmaScriptToUint32(value: UExpr): UExpr = with(this) { + val zero = mkFp64(0.0) + val isFinite = mkAnd( + mkFpIsNaNExpr(value).not(), + mkFpIsInfiniteExpr(value).not(), + ) + val finiteValue = mkIte(isFinite, value, zero) + val towardZero = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero) + val integer = mkFpRoundToIntegralExpr( + roundingMode = towardZero, + value = finiteValue, + ) + val modulus = mkFp64(UINT32_MODULUS) + val remainder = mkFpRemExpr(integer, modulus) + val remainderIsNegative = mkFpLessExpr(remainder, zero) + val positiveRemainder = mkFpAddExpr(fpRoundingModeSortDefaultValue(), remainder, modulus) + val normalizedRemainder = mkIte( + condition = remainderIsNegative, + trueBranch = positiveRemainder, + falseBranch = remainder, + ) + + mkFpToBvExpr( + roundingMode = towardZero, + value = normalizedRemainder, + bvSize = bv32Sort.sizeBits.toInt(), + isSigned = false, + ) +} + +internal fun TsContext.mkValidArrayIndexProperty( + value: UExpr, + maximumSupportedIndex: Int, +): UBoolExpr = mkFiniteIntegerInRange( + value = value, + minimum = 0.0, + maximum = maximumSupportedIndex.toDouble(), +) + +internal fun TsContext.mkValidArrayLength(value: UExpr): UBoolExpr = mkFiniteIntegerInRange( + value = value, + minimum = 0.0, + maximum = UINT32_MAX_VALUE, +) + +internal fun TsContext.mkFpToUint32AfterValidation( + value: UExpr, + validity: UBoolExpr, +): UExpr = with(this) { + val safeValue = mkIte( + condition = validity, + trueBranch = value, + falseBranch = mkFp64(0.0), + ) + mkFpToBvExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = safeValue, + bvSize = bv32Sort.sizeBits.toInt(), + isSigned = false, + ) +} + +private fun TsContext.mkFiniteIntegerInRange( + value: UExpr, + minimum: Double, + maximum: Double, +): UBoolExpr = with(this) { + val towardZero = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero) + val integer = mkFpRoundToIntegralExpr( + roundingMode = towardZero, + value = value, + ) + val isNotNaN = mkFpIsNaNExpr(value).not() + val isNotInfinite = mkFpIsInfiniteExpr(value).not() + val isInteger = mkFpEqualExpr(value, integer) + val minimumValue = mkFp64(minimum) + val maximumValue = mkFp64(maximum) + val isAtLeastMinimum = mkFpLessOrEqualExpr(minimumValue, value) + val isAtMostMaximum = mkFpLessOrEqualExpr(value, maximumValue) + + mkAnd( + isNotNaN, + isNotInfinite, + isInteger, + isAtLeastMinimum, + isAtMostMaximum, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt new file mode 100644 index 0000000000..1a97c07731 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt @@ -0,0 +1,29 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFieldSignature + +internal val builtInErrorSignature = EtsClassSignature.UNKNOWN.copy(name = "Error") + +internal const val ERROR_NAME_STORAGE_FIELD = "__usvmErrorName" +internal const val ERROR_MESSAGE_STORAGE_FIELD = "__usvmErrorMessage" + +internal fun EtsFieldSignature.isErrorModelStorageDefinitionField(): Boolean = + enclosingClass.name == "ErrorValue" && + enclosingClass.file.fileName == "ErrorModels.ts" && + (name == ERROR_NAME_STORAGE_FIELD || name == ERROR_MESSAGE_STORAGE_FIELD) + +internal fun EtsFieldSignature.isUnresolvedErrorField(): Boolean = + enclosingClass == builtInErrorSignature && (name == "name" || name == "message") + +internal fun EtsFieldSignature.errorModelStorageField(): String? { + if (enclosingClass != builtInErrorSignature && enclosingClass != EtsClassSignature.UNKNOWN) { + return null + } + + return when (name) { + "name" -> ERROR_NAME_STORAGE_FIELD + "message" -> ERROR_MESSAGE_STORAGE_FIELD + else -> null + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt index 0d913621ee..1a22c04022 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt @@ -19,6 +19,11 @@ import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.ExprWithTypeConstraint import org.usvm.types.single import org.usvm.util.boolToFp +import org.usvm.util.refOrStringTruthy + +private fun TsState.refTruthyExpr(ref: UHeapRef): UBoolExpr = with(ctx) { + refOrStringTruthy(ref) +} fun TsContext.checkNotFake(expr: UExpr<*>) { require(!expr.isFakeObject()) { @@ -63,10 +68,7 @@ fun TsContext.mkTruthyExpr( val value = memory.read(getIntermediateRefLValue(expr.address)) conjuncts += ExprWithTypeConstraint( constraint = possibleType.refTypeExpr, - expr = mkAnd( - mkHeapRefEq(value, mkTsNullValue()).not(), - mkHeapRefEq(value, mkUndefinedValue()).not(), - ) + expr = refTruthyExpr(value), ) } @@ -89,10 +91,7 @@ fun TsContext.mkTruthyExpr( mkFpIsNaNExpr(expr.asExpr(fp64Sort)).not() ) - addressSort -> mkAnd( - mkHeapRefEq(expr.asExpr(addressSort), mkTsNullValue()).not(), - mkHeapRefEq(expr.asExpr(addressSort), mkUndefinedValue()).not(), - ) + addressSort -> refTruthyExpr(expr.asExpr(addressSort)) else -> TODO("Unsupported sort: ${expr.sort}") } 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 4ffb6e227e..13f2ddb0aa 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,11 +4,17 @@ 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.EtsStringType +import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.isFalse +import org.usvm.isTrue import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort @@ -43,32 +49,40 @@ internal fun TsExprResolver.handleArrayAccess( // Resolve the index. val resolvedIndex = resolve(value.index) ?: return null - check(resolvedIndex.sort == fp64Sort) { - "Expected fp64 sort for index, got: ${resolvedIndex.sort}" + val index = extractNumericArrayIndex(scope, resolvedIndex) + if (scope.checkSat(index.hasUnsupportedReadKey) != null) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_READ, + detail = "property key requires unsupported ToPropertyKey conversion: $resolvedIndex", + ) + } + scope.assert(mkNot(index.hasUnsupportedReadKey)) ?: return null + + val storageType = scope.calcOnState { arrayStorageType(array, value.array.type) } + if (storageType is EtsStringType) { + return readStringIndex(scope, array, index.value, index.isNumeric) } - val index = resolvedIndex.asExpr(fp64Sort) - - // Convert the index to a bit-vector. - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = sizeSort.sizeBits.toInt(), - isSigned = true, - ).asExpr(sizeSort) - - val arrayType = scope.calcOnState { arrayStorageType(array, value.array.type) } - check(arrayType is EtsArrayType) { + check(storageType is EtsArrayType) { "Expected EtsArrayType, got: ${value.array.type}" } - // Read the array element. - readArray(scope, array, bvIndex, arrayType) + val indexIsSupported = mkAnd( + index.isNumeric, + mkValidArrayIndexProperty( + value = index.value, + maximumSupportedIndex = options.maxArraySize, + ), + ) + val bvIndex = mkFpToUint32AfterValidation(index.value, indexIsSupported).asExpr(sizeSort) + + readArrayProperty(scope, array, bvIndex, indexIsSupported, storageType) } -fun TsContext.readArray( +private fun TsContext.readArrayProperty( scope: TsStepScope, array: UHeapRef, index: UExpr, + indexIsSupported: UBoolExpr, arrayType: EtsArrayType, ): UExpr<*>? { checkNotFake(array) @@ -79,10 +93,33 @@ fun TsContext.readArray( memory.read(lengthLValue) } - // Check for out-of-bounds access. - checkNegativeIndexRead(scope, index) ?: return null - checkReadingInRange(scope, index, length) ?: return null + val elementExists = mkAnd( + indexIsSupported, + mkBvSignedLessExpr(index, length), + ) + if (elementExists.isFalse) { + return mkUndefinedValue() + } + + val element = readArrayElement(scope, array, index, arrayType) + if (elementExists.isTrue) { + return element + } + return iteWriteIntoFakeObject( + scope = scope, + condition = elementExists, + trueBranchValue = element, + falseBranchValue = mkUndefinedValue(), + ) +} + +private fun TsContext.readArrayElement( + scope: TsStepScope, + array: UHeapRef, + index: UExpr, + arrayType: EtsArrayType, +): UExpr<*> { // Determine the element sort. val sort = typeToSort(arrayType.elementType) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt index fd27d6f2c5..d6990b9fad 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt @@ -2,17 +2,22 @@ package org.usvm.machine.expr import io.ksmt.utils.asExpr import mu.KotlinLogging +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsStaticFieldRef +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.api.typeStreamOf import org.usvm.machine.TsContext import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.ensureStaticsInitialized import org.usvm.machine.types.EtsAuxiliaryType import org.usvm.machine.types.mkFakeValue +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.TsResolutionResult import org.usvm.util.createFakeField @@ -62,19 +67,42 @@ fun TsContext.readField( instance: UHeapRef, field: EtsFieldSignature, hierarchy: EtsHierarchy, -): UExpr<*> { +): UExpr<*>? { checkNotFake(instance) - val sort = when (val etsField = resolveEtsField(instanceLocal, field, hierarchy)) { + val isUnresolvedErrorField = field.isUnresolvedErrorField() + val candidateErrorStorageField = field.errorModelStorageField() + val concreteRuntimeType = (instance as? UConcreteHeapRef) + ?.takeIf { candidateErrorStorageField != null } + ?.let { concreteInstance -> + scope.calcOnState { memory.typeStreamOf(concreteInstance).singleOrNull() } + } + val errorStorageField = candidateErrorStorageField.takeIf { + concreteRuntimeType == EtsClassType(signature = builtInErrorSignature) + } + val isErrorModelStorageField = errorStorageField != null + val etsField = when { + isErrorModelStorageField -> TsResolutionResult.Empty + isUnresolvedErrorField -> resolveEtsField( + instance = instanceLocal, + field = field.copy(enclosingClass = EtsClassSignature.UNKNOWN), + hierarchy = hierarchy, + ) + else -> resolveEtsField(instanceLocal, field, hierarchy) + } + val isModelStorageField = field.isModelStorageField() || isErrorModelStorageField + val sort = when (etsField) { is TsResolutionResult.Empty -> { - if (field.name !in listOf("i", "LogLevel")) { - logger.warn { "Field $field not found, creating fake field" } + if (!isModelStorageField) { + if (field.name !in listOf("i", "LogLevel")) { + logger.warn { "Field $field not found, creating fake field" } + } + // If we didn't find any real fields, let's create a fake one. + // It is possible due to mistakes in the IR or if the field was added explicitly + // in the code. + // Probably, the right behaviour here is to fork the state. + instance.createFakeField(scope, field.name) } - // If we didn't find any real fields, let's create a fake one. - // It is possible due to mistakes in the IR or if the field was added explicitly - // in the code. - // Probably, the right behaviour here is to fork the state. - instance.createFakeField(scope, field.name) addressSort } @@ -83,18 +111,21 @@ fun TsContext.readField( is TsResolutionResult.Ambiguous -> unresolvedSort } - scope.doWithState { - // If we accessed some field, we make an assumption that - // this field should present in the object. - // That's not true in the common case for TS, but that's the decision we made. - val auxiliaryType = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.assert(memory.types.evalIsSubtype(instance, auxiliaryType)) + if (!isModelStorageField) { + val fieldExists = scope.calcOnState { + // If we accessed some field, we make an assumption that + // this field should present in the object. + // That's not true in the common case for TS, but that's the decision we made. + val auxiliaryType = EtsAuxiliaryType(properties = setOf(field.name)) + memory.types.evalIsSubtype(instance, auxiliaryType) + } + // A failed assertion stops this step; do not access its state afterward. + scope.assert(fieldExists) ?: return null } // If the field type is known, we can read it directly. if (sort !is TsUnresolvedSort) { - val lValue = mkFieldLValue(sort, instance, field) + val lValue = mkFieldLValue(sort, instance, errorStorageField ?: field.name) return scope.calcOnState { memory.read(lValue) } } @@ -121,6 +152,12 @@ fun TsContext.readField( } } +private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { + "DateValue" -> name == "timestamp" + "ErrorValue" -> isErrorModelStorageDefinitionField() + else -> false +} + internal fun TsExprResolver.handleStaticFieldRef( value: EtsStaticFieldRef, ): UExpr<*>? = with(ctx) { 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 86444e1054..b20dc64274 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 @@ -14,6 +14,7 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayLengthLValue +import org.usvm.util.stringLength // Handles reading the `length` property. fun TsContext.readLengthProperty( @@ -24,6 +25,18 @@ fun TsContext.readLengthProperty( ): UExpr<*>? { // Determine the array type. val storageType = scope.calcOnState { arrayStorageType(instance, instanceLocal.type) } + if (storageType is EtsStringType) { + val length = scope.calcOnState { stringLength(instance) } + ensureLengthBounds(scope, length, maxArraySize) ?: return null + + return mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.asExpr(sizeSort), + signed = true, + ) + } + val arrayType: EtsArrayType = when (val type = storageType) { is EtsArrayType -> type @@ -33,11 +46,6 @@ fun TsContext.readLengthProperty( EtsArrayType(EtsUnknownType, dimensions = 1) } - is EtsStringType -> { - // Strings are treated as arrays of characters (represented as strings). - EtsArrayType(EtsStringType, dimensions = 1) - } - else -> error("Expected EtsArrayType, EtsAnyType or EtsUnknownType, but got: $type") } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt new file mode 100644 index 0000000000..e3b837e67b --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt @@ -0,0 +1,50 @@ +package org.usvm.machine.expr + +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.sizeSort +import org.usvm.util.STRING_CHARACTER_ARRAY_TYPE +import org.usvm.util.allocateString +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.stringCharacters +import org.usvm.util.stringLength + +/** Indexed string access returns one UTF-16 code unit, or undefined for a missing property. */ +internal fun TsContext.readStringIndex( + scope: TsStepScope, + string: UHeapRef, + index: UExpr, + indexIsNumeric: UBoolExpr, +): UExpr<*> = scope.calcOnState { + val indexIsValid = mkAnd( + indexIsNumeric, + mkValidArrayIndexProperty(index, maximumSupportedIndex = Int.MAX_VALUE), + ) + val storageIndex = mkFpToUint32AfterValidation(index, indexIsValid).asExpr(sizeSort) + val length = stringLength(string) + val indexIsInBounds = mkBvSignedLessExpr(storageIndex, length) + val propertyExists = mkAnd(indexIsValid, indexIsInBounds) + val characters = stringCharacters(string) + val sourceSlot = mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = storageIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + val codeUnit = memory.read(sourceSlot) + val (result, destination) = allocateString(length = mkBv(1), maxLength = 1) + val destinationSlot = mkArrayIndexLValue( + sort = bv16Sort, + ref = destination, + index = mkBv(0), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + memory.write(destinationSlot, codeUnit, guard = trueExpr) + + mkIte(propertyExists, result, mkUndefinedValue()) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 66184e3d19..52397ba30e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -89,12 +89,16 @@ import org.usvm.api.allocateConcreteRef import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArrayLength import org.usvm.api.makeSymbolicPrimitive +import org.usvm.api.typeStreamOf import org.usvm.dataflow.ts.infer.tryGetKnownType import org.usvm.dataflow.ts.util.type import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsConcreteMethodCallStmt import org.usvm.machine.TsContext +import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch @@ -114,8 +118,10 @@ import org.usvm.machine.state.newStmt import org.usvm.machine.types.EtsNominalType import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.sizeSort +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.SymbolResolutionResult +import org.usvm.util.concatStrings import org.usvm.util.isResolved import org.usvm.util.mkFieldLValue import org.usvm.util.mkRegisterStackLValue @@ -138,6 +144,7 @@ private const val ECMASCRIPT_BITWISE_INTEGER_SIZE = 32 * and `x << 37` is equivalent to `x << 5`. */ private const val ECMASCRIPT_BITWISE_SHIFT_MASK = 0b11111 +private const val UNKNOWN_SIGNATURE_COMPONENT = "%unk" private enum class UpdateOperator { INCREMENT, @@ -150,6 +157,7 @@ class TsExprResolver( internal val options: TsOptions, internal val hierarchy: EtsHierarchy, internal val unknownCallDispatcher: TsUnknownCallDispatcher, + internal val observer: TsInterpreterObserver?, ) : EtsEntity.Visitor?> { val simpleValueResolver: TsSimpleValueResolver = @@ -159,6 +167,20 @@ class TsExprResolver( return expr.accept(this) } + internal fun reportRuntimeFeatureLimitation( + reason: TsRuntimeFeatureLimitationReason, + detail: String, + ) { + val statement = scope.calcOnState { lastStmt } + observer?.onRuntimeFeatureLimitation( + TsRuntimeFeatureLimitationEvent( + statement = statement, + reason = reason, + detail = detail, + ) + ) + } + private fun resolveUnaryOperator( operator: TsUnaryOperator, expr: EtsUnaryExpr, @@ -307,13 +329,8 @@ class TsExprResolver( val arg = resolve(expr.arg) ?: return null val numericArg = mkNumericExpr(arg, scope) - // Convert to 32-bit integer, perform bitwise NOT, then convert back to number - val bvArg = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = numericArg.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true - ) + // Convert to the total ECMAScript 32-bit pattern, perform bitwise NOT, then convert back to number. + val bvArg = mkEcmaScriptToUint32(numericArg.asExpr(fp64Sort)) val notResult = mkBvNotExpr(bvArg) return mkBvToFpExpr( @@ -324,46 +341,8 @@ class TsExprResolver( ) } - override fun visit(expr: EtsCastExpr): UExpr<*>? = with(ctx) { - val resolvedExpr = resolve(expr.arg) ?: return@with null - return when (resolvedExpr.sort) { - fp64Sort -> { - logger.error("Unsupported cast from fp ${expr.arg} to ${expr.type}") - TODO("Not yet implemented https://github.com/UnitTestBot/usvm/issues/299") - } - - boolSort -> { - logger.error("Unsupported cast from boolean ${expr.arg} to ${expr.type}") - TODO("Not yet implemented https://github.com/UnitTestBot/usvm/issues/299") - } - - addressSort -> { - scope.calcOnState { - val instance = resolvedExpr.asExpr(addressSort) - - if (instance.isFakeObject()) { - val fakeType = instance.getFakeType(scope) - pathConstraints += fakeType.refTypeExpr - val refValue = instance.extractRef(scope) - pathConstraints += memory.types.evalIsSubtype(refValue, expr.type) - return@calcOnState instance - } - - if (expr.type !is EtsRefType) { - logger.error("Unsupported cast from non-ref ${expr.arg} to ${expr.type}") - TODO("Not supported yet https://github.com/UnitTestBot/usvm/issues/299") - } - - pathConstraints += memory.types.evalIsSubtype(instance, expr.type) - instance - } - } - - else -> { - error("Unsupported cast from ${expr.arg} to ${expr.type}") - } - } - } + // TypeScript assertions are erased; they neither convert nor constrain the runtime value. + override fun visit(expr: EtsCastExpr): UExpr<*>? = resolve(expr.arg) override fun visit(expr: EtsTypeOfExpr): UExpr? = with(ctx) { val arg = resolve(expr.arg) ?: return null @@ -580,15 +559,34 @@ class TsExprResolver( if (expr.type == EtsStringType) { return resolveAfterResolved(expr.left, expr.right) { lhs, rhs -> val lhsString = concreteStringValue(lhs) - ?: error("Symbolic string concatenation is not supported for left operand: $lhs") val rhsString = concreteStringValue(rhs) - ?: error("Symbolic string concatenation is not supported for right operand: $rhs") - ctx.mkStringConstant(lhsString + rhsString, scope) + if (lhsString != null && rhsString != null) { + return@resolveAfterResolved ctx.mkStringConstant(lhsString + rhsString, scope) + } + + val lhsRef = stringStorageRef(lhs) + ?: error("String concatenation is not supported for left operand: $lhs") + val rhsRef = stringStorageRef(rhs) + ?: error("String concatenation is not supported for right operand: $rhs") + scope.calcOnState { concatStrings(lhsRef, rhsRef) } } } return resolveBinaryOperator(TsBinaryOperator.Add, expr) } + private fun stringStorageRef(value: UExpr<*>): UHeapRef? = with(ctx) { + if (value.sort == addressSort) { + val ref = value.asExpr(addressSort) + val type = scope.calcOnState { memory.typeStreamOf(ref).singleOrNull() } + if (type is EtsStringType) { + return ref + } + } + + val concrete = concreteStringValue(value) ?: return null + mkStringConstant(concrete, scope) + } + private fun concreteStringValue(value: UExpr<*>): String? = with(ctx) { when { value == trueExpr -> "true" @@ -638,18 +636,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise AND, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvAndExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -663,18 +651,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise OR, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvOrExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -688,18 +666,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise XOR, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvXorExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -713,18 +681,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform left shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) as per JavaScript spec val shiftAmount = mkBvAndExpr( @@ -744,18 +702,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform signed right shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) val shiftAmount = mkBvAndExpr( @@ -780,18 +728,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform unsigned right shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) val shiftAmount = mkBvAndExpr( @@ -957,12 +895,19 @@ class TsExprResolver( val callee = scope.calcOnState { associatedFunction[ptr] } if (callee == null) { + val resolvedArguments = if (expr.isBuiltInArrayConstructor()) { + val argument = resolve(expr.args.single()) ?: return null + listOf(argument) + } else { + List(expr.args.size) { null } + } unknownCallDispatcher.dispatch( scope = scope, call = expr, callSite = scope.calcOnState { lastStmt }, failureReason = TsUnknownCallFailureReason.POINTER_TARGET_NOT_FOUND, resolvedReceiver = ptr, + resolvedArguments = resolvedArguments, ) return null } @@ -994,6 +939,26 @@ class TsExprResolver( } } + private fun EtsPtrCallExpr.isBuiltInArrayConstructor(): Boolean { + if (callee.name != "Array" || ptr.name != "Array" || args.size != 1) { + return false + } + + val signature = (ptr.type as? EtsFunctionType)?.signature ?: return false + val parameter = signature.parameters.singleOrNull() ?: return false + val returnType = signature.returnType as? EtsArrayType ?: return false + val signatureFile = signature.enclosingClass.file + + return signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT && + signature.name.isEmpty() && + parameter.type == EtsNumberType && + parameter.isOptional && + !parameter.isRest && + returnType.elementType == EtsAnyType && + returnType.dimensions == 1 + } + private fun EtsPtrCallExpr.isBuiltInNumberConverter(): Boolean { val hasBuiltInCallSite = callee.name == "Number" && callee.enclosingClass == EtsClassSignature.UNKNOWN && @@ -1092,33 +1057,25 @@ class TsExprResolver( TODO() } - val bvSize = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = size.asExpr(fp64Sort), - bvSize = 32, - isSigned = true, - ) + val fpSize = size.asExpr(fp64Sort) + val validLength = mkValidArrayLength(fpSize) - val condition = mkAnd( - mkEq( - mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = bvSize, - signed = true, - ), - size.asExpr(fp64Sort) - ), - mkAnd( - mkBvSignedLessOrEqualExpr(mkBv(0), bvSize.asExpr(bv32Sort)), - mkBvSignedLessOrEqualExpr(bvSize.asExpr(bv32Sort), mkBv(Int.MAX_VALUE)) + scope.fork( + validLength, + blockOnFalseState = { throwException("RangeError: Invalid array length: $fpSize") }, + ) ?: return@calcOnState null + + val maximumSupportedLength = mkFp64(options.maxArraySize.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr(fpSize, maximumSupportedLength) + if (scope.checkSat(mkNot(withinModelCapacity)) != null) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + detail = "new Array length exceeds model capacity: $fpSize", ) - ) + } + scope.assert(withinModelCapacity) ?: return@calcOnState null - scope.fork( - condition, - blockOnFalseState = { throwException("Invalid array size: ${size.asExpr(fp64Sort)}") } - ) + val bvSize = mkFpToUint32AfterValidation(fpSize, validLength) if (arrayType.elementType is EtsArrayType) { TODO("Multidimensional arrays are not supported yet, https://github.com/UnitTestBot/usvm/issues/287") @@ -1126,7 +1083,7 @@ class TsExprResolver( val descriptor = arrayDescriptorOf(arrayType) val address = memory.allocConcrete(descriptor) - memory.initializeArrayLength(address, descriptor, sizeSort, bvSize) + memory.initializeArrayLength(address, descriptor, sizeSort, bvSize.asExpr(sizeSort)) address } 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 f24700c2fe..e315837abb 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 @@ -1,11 +1,13 @@ package org.usvm.machine.expr import io.ksmt.utils.asExpr +import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort @@ -13,6 +15,8 @@ import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue +private val logger = KotlinLogging.logger {} + internal fun TsExprResolver.handleAssignToArrayIndex( lhv: EtsArrayAccess, expr: UExpr<*>, @@ -24,30 +28,63 @@ internal fun TsExprResolver.handleAssignToArrayIndex( } val array = resolvedArray.asExpr(addressSort) + handleAssignToArrayIndex(lhv, expr, array) +} + +internal fun TsExprResolver.handleAssignToArrayIndex( + lhv: EtsArrayAccess, + expr: UExpr<*>, + array: UHeapRef, +): Unit? = with(ctx) { // Check for undefined or null array access. checkUndefinedOrNullPropertyRead(scope, array, propertyName = "[]") ?: return null // Resolve the index. val resolvedIndex = resolve(lhv.index) ?: return null - check(resolvedIndex.sort == fp64Sort) { - "Expected fp64 sort for index, got: ${resolvedIndex.sort}" + val index = extractNumericArrayIndex(scope, resolvedIndex) + + val indexIsSupported = mkAnd( + index.isNumeric, + mkValidArrayIndexProperty( + value = index.value, + maximumSupportedIndex = options.maxArraySize, + ), + ) + if (scope.checkSat(mkNot(indexIsSupported)) != null) { + logger.warn { "Unsupported named array property write for key: $resolvedIndex" } + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + detail = "property key is not a supported numeric array index: $resolvedIndex", + ) } - val index = resolvedIndex.asExpr(fp64Sort) + scope.assert(indexIsSupported) ?: return null - // Convert the index to a bit-vector. - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = 32, - isSigned = true, - ).asExpr(sizeSort) + val bvIndex = mkFpToUint32AfterValidation(index.value, indexIsSupported).asExpr(sizeSort) val arrayType = scope.calcOnState { arrayStorageType(array, lhv.array.type) } check(arrayType is EtsArrayType) { "Expected EtsArrayType, got: ${lhv.array.type}" } - return assignToArrayIndex(scope, array, bvIndex, expr, arrayType) + return assignToArrayIndex( + scope = scope, + array = array, + index = bvIndex, + expr = expr, + arrayType = arrayType, + onUnsupportedGrowth = { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + detail = "array index write would grow beyond the current length", + ) + }, + onUnsupportedElementKind = { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_KIND_WRITE, + detail = "array storage cannot represent the assigned runtime value kind", + ) + }, + ) } fun TsContext.assignToArrayIndex( @@ -56,6 +93,8 @@ fun TsContext.assignToArrayIndex( index: UExpr, expr: UExpr<*>, arrayType: EtsArrayType, + onUnsupportedGrowth: (() -> Unit)? = null, + onUnsupportedElementKind: (() -> Unit)? = null, ): Unit? { checkNotFake(array) @@ -69,14 +108,35 @@ fun TsContext.assignToArrayIndex( // However, we decided to forbid this behavior in our model for simplicity. // Instead, we only allow writing to existing indices. - // Check for out-of-bounds access. - checkNegativeIndexRead(scope, index) ?: return null - checkReadingInRange(scope, index, length) ?: return null + val indexIsNonNegative = mkBvSignedGreaterOrEqualExpr(index, mkBv(0)) + val indexIsBelowLength = mkBvSignedLessExpr(index, length) + val indexIsInRange = mkAnd(indexIsNonNegative, indexIsBelowLength) + if (scope.checkSat(mkNot(indexIsInRange)) != null) { + logger.warn { "Unsupported array growth through index write: index=$index, length=$length" } + onUnsupportedGrowth?.invoke() + } + scope.assert(indexIsInRange) ?: return null val elementSort = typeToSort(arrayType.elementType) // If the element sort is known, write directly. if (elementSort !is TsUnresolvedSort) { + val (payload, kindGuard) = if (expr.isFakeObject()) { + val type = expr.getFakeType(scope) + when (elementSort) { + boolSort -> expr.extractBool(scope) to type.boolTypeExpr + fp64Sort -> expr.extractFp(scope) to type.fpTypeExpr + addressSort -> expr.extractRef(scope) to type.refTypeExpr + else -> error("Unsupported array element sort: $elementSort") + } + } else { + expr to mkBool(expr.sort == elementSort) + } + if (scope.checkSat(mkNot(kindGuard)) != null) { + onUnsupportedElementKind?.invoke() + } + scope.assert(kindGuard) ?: return null + val lValue = mkArrayIndexLValue( sort = elementSort, ref = array, @@ -84,7 +144,7 @@ fun TsContext.assignToArrayIndex( type = arrayType, ) return scope.doWithState { - memory.write(lValue, expr.asExpr(elementSort), guard = trueExpr) + memory.write(lValue, payload.asExpr(elementSort), guard = trueExpr) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 2a465cc790..fbbb2943a0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -4,19 +4,25 @@ import io.ksmt.utils.asExpr import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsStaticFieldRef +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.api.typeStreamOf import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.ensureStaticsInitialized import org.usvm.machine.types.EtsAuxiliaryType import org.usvm.machine.types.extractValue import org.usvm.sizeSort +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.TsResolutionResult import org.usvm.util.arrayStorageType @@ -61,6 +67,7 @@ internal fun TsExprResolver.handleAssignToInstanceField( arrayType = arrayType, value = expr, maxArraySize = options.maxArraySize, + onFeatureLimitation = ::reportRuntimeFeatureLimitation, ) } @@ -74,6 +81,7 @@ private fun TsContext.assignToArrayLength( arrayType: EtsArrayType, value: UExpr<*>, maxArraySize: Int, + onFeatureLimitation: (TsRuntimeFeatureLimitationReason, String) -> Unit, ): Unit? = with(this) { val (fpLength, numericTypeGuard) = scope.calcOnState { with(ctx) { @@ -89,37 +97,41 @@ private fun TsContext.assignToArrayLength( return null } - val convertedLength = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = fpLength, - bvSize = 32, - isSigned = true, - ) - val roundTrip = mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = convertedLength, - signed = true, - ) - val length = convertedLength.asExpr(sizeSort) + val validJsLength = mkValidArrayLength(fpLength) + scope.fork( + validJsLength, + blockOnFalseState = { throwException("RangeError: Invalid array length: $fpLength") }, + ) ?: return null + + val maximumSupportedLength = mkFp64(maxArraySize.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr(fpLength, maximumSupportedLength) + if (scope.checkSat(mkNot(withinModelCapacity)) != null) { + onFeatureLimitation( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + "assigned array length exceeds model capacity: $fpLength", + ) + } + scope.assert(withinModelCapacity) ?: run { + logger.warn { "Unsupported array length assignment beyond model capacity: $fpLength" } + return null + } + + val length = mkFpToUint32AfterValidation(fpLength, validJsLength).asExpr(sizeSort) val lengthLValue = mkArrayLengthLValue(array, arrayType) val currentLength = scope.calcOnState { memory.read(lengthLValue) } - val lengthIsIntegral = mkFpEqualExpr(roundTrip, fpLength) - val lengthIsNonNegative = mkBvSignedGreaterOrEqualExpr(length, mkBv(0)) - val lengthIsWithinLimit = mkBvSignedLessOrEqualExpr(length, mkBv(maxArraySize)) val lengthIsNotGrowing = mkBvSignedLessOrEqualExpr(length, currentLength) - val validLength = mkAnd( - lengthIsIntegral, - lengthIsNonNegative, - lengthIsWithinLimit, - lengthIsNotGrowing, - ) - scope.assert(validLength) ?: run { + if (scope.checkSat(mkNot(lengthIsNotGrowing)) != null) { + onFeatureLimitation( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_GROWTH, + "assigned array length would grow beyond the current length", + ) + } + scope.assert(lengthIsNotGrowing) ?: run { logger.warn { - "Unsupported array length assignment: expected an integral length in [0, current length], " + - "but the constraint is UNSAT: $validLength" + "Unsupported array length growth: expected length at most the current length, " + + "but the constraint is UNSAT: $lengthIsNotGrowing" } return null } @@ -136,36 +148,56 @@ fun TsContext.assignToInstanceField( field: EtsFieldSignature, expr: UExpr<*>, hierarchy: EtsHierarchy, -) { +): Unit? { // Unwrap to get non-fake reference. val unwrappedInstance = instance.unwrapRef(scope) - val etsField = resolveEtsField(instanceLocal, field, hierarchy) + val isUnresolvedErrorField = field.isUnresolvedErrorField() + val candidateErrorStorageField = field.errorModelStorageField() + val concreteRuntimeType = (unwrappedInstance as? UConcreteHeapRef) + ?.takeIf { candidateErrorStorageField != null } + ?.let { concreteInstance -> + scope.calcOnState { memory.typeStreamOf(concreteInstance).singleOrNull() } + } + val errorStorageField = candidateErrorStorageField.takeIf { + concreteRuntimeType == EtsClassType(signature = builtInErrorSignature) + } + val isModelStorageField = field.isModelStorageField() || errorStorageField != null + val etsField = when { + errorStorageField != null -> TsResolutionResult.Empty + isUnresolvedErrorField -> resolveEtsField( + instance = instanceLocal, + field = field.copy(enclosingClass = EtsClassSignature.UNKNOWN), + hierarchy = hierarchy, + ) + else -> resolveEtsField(instanceLocal, field, hierarchy) + } // If we access some field, we expect that the object must have this field. // It is not always true for TS, but we decided to process it so. - val supertype = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.doWithState { - scope.assert(memory.types.evalIsSubtype(unwrappedInstance, supertype)) + if (!isModelStorageField) { + val supertype = EtsAuxiliaryType(properties = setOf(field.name)) + val propertyExists = scope.calcOnState { memory.types.evalIsSubtype(unwrappedInstance, supertype) } + // The assertion is required to update models before the write. + scope.assert(propertyExists) ?: return null } // Determine the field sort. val sort = when (etsField) { - is TsResolutionResult.Empty -> unresolvedSort + is TsResolutionResult.Empty -> if (errorStorageField != null) addressSort else unresolvedSort is TsResolutionResult.Unique -> typeToSort(etsField.property.type) is TsResolutionResult.Ambiguous -> unresolvedSort } // If the field type is unknown, we create a fake object for the expr and assign it. // Otherwise, assign expr directly. - scope.doWithState { + return scope.doWithState { if (sort is TsUnresolvedSort) { val fakeObject = expr.toFakeObject(scope) - val lValue = mkFieldLValue(addressSort, unwrappedInstance, field) + val lValue = mkFieldLValue(addressSort, unwrappedInstance, field.name) lValuesToAllocatedFakeObjects += lValue to fakeObject memory.write(lValue, fakeObject, guard = trueExpr) } else { - val lValue = mkFieldLValue(sort, unwrappedInstance, field) + val lValue = mkFieldLValue(sort, unwrappedInstance, errorStorageField ?: field.name) if (lValue.sort != expr.sort) { if (expr.isFakeObject()) { val lhvType = instanceLocal.type @@ -196,6 +228,12 @@ fun TsContext.assignToInstanceField( } } +private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { + "DateValue" -> name == "timestamp" + "ErrorValue" -> isErrorModelStorageDefinitionField() + else -> false +} + internal fun TsExprResolver.handleAssignToStaticField( lhv: EtsStaticFieldRef, expr: UExpr<*>, 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 423f5dde71..41cab2635b 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,6 +35,7 @@ import org.usvm.StepResult import org.usvm.StepScope import org.usvm.UExpr import org.usvm.UInterpreter +import org.usvm.USort import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArray import org.usvm.api.targets.TsTarget @@ -78,7 +79,6 @@ import org.usvm.types.TypesResult import org.usvm.types.first import org.usvm.types.single import org.usvm.util.executableOverloadImplementation -import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkFieldLValue import org.usvm.util.mkRegisterStackLValue @@ -527,32 +527,8 @@ class TsInterpreter( "Expected address sort for the array, got: ${array.sort}" } val arrayRef = array.asExpr(addressSort) - val resolvedIndex = exprResolver.resolve(lhv.index) ?: return null - val index = resolvedIndex.asExpr(fp64Sort) - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = 32, - isSigned = true, - ).asExpr(sizeSort) - val arrayType = if (isAllocatedConcreteHeapRef(array)) { - scope.calcOnState { memory.typeStreamOf(array).first() } - } else { - lhv.array.type - } - check(arrayType is EtsArrayType) { - "Expected EtsArrayType, got: ${lhv.array.type}" - } - val elementSort = typeToSort(arrayType.elementType) - val elementLValue = mkArrayIndexLValue( - sort = elementSort, - ref = arrayRef, - index = bvIndex.asExpr(sizeSort), - type = arrayType, - ) - scope.doWithState { - memory.write(elementLValue, expr.cast(), guard = trueExpr) - } + + exprResolver.handleAssignToArrayIndex(lhv, expr, arrayRef) } } @@ -710,9 +686,15 @@ class TsInterpreter( options = options, hierarchy = graph.hierarchy, unknownCallDispatcher = unknownCallDispatcher, + observer = observer, ) - fun getInitialState(method: EtsMethod, targets: List): TsState = with(ctx) { + fun getInitialState( + method: EtsMethod, + targets: List, + configure: (TsState) -> Unit = {}, + parameterSortOverride: (Int) -> USort? = { null }, + ): TsState = with(ctx) { val state = TsState( ctx = ctx, ownership = MutabilityOwnership(), @@ -739,6 +721,11 @@ class TsInterpreter( method.parameters.forEachIndexed { i, param -> val idx = i + 1 // +1 because 0 is reserved for `this` + val overriddenSort = parameterSortOverride(idx) + if (overriddenSort != null) { + state.saveSortForLocal(idx, overriddenSort) + return@forEachIndexed + } val ref by lazy { val lValue = mkRegisterStackLValue(addressSort, idx) @@ -802,6 +789,8 @@ class TsInterpreter( } } + configure(state) + val solver = solver() val model = solver.check(state.pathConstraints).ensureSat().model state.models = listOf(model) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 9e4e1db477..ab338365c0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -7,6 +7,7 @@ import mu.KotlinLogging import org.usvm.UAddressSort import org.usvm.UBoolExpr import org.usvm.UBoolSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.UIteExpr @@ -18,6 +19,7 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.ExprWithTypeConstraint import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.util.boolToFp +import org.usvm.util.refOrStringValueEquals private val logger = KotlinLogging.logger {} @@ -392,7 +394,7 @@ sealed interface TsBinaryOperator { return mkOr( mkAnd(lhsIsUndefined, rhsIsNull), mkAnd(lhsIsNull, rhsIsUndefined), - mkHeapRefEq(lhs, rhs) + scope.calcOnState { refOrStringValueEquals(lhs, rhs) }, ) } @@ -557,7 +559,7 @@ sealed interface TsBinaryOperator { rhs: UHeapRef, scope: TsStepScope, ): UBoolExpr { - return mkHeapRefEq(lhs, rhs) + return scope.calcOnState { refOrStringValueEquals(lhs, rhs) } } override fun TsContext.resolveFakeObject( @@ -567,93 +569,55 @@ sealed interface TsBinaryOperator { ): UBoolExpr { check(lhs.isFakeObject() || rhs.isFakeObject()) - var lhsValue: UExpr<*> = lhs - var rhsValue: UExpr<*> = rhs - - val typeConstraint = when { - lhs.isFakeObject() && rhs.isFakeObject() -> { - val lhsType = lhs.getFakeType(scope) - val rhsType = rhs.getFakeType(scope) - mkAnd( - lhsType.boolTypeExpr eq rhsType.boolTypeExpr, - lhsType.fpTypeExpr eq rhsType.fpTypeExpr, - // TODO support type equality - lhsType.refTypeExpr eq rhsType.refTypeExpr, - ) - } + if (lhs.isFakeObject() && rhs.isFakeObject()) { + val lhsType = lhs.getFakeType(scope) + val rhsType = rhs.getFakeType(scope) + val boolValuesEqual = onBool(lhs.extractBool(scope), rhs.extractBool(scope), scope) + val fpValuesEqual = onFp(lhs.extractFp(scope), rhs.extractFp(scope), scope) + val refValuesEqual = onRef(lhs.extractRef(scope), rhs.extractRef(scope), scope) + val boolMatch = mkAnd(lhsType.boolTypeExpr, rhsType.boolTypeExpr, boolValuesEqual) + val fpMatch = mkAnd(lhsType.fpTypeExpr, rhsType.fpTypeExpr, fpValuesEqual) + val refMatch = mkAnd(lhsType.refTypeExpr, rhsType.refTypeExpr, refValuesEqual) + + return mkOr(boolMatch, fpMatch, refMatch) + } - lhs.isFakeObject() -> { - val lhsType = lhs.getFakeType(scope) - when (rhs.sort) { - boolSort -> { - lhsValue = lhs.extractBool(scope) - lhsType.boolTypeExpr - } - - fp64Sort -> { - lhsValue = lhs.extractFp(scope) - lhsType.fpTypeExpr - } - - // TODO support type equality - addressSort -> { - lhsValue = lhs.extractRef(scope) - lhsType.refTypeExpr - } - - else -> error("Unsupported sort ${rhs.sort}") - } - } + return if (lhs.isFakeObject()) { + compareFakeWithConcrete(lhs, rhs, scope) + } else { + check(rhs.isFakeObject()) + compareFakeWithConcrete(rhs, lhs, scope) + } + } - rhs.isFakeObject() -> { - val rhsType = rhs.getFakeType(scope) - when (lhs.sort) { - boolSort -> { - rhsValue = rhs.extractBool(scope) - rhsType.boolTypeExpr - } - - fp64Sort -> { - rhsValue = rhs.extractFp(scope) - rhsType.fpTypeExpr - } - - // TODO support type equality - addressSort -> { - rhsValue = rhs.extractRef(scope) - rhsType.refTypeExpr - } - - else -> error("Unsupported sort ${lhs.sort}") - } + private fun TsContext.compareFakeWithConcrete( + wrapped: UConcreteHeapRef, + concrete: UExpr<*>, + scope: TsStepScope, + ): UBoolExpr { + val type = wrapped.getFakeType(scope) + return when (concrete.sort) { + boolSort -> { + val payload = wrapped.extractBool(scope) + val equal = onBool(payload, concrete.asExpr(boolSort), scope) + mkAnd(type.boolTypeExpr, equal) } - else -> { - error("Should not be called") + fp64Sort -> { + val payload = wrapped.extractFp(scope) + val equal = onFp(payload, concrete.asExpr(fp64Sort), scope) + mkAnd(type.fpTypeExpr, equal) } - } - check(!lhsValue.isFakeObject()) { "Nested fake objects are not supported" } - check(!rhsValue.isFakeObject()) { "Nested fake objects are not supported" } - - // Note: this is the case 'ref === ref', - // which should be `true` only if both have the same reference. - // It is not correct to delegate to `Eq.resolve` in this case, - // since `==` treats `null == undefined`, while `null !== undefined`. - if (lhsValue.sort == addressSort && rhsValue.sort == addressSort) { - val left = lhsValue.asExpr(addressSort) - val right = rhsValue.asExpr(addressSort) - return mkAnd( - typeConstraint, - mkHeapRefEq(left, right) - ) - } + addressSort -> { + val payload = wrapped.extractRef(scope) + check(!payload.isFakeObject()) { "Nested fake objects are not supported" } + val equal = onRef(payload, concrete.asExpr(addressSort), scope) + mkAnd(type.refTypeExpr, equal) + } - val looseEqualityConstraint = with(Eq) { - resolve(lhsValue, rhsValue, scope)?.asExpr(boolSort) ?: error("Should not be encountered") + else -> error("Unsupported sort ${concrete.sort}") } - - return mkAnd(typeConstraint, looseEqualityConstraint) } override fun TsContext.internalResolve( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 019257dd38..93e31bfc81 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt @@ -30,6 +30,7 @@ import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.TsFunction import org.usvm.memory.ULValue import org.usvm.memory.UMemory +import org.usvm.memory.UReadOnlyMemoryRegion import org.usvm.model.UModelBase import org.usvm.sizeSort import org.usvm.targets.UTargetsSet @@ -81,6 +82,8 @@ class TsState( * for identical string values. */ var stringConstantAllocatedRefs: UPersistentHashMap = persistentHashMapOf(), + internal val stringMaxLengths: MutableMap = mutableMapOf(), + internal val denseInputArrays: MutableMap = mutableMapOf(), private val activeUnknownCallModels: MutableList> = mutableListOf(), ) : UState( ctx = ctx, @@ -273,6 +276,7 @@ class TsState( ref } stringConstantAllocatedRefs = updated + stringMaxLengths[result] = value.length result } @@ -310,6 +314,8 @@ class TsState( dfltObject = dfltObject, dfltObjectFieldSorts = dfltObjectFieldSorts, stringConstantAllocatedRefs = stringConstantAllocatedRefs, + stringMaxLengths = stringMaxLengths.toMutableMap(), + denseInputArrays = denseInputArrays.toMutableMap(), activeUnknownCallModels = activeUnknownCallModels.toMutableList(), ) } @@ -317,3 +323,10 @@ class TsState( override val isExceptional: Boolean get() = methodResult is TsMethodResult.TsException } + +/** Storage snapshots used to invalidate the dense-input guarantee after writes. */ +data class TsDenseInputArray( + val type: EtsArrayType, + val lengthRegion: UReadOnlyMemoryRegion<*, *>, + val elementRegion: UReadOnlyMemoryRegion<*, *>, +) 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 3cd941ce4b..b308634a5e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -14,11 +14,14 @@ import org.usvm.USort import org.usvm.api.memcpy import org.usvm.collection.array.UArrayRegion import org.usvm.collection.array.UArrayRegionId +import org.usvm.collection.array.length.UArrayLengthsRegionId import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsDenseInputArray import org.usvm.machine.state.TsState import org.usvm.machine.types.TsUnresolvedArrayKind +import org.usvm.sizeSort /** 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) { @@ -73,3 +76,40 @@ internal fun TsState.initializeArrayKind( ) memory.setRegion(regionId, initialized) } + +/** Records that every slot below the current input length has an initialized value. */ +fun TsState.markDenseInputArray( + array: UConcreteHeapRef, + type: EtsArrayType, +) = with(ctx) { + val descriptor = arrayDescriptorOf(type) + val elementSort = typeToSort(type.elementType) + require(elementSort !is TsUnresolvedSort) { "A dense input array needs a concrete element sort" } + + val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) + val elementRegionId = UArrayRegionId(descriptor, elementSort) + denseInputArrays[array] = TsDenseInputArray( + type = type, + lengthRegion = memory.getRegion(lengthRegionId), + elementRegion = memory.getRegion(elementRegionId), + ) +} + +/** Returns true only while neither the recorded length nor payload region has changed. */ +internal fun TsState.isUnmodifiedDenseInputArray( + array: UConcreteHeapRef, + type: EtsArrayType, +): Boolean = with(ctx) { + val snapshot = denseInputArrays[array] ?: return false + if (snapshot.type != type) return false + + val descriptor = arrayDescriptorOf(type) + val elementSort = typeToSort(type.elementType) + if (elementSort is TsUnresolvedSort) return false + + val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) + val elementRegionId = UArrayRegionId(descriptor, elementSort) + + memory.getRegion(lengthRegionId) === snapshot.lengthRegion && + memory.getRegion(elementRegionId) === snapshot.elementRegion +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt new file mode 100644 index 0000000000..7492f999dd --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -0,0 +1,255 @@ +package org.usvm.util + +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.api.evalTypeEquals +import org.usvm.api.initializeArrayLength +import org.usvm.api.memcpy +import org.usvm.machine.TsSizeSort +import org.usvm.machine.state.TsState +import org.usvm.sizeSort + +internal val STRING_CHARACTER_ARRAY_TYPE = EtsArrayType(EtsNumberType, dimensions = 1) + +internal fun TsState.stringCharacters(receiver: UHeapRef): UHeapRef = with(ctx) { + memory.read(mkFieldLValue(addressSort, receiver, "value")).asExpr(addressSort) +} + +internal fun TsState.stringLength(receiver: UHeapRef): UExpr { + val characters = stringCharacters(receiver) + return memory.read(mkArrayLengthLValue(characters, STRING_CHARACTER_ARRAY_TYPE)) +} + +fun TsState.markStringMaxLength( + string: UConcreteHeapRef, + maxLength: Int, +) { + require(maxLength >= 0) { "String maximum length must be non-negative" } + stringMaxLengths[string] = maxLength +} + +private fun TsState.concreteStringMaxLength(string: UHeapRef): Int? = + (string as? UConcreteHeapRef)?.let(stringMaxLengths::get) + +private data class StringLengthBound( + val representative: UHeapRef, + val maxLength: Int, + val isTrackedString: UBoolExpr, +) + +private fun TsState.stringLengthBound(string: UHeapRef): StringLengthBound? = with(ctx) { + concreteStringMaxLength(string)?.let { maxLength -> + return StringLengthBound( + representative = string, + maxLength = maxLength, + isTrackedString = trueExpr, + ) + } + if (string is UConcreteHeapRef || stringMaxLengths.isEmpty()) { + return null + } + + val trackedStrings = stringMaxLengths.entries.toList() + val representative = trackedStrings.drop(1).fold(trackedStrings.first().key as UHeapRef) { fallback, entry -> + mkIte( + condition = mkHeapRefEq(string, entry.key), + trueBranch = entry.key, + falseBranch = fallback, + ) + } + val trackedStringGuards = trackedStrings.map { (tracked, _) -> mkHeapRefEq(string, tracked) } + StringLengthBound( + representative = representative, + maxLength = trackedStrings.maxOf { it.value }, + isTrackedString = mkOr(trackedStringGuards), + ) +} + +internal fun TsState.allocateString( + length: UExpr, + maxLength: Int? = null, +): Pair = with(ctx) { + val string = memory.allocConcrete(EtsStringType) + val characters = memory.allocConcrete(STRING_CHARACTER_ARRAY_TYPE.elementType) + memory.initializeArrayLength( + arrayHeapRef = characters, + type = arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + sizeSort = sizeSort, + count = length, + ) + memory.write( + mkFieldLValue(addressSort, string, "value"), + characters, + guard = trueExpr, + ) + if (maxLength != null) { + markStringMaxLength(string = string, maxLength = maxLength) + } + + string to characters +} + +internal fun TsState.stringValueEqualsOrNull( + left: UHeapRef, + right: UHeapRef, +): UBoolExpr? = with(ctx) { + val leftBound = stringLengthBound(left) ?: return null + val rightBound = stringLengthBound(right) ?: return null + val leftCharacters = stringCharacters(leftBound.representative) + val rightCharacters = stringCharacters(rightBound.representative) + val leftLength = memory.read(mkArrayLengthLValue(leftCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val rightLength = memory.read(mkArrayLengthLValue(rightCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val zeroLength = mkBv(0) + val leftMaximumLength = mkBv(leftBound.maxLength) + val rightMaximumLength = mkBv(rightBound.maxLength) + val lengthIsValid = mkAnd( + leftBound.isTrackedString, + mkBvSignedGreaterOrEqualExpr(leftLength, zeroLength), + mkBvSignedLessOrEqualExpr(leftLength, leftMaximumLength), + rightBound.isTrackedString, + mkBvSignedGreaterOrEqualExpr(rightLength, zeroLength), + mkBvSignedLessOrEqualExpr(rightLength, rightMaximumLength), + ) + val lengthsAreEqual = mkEq(leftLength, rightLength) + val charactersAreEqual = (0 until maxOf(leftBound.maxLength, rightBound.maxLength)).map { index -> + val symbolicIndex = mkBv(index) + val indexIsLive = mkBvSignedLessExpr(symbolicIndex, leftLength) + val leftCharacter = memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = leftCharacters, + index = symbolicIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + val rightCharacter = memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = rightCharacters, + index = symbolicIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + + mkImplies(indexIsLive, mkEq(leftCharacter, rightCharacter)) + } + + mkAnd(lengthIsValid, lengthsAreEqual, mkAnd(charactersAreEqual)) +} + +internal fun TsState.refOrStringValueEquals( + left: UHeapRef, + right: UHeapRef, +): UBoolExpr = with(ctx) { + val referencesAreEqual = mkHeapRefEq(left, right) + val stringValuesAreEqual = stringValueEqualsOrNull(left, right) ?: return referencesAreEqual + val bothAreStrings = mkAnd( + memory.types.evalTypeEquals(left, EtsStringType), + memory.types.evalTypeEquals(right, EtsStringType), + ) + + mkOr(referencesAreEqual, mkAnd(bothAreStrings, stringValuesAreEqual)) +} + +internal fun TsState.refOrStringTruthy(ref: UHeapRef): UBoolExpr = with(ctx) { + val referenceIsTruthy = mkAnd( + mkHeapRefEq(ref, mkTsNullValue()).not(), + mkHeapRefEq(ref, mkUndefinedValue()).not(), + ) + + stringMaxLengths.keys.fold(referenceIsTruthy) { fallback, trackedString -> + val trackedStringIsNonEmpty = mkEq(stringLength(trackedString), mkBv(0)).not() + mkIte( + condition = mkHeapRefEq(ref, trackedString), + trueBranch = trackedStringIsNonEmpty, + falseBranch = fallback, + ) + } +} + +internal fun TsState.copyStringRange( + receiver: UHeapRef, + from: UExpr, + length: UExpr, +): UConcreteHeapRef = with(ctx) { + val source = stringCharacters(receiver) + val (result, destination) = allocateString(length, maxLength = concreteStringMaxLength(receiver)) + memory.memcpy( + source, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = from, + fromDst = mkBv(0), + length = length, + ) + result +} + +internal fun TsState.concatStrings( + left: UHeapRef, + right: UHeapRef, +): UConcreteHeapRef = with(ctx) { + val leftCharacters = stringCharacters(left) + val rightCharacters = stringCharacters(right) + val leftLength = memory.read(mkArrayLengthLValue(leftCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val rightLength = memory.read(mkArrayLengthLValue(rightCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val resultLength = mkBvAddExpr(leftLength, rightLength) + val resultMaxLength = concreteStringMaxLength(left)?.let { leftMaxLength -> + concreteStringMaxLength(right)?.let { rightMaxLength -> + (leftMaxLength.toLong() + rightMaxLength.toLong()) + .takeIf { it <= Int.MAX_VALUE } + ?.toInt() + } + } + val (result, destination) = allocateString(resultLength, maxLength = resultMaxLength) + + memory.memcpy( + leftCharacters, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = mkBv(0), + fromDst = mkBv(0), + length = leftLength, + ) + memory.memcpy( + rightCharacters, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = mkBv(0), + fromDst = leftLength, + length = rightLength, + ) + result +} + +internal fun TsState.stringFromCodeUnit(codeUnit: UExpr): UConcreteHeapRef = with(ctx) { + val (result, characters) = allocateString(mkBv(1), maxLength = 1) + val value = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = codeUnit, + bvSize = bv16Sort.sizeBits.toInt(), + isSigned = false, + ).asExpr(bv16Sort) + val character = mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = mkBv(0), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + memory.write( + character, + value, + guard = trueExpr, + ) + result +} diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 0e22541d97..db3b8b86c7 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -1,4 +1,14 @@ +declare class ArrayModelPrimitives { + static grow(receiver: any[], length: number): void; + static allocate(length: number): any[]; + static allocateLike(receiver: any[], length: number): any[]; +} + export class ArrayModels { + static fromLength(length: number): any[] { + return ArrayModelPrimitives.allocate(length); + } + static pop(receiver: any[]): any { const length = receiver.length; if (length === 0) { @@ -9,4 +19,201 @@ export class ArrayModels { receiver.length = length - 1; return result; } + + static indexOf(receiver: any[], searchElement: any, fromIndex: number): number { + const length = receiver.length; + if (length === 0) { + return -1; + } + + const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + if (start === Infinity || start >= length) { + return -1; + } + + let index = start >= 0 ? start : length + start; + if (index < 0) { + index = 0; + } + + while (index < length) { + if (receiver[index] === searchElement) { + return index; + } + + index++; + } + + return -1; + } + + static includes(receiver: any[], searchElement: any, fromIndex: number): boolean { + const length = receiver.length; + if (length === 0) { + return false; + } + + const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + if (start === Infinity || start >= length) { + return false; + } + + let index = start >= 0 ? start : length + start; + if (index < 0) { + index = 0; + } + + while (index < length) { + const element = receiver[index]; + if (element === searchElement || (element !== element && searchElement !== searchElement)) { + return true; + } + + index++; + } + + return false; + } + + static lastIndexOf(receiver: any[], searchElement: any, fromIndex: number): number { + const length = receiver.length; + if (length === 0 || fromIndex === -Infinity) { + return -1; + } + + let start = fromIndex !== fromIndex ? 0 : fromIndex; + if (start !== Infinity) { + start = start < 0 ? -Math.floor(-start) : Math.floor(start); + if (start === 0) { + start = 0; + } + } + if (start < -length) { + return -1; + } + + let index = start >= length ? length - 1 : (start >= 0 ? start : length + start); + while (index >= 0) { + if (receiver[index] === searchElement) { + return index; + } + + index--; + } + + return -1; + } + + static push(receiver: any[], first: any, second: any, third: any, count: number): number { + const length = receiver.length; + const resultLength = length + count; + ArrayModelPrimitives.grow(receiver, resultLength); + if (count >= 1) receiver[length] = first; + if (count >= 2) receiver[length + 1] = second; + if (count >= 3) receiver[length + 2] = third; + return resultLength; + } + + static fill(receiver: any[], value: any, start: number, end: number): any[] { + const length = receiver.length; + let index = ArrayModels.normalizeSliceIndex(start, length); + const final = ArrayModels.normalizeSliceIndex(end, length); + while (index < final) { + receiver[index] = value; + index++; + } + return receiver; + } + + static reverse(receiver: any[]): any[] { + const length = receiver.length; + let lower = 0; + while (lower < Math.floor(length / 2)) { + const upper = length - lower - 1; + const value = receiver[lower]; + receiver[lower] = receiver[upper]; + receiver[upper] = value; + lower++; + } + return receiver; + } + + static unshift(receiver: any[], first: any, second: any, third: any, count: number): number { + const length = receiver.length; + const resultLength = length + count; + ArrayModelPrimitives.grow(receiver, resultLength); + let index = length; + while (index > 0) { + index--; + receiver[index + count] = receiver[index]; + } + if (count >= 1) receiver[0] = first; + if (count >= 2) receiver[1] = second; + if (count >= 3) receiver[2] = third; + return resultLength; + } + + static slice(receiver: any[], start: number, end: number): any[] { + const length = receiver.length; + const from = ArrayModels.normalizeSliceIndex(start, length); + const to = ArrayModels.normalizeSliceIndex(end, length); + const count = to > from ? to - from : 0; + const result = ArrayModelPrimitives.allocateLike(receiver, count); + let index = 0; + while (index < count) { + result[index] = receiver[from + index]; + index++; + } + return result; + } + + static concat(receiver: any[], other: any[]): any[] { + const receiverLength = receiver.length; + const otherLength = other.length; + const result = ArrayModelPrimitives.allocateLike(receiver, receiverLength + otherLength); + let index = 0; + while (index < receiverLength) { + result[index] = receiver[index]; + index++; + } + let otherIndex = 0; + while (otherIndex < otherLength) { + result[receiverLength + otherIndex] = other[otherIndex]; + otherIndex++; + } + return result; + } + + private static normalizeRelativeIndex(fromIndex: number, length: number): number { + if (fromIndex !== fromIndex) { + return 0; + } + + if (fromIndex === Infinity || fromIndex >= length) { + return length; + } + + if (fromIndex === -Infinity || fromIndex <= -length) { + return -length; + } + + const integer = fromIndex < 0 ? -Math.floor(-fromIndex) : Math.floor(fromIndex); + return integer === 0 ? 0 : integer; + } + + private static normalizeSliceIndex(index: number, length: number): number { + if (index !== index || index === -Infinity) { + return 0; + } + if (index === Infinity) { + return length; + } + + const integer = index < 0 ? -Math.floor(-index) : Math.floor(index); + if (integer < 0) { + const relative = length + integer; + return relative < 0 ? 0 : relative; + } + return integer > length ? length : integer; + } } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts new file mode 100644 index 0000000000..701bb52013 --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -0,0 +1,559 @@ +// Numeric Date semantic model. Local-time operations intentionally use UTC. + +export class DateValue { + timestamp: number = NaN; +} + +class DateParts { + year: number; + month: number; + date: number; + day: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; + + constructor( + year: number, + month: number, + date: number, + day: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ) { + this.year = year; + this.month = month; + this.date = date; + this.day = day; + this.hours = hours; + this.minutes = minutes; + this.seconds = seconds; + this.milliseconds = milliseconds; + } +} + +export class DateModels { + private static readonly MS_PER_SECOND = 1_000; + private static readonly MS_PER_MINUTE = 60_000; + private static readonly MS_PER_HOUR = 3_600_000; + private static readonly MS_PER_DAY = 86_400_000; + private static readonly MAX_TIME = 8_640_000_000_000_000; + + static construct( + receiver: DateValue, + argumentCount: number, + nowMilliseconds: number, + yearOrTimestamp: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): DateValue { + if (argumentCount === 0) { + receiver.timestamp = DateModels.timeClip(nowMilliseconds); + return receiver; + } + + if (argumentCount === 1) { + receiver.timestamp = DateModels.timeClip(yearOrTimestamp); + return receiver; + } + + receiver.timestamp = DateModels.makeDate( + DateModels.normalizeConstructorYear(yearOrTimestamp), + month, + argumentCount >= 3 ? date : 1, + argumentCount >= 4 ? hours : 0, + argumentCount >= 5 ? minutes : 0, + argumentCount >= 6 ? seconds : 0, + argumentCount >= 7 ? milliseconds : 0, + ); + return receiver; + } + + static utc( + argumentCount: number, + year: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + if (argumentCount < 1) { + return NaN; + } + + return DateModels.makeDate( + DateModels.normalizeConstructorYear(year), + argumentCount >= 2 ? month : 0, + argumentCount >= 3 ? date : 1, + argumentCount >= 4 ? hours : 0, + argumentCount >= 5 ? minutes : 0, + argumentCount >= 6 ? seconds : 0, + argumentCount >= 7 ? milliseconds : 0, + ); + } + + static now(nowMilliseconds: number): number { + return DateModels.timeClip(nowMilliseconds); + } + + static getDate(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).date; + } + + static getDay(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).day; + } + + static getFullYear(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).year; + } + + static getHours(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).hours; + } + + static getMilliseconds(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).milliseconds; + } + + static getMinutes(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).minutes; + } + + static getMonth(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).month; + } + + static getSeconds(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).seconds; + } + + static getTime(receiver: DateValue): number { + return receiver.timestamp; + } + + static getTimezoneOffset(receiver: DateValue): number { + return DateModels.isInvalid(receiver.timestamp) ? NaN : 0; + } + + static getUTCDate(receiver: DateValue): number { + return DateModels.getDate(receiver); + } + + static getUTCDay(receiver: DateValue): number { + return DateModels.getDay(receiver); + } + + static getUTCFullYear(receiver: DateValue): number { + return DateModels.getFullYear(receiver); + } + + static getUTCHours(receiver: DateValue): number { + return DateModels.getHours(receiver); + } + + static getUTCMilliseconds(receiver: DateValue): number { + return DateModels.getMilliseconds(receiver); + } + + static getUTCMinutes(receiver: DateValue): number { + return DateModels.getMinutes(receiver); + } + + static getUTCMonth(receiver: DateValue): number { + return DateModels.getMonth(receiver); + } + + static getUTCSeconds(receiver: DateValue): number { + return DateModels.getSeconds(receiver); + } + + static setDate(receiver: DateValue, date: number): number { + return DateModels.setDateFields(receiver, 1, date, 0, 0); + } + + static setFullYear( + receiver: DateValue, + argumentCount: number, + year: number, + month: number, + date: number, + ): number { + const current = DateModels.partsOrEpoch(receiver.timestamp); + return DateModels.replaceDate( + receiver, + year, + argumentCount >= 2 ? month : current.month, + argumentCount >= 3 ? date : current.date, + current, + ); + } + + static setHours( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setTimeFields(receiver, argumentCount, hours, minutes, seconds, milliseconds, 0); + } + + static setMilliseconds(receiver: DateValue, milliseconds: number): number { + return DateModels.setTimeFields(receiver, 4, 0, 0, 0, milliseconds, 3); + } + + static setMinutes( + receiver: DateValue, + argumentCount: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setTimeFields(receiver, argumentCount + 1, 0, minutes, seconds, milliseconds, 1); + } + + static setMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { + return DateModels.setDateFields(receiver, argumentCount + 1, 0, month, date); + } + + static setSeconds(receiver: DateValue, argumentCount: number, seconds: number, milliseconds: number): number { + return DateModels.setTimeFields(receiver, argumentCount + 2, 0, 0, seconds, milliseconds, 2); + } + + static setTime(receiver: DateValue, timestamp: number): number { + receiver.timestamp = DateModels.timeClip(timestamp); + return receiver.timestamp; + } + + static setUTCDate(receiver: DateValue, date: number): number { + return DateModels.setDate(receiver, date); + } + + static setUTCFullYear( + receiver: DateValue, + argumentCount: number, + year: number, + month: number, + date: number, + ): number { + return DateModels.setFullYear(receiver, argumentCount, year, month, date); + } + + static setUTCHours( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setHours(receiver, argumentCount, hours, minutes, seconds, milliseconds); + } + + static setUTCMilliseconds(receiver: DateValue, milliseconds: number): number { + return DateModels.setMilliseconds(receiver, milliseconds); + } + + static setUTCMinutes( + receiver: DateValue, + argumentCount: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setMinutes(receiver, argumentCount, minutes, seconds, milliseconds); + } + + static setUTCMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { + return DateModels.setMonth(receiver, argumentCount, month, date); + } + + static setUTCSeconds(receiver: DateValue, argumentCount: number, seconds: number, milliseconds: number): number { + return DateModels.setSeconds(receiver, argumentCount, seconds, milliseconds); + } + + static toISOString(receiver: DateValue): string { + const parts = DateModels.parts(receiver.timestamp); + if (DateModels.isInvalid(parts.year)) { + throw new RangeError("Invalid time value"); + } + + return DateModels.formatYear(parts.year) + "-" + + DateModels.pad2(parts.month + 1) + "-" + + DateModels.pad2(parts.date) + "T" + + DateModels.pad2(parts.hours) + ":" + + DateModels.pad2(parts.minutes) + ":" + + DateModels.pad2(parts.seconds) + "." + + DateModels.pad3(parts.milliseconds) + "Z"; + } + + static valueOf(receiver: DateValue): number { + return receiver.timestamp; + } + + private static setDateFields( + receiver: DateValue, + argumentCount: number, + first: number, + second: number, + third: number, + ): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + if (argumentCount === 1) { + return DateModels.replaceDate(receiver, current.year, current.month, first, current); + } + + return DateModels.replaceDate( + receiver, + current.year, + second, + argumentCount >= 3 ? third : current.date, + current, + ); + } + + private static setTimeFields( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + firstField: number, + ): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextHours = firstField === 0 ? hours : current.hours; + const nextMinutes = firstField <= 1 && argumentCount >= 2 ? minutes : current.minutes; + const nextSeconds = firstField <= 2 && argumentCount >= 3 ? seconds : current.seconds; + const nextMilliseconds = argumentCount >= 4 ? milliseconds : current.milliseconds; + + receiver.timestamp = DateModels.makeDate( + current.year, + current.month, + current.date, + nextHours, + nextMinutes, + nextSeconds, + nextMilliseconds, + ); + return receiver.timestamp; + } + + private static replaceDate( + receiver: DateValue, + year: number, + month: number, + date: number, + time: DateParts, + ): number { + receiver.timestamp = DateModels.makeDate( + year, + month, + date, + time.hours, + time.minutes, + time.seconds, + time.milliseconds, + ); + return receiver.timestamp; + } + + private static makeDate( + year: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + year = DateModels.toInteger(year); + month = DateModels.toInteger(month); + date = DateModels.toInteger(date); + hours = DateModels.toInteger(hours); + minutes = DateModels.toInteger(minutes); + seconds = DateModels.toInteger(seconds); + milliseconds = DateModels.toInteger(milliseconds); + + if ( + DateModels.isInvalid(year) || DateModels.isInvalid(month) || DateModels.isInvalid(date) || + DateModels.isInvalid(hours) || DateModels.isInvalid(minutes) || DateModels.isInvalid(seconds) || + DateModels.isInvalid(milliseconds) + ) { + return NaN; + } + + const normalizedYear = year + DateModels.floorDiv(month, 12); + const normalizedMonth = DateModels.mod(month, 12); + const days = DateModels.daysFromCivil(normalizedYear, normalizedMonth, date); + const timestamp = days * DateModels.MS_PER_DAY + hours * DateModels.MS_PER_HOUR + + minutes * DateModels.MS_PER_MINUTE + seconds * DateModels.MS_PER_SECOND + milliseconds; + return DateModels.timeClip(timestamp); + } + + private static parts(timestamp: number): DateParts { + if (DateModels.isInvalid(timestamp)) { + return new DateParts(NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN); + } + + const normalizedTimestamp = timestamp === 0 ? 0 : timestamp; + const days = DateModels.floorDiv(normalizedTimestamp, DateModels.MS_PER_DAY); + let withinDay = normalizedTimestamp - days * DateModels.MS_PER_DAY; + const hours = DateModels.floorDiv(withinDay, DateModels.MS_PER_HOUR); + withinDay -= hours * DateModels.MS_PER_HOUR; + const minutes = DateModels.floorDiv(withinDay, DateModels.MS_PER_MINUTE); + withinDay -= minutes * DateModels.MS_PER_MINUTE; + const seconds = DateModels.floorDiv(withinDay, DateModels.MS_PER_SECOND); + const milliseconds = withinDay - seconds * DateModels.MS_PER_SECOND; + + const civil = DateModels.civilFromDays(days); + return new DateParts( + civil.year, + civil.month, + civil.date, + DateModels.mod(days + 4, 7), + hours, + minutes, + seconds, + milliseconds, + ); + } + + private static partsOrEpoch(timestamp: number): DateParts { + return DateModels.isInvalid(timestamp) ? DateModels.parts(0) : DateModels.parts(timestamp); + } + + private static daysFromCivil(year: number, month: number, date: number): number { + const adjustedYear = year - (month <= 1 ? 1 : 0); + const era = DateModels.floorDiv(adjustedYear, 400); + const yearOfEra = adjustedYear - era * 400; + const adjustedMonth = month + (month > 1 ? -2 : 10); + const dayOfYear = DateModels.floorDiv(153 * adjustedMonth + 2, 5) + date - 1; + const dayOfEra = yearOfEra * 365 + DateModels.floorDiv(yearOfEra, 4) - + DateModels.floorDiv(yearOfEra, 100) + dayOfYear; + return era * 146_097 + dayOfEra - 719_468; + } + + private static civilFromDays(days: number): DateParts { + const adjustedDays = days + 719_468; + const era = DateModels.floorDiv(adjustedDays, 146_097); + const dayOfEra = adjustedDays - era * 146_097; + const yearOfEra = DateModels.floorDiv( + dayOfEra - DateModels.floorDiv(dayOfEra, 1_460) + + DateModels.floorDiv(dayOfEra, 36_524) - DateModels.floorDiv(dayOfEra, 146_096), + 365, + ); + let year = yearOfEra + era * 400; + const dayOfYear = dayOfEra - ( + 365 * yearOfEra + DateModels.floorDiv(yearOfEra, 4) - DateModels.floorDiv(yearOfEra, 100) + ); + const monthPrime = DateModels.floorDiv(5 * dayOfYear + 2, 153); + const date = dayOfYear - DateModels.floorDiv(153 * monthPrime + 2, 5) + 1; + const month = monthPrime + (monthPrime < 10 ? 2 : -10); + year += month <= 1 ? 1 : 0; + return new DateParts(year, month, date, 0, 0, 0, 0, 0); + } + + private static normalizeConstructorYear(year: number): number { + year = DateModels.toInteger(year); + return year >= 0 && year <= 99 ? year + 1900 : year; + } + + private static timeClip(timestamp: number): number { + if (DateModels.isInvalid(timestamp) || timestamp > DateModels.MAX_TIME || timestamp < -DateModels.MAX_TIME) { + return NaN; + } + + const clipped = DateModels.toInteger(timestamp); + return clipped === 0 ? 0 : clipped; + } + + private static toInteger(value: number): number { + if (value === 0 || DateModels.isInvalid(value)) { + return value; + } + + return value < 0 ? -Math.floor(-value) : Math.floor(value); + } + + private static floorDiv(dividend: number, divisor: number): number { + return Math.floor(dividend / divisor); + } + + private static mod(dividend: number, divisor: number): number { + const remainder = dividend % divisor; + if (remainder === 0) { + return 0; + } + + return remainder < 0 ? remainder + divisor : remainder; + } + + private static isInvalid(value: number): boolean { + return value !== value; + } + + private static invalidate(receiver: DateValue): number { + receiver.timestamp = NaN; + return receiver.timestamp; + } + + private static digit(value: number): string { + if (value === 0) return "0"; + if (value === 1) return "1"; + if (value === 2) return "2"; + if (value === 3) return "3"; + if (value === 4) return "4"; + if (value === 5) return "5"; + if (value === 6) return "6"; + if (value === 7) return "7"; + if (value === 8) return "8"; + return "9"; + } + + private static pad2(value: number): string { + return DateModels.digit(DateModels.floorDiv(value, 10)) + DateModels.digit(DateModels.mod(value, 10)); + } + + private static pad3(value: number): string { + return DateModels.digit(DateModels.floorDiv(value, 100)) + + DateModels.pad2(DateModels.mod(value, 100)); + } + + private static pad4(value: number): string { + return DateModels.pad2(DateModels.floorDiv(value, 100)) + DateModels.pad2(DateModels.mod(value, 100)); + } + + private static pad6(value: number): string { + return DateModels.pad3(DateModels.floorDiv(value, 1_000)) + DateModels.pad3(DateModels.mod(value, 1_000)); + } + + private static formatYear(year: number): string { + if (year >= 0 && year <= 9_999) { + return DateModels.pad4(year); + } + + const sign = year < 0 ? "-" : "+"; + const absoluteYear = year < 0 ? -year : year; + return sign + DateModels.pad6(absoluteYear); + } +} diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts new file mode 100644 index 0000000000..ca847a6ea2 --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts @@ -0,0 +1,13 @@ +export class ErrorValue { + // Keep model storage out of unresolved user-property lookup. + __usvmErrorName: string; + __usvmErrorMessage: string; +} + +export class ErrorModels { + static construct(receiver: ErrorValue, message: string): ErrorValue { + receiver.__usvmErrorName = "Error"; + receiver.__usvmErrorMessage = message; + return receiver; + } +} diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts new file mode 100644 index 0000000000..a68e04b026 --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -0,0 +1,344 @@ +declare class StringModelPrimitives { + static length(receiver: string): number; + static codeUnitAt(receiver: string, index: number): number; + static fromCodeUnit(codeUnit: number): string; + static copyRange(receiver: string, start: number, end: number): string; +} + +export class StringModels { + static charAt(receiver: string, index: number): string { + const length = StringModelPrimitives.length(receiver); + const integerIndex = StringModels.normalizeCharIndex(index, length); + if (integerIndex < 0 || integerIndex >= length) { + return ""; + } + + const codeUnit = StringModelPrimitives.codeUnitAt(receiver, integerIndex); + return StringModelPrimitives.fromCodeUnit(codeUnit); + } + + static indexOf(receiver: string, searchString: string, position: number): number { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const start = StringModels.normalizePosition(position, length); + + if (searchLength === 0) { + return start; + } + + let index = start; + while (index + searchLength <= length) { + let searchIndex = 0; + while ( + searchIndex < searchLength && + StringModelPrimitives.codeUnitAt(receiver, index + searchIndex) === + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + searchIndex++; + } + + if (searchIndex === searchLength) { + return index; + } + + index++; + } + + return -1; + } + + static includes(receiver: string, searchString: string, position: number): boolean { + return StringModels.indexOf(receiver, searchString, position) !== -1; + } + + static charCodeAt(receiver: string, index: number): number { + const length = StringModelPrimitives.length(receiver); + const integerIndex = StringModels.normalizeCharIndex(index, length); + if (integerIndex < 0 || integerIndex >= length) { + return NaN; + } + + return StringModelPrimitives.codeUnitAt(receiver, integerIndex); + } + + static slice(receiver: string, start: number, end: number): string { + const length = StringModelPrimitives.length(receiver); + const from = StringModels.normalizeSliceIndex(start, length); + const to = StringModels.normalizeSliceIndex(end, length); + return StringModelPrimitives.copyRange(receiver, from, to < from ? from : to); + } + + static substring(receiver: string, start: number, end: number): string { + const length = StringModelPrimitives.length(receiver); + const from = StringModels.normalizePosition(start, length); + const to = StringModels.normalizePosition(end, length); + return from <= to + ? StringModelPrimitives.copyRange(receiver, from, to) + : StringModelPrimitives.copyRange(receiver, to, from); + } + + static trim(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let start = 0; + let end = length; + while (start < end && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, start))) { + start++; + } + while (end > start && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, end - 1))) { + end--; + } + return StringModelPrimitives.copyRange(receiver, start, end); + } + + static trimStart(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let start = 0; + while (start < length && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, start))) { + start++; + } + return StringModelPrimitives.copyRange(receiver, start, length); + } + + static trimEnd(receiver: string): string { + let end = StringModelPrimitives.length(receiver); + while (end > 0 && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, end - 1))) { + end--; + } + return StringModelPrimitives.copyRange(receiver, 0, end); + } + + static replaceAll(receiver: string, search: string, replacement: string): string { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(search); + let result = ""; + let endOfLastMatch = 0; + let position = 0; + while (position + searchLength <= length) { + let offset = 0; + while (offset < searchLength && + StringModelPrimitives.codeUnitAt(receiver, position + offset) === + StringModelPrimitives.codeUnitAt(search, offset)) { + offset++; + } + if (offset === searchLength) { + result += StringModelPrimitives.copyRange(receiver, endOfLastMatch, position); + result += StringModels.substitution(receiver, search, replacement, position); + endOfLastMatch = position + searchLength; + position = endOfLastMatch + (searchLength === 0 ? 1 : 0); + } else { + position++; + } + } + return result + StringModelPrimitives.copyRange(receiver, endOfLastMatch, length); + } + private static substitution(receiver: string, search: string, replacement: string, position: number): string { + const replacementLength = StringModelPrimitives.length(replacement); + let result = ""; + let index = 0; + while (index < replacementLength) { + const code = StringModelPrimitives.codeUnitAt(replacement, index); + if (code === 36 && index + 1 < replacementLength) { + const next = StringModelPrimitives.codeUnitAt(replacement, index + 1); + if (next === 36) { + result += "$"; + index += 2; + continue; + } + if (next === 38) { + result += search; + index += 2; + continue; + } + if (next === 96) { + result += StringModelPrimitives.copyRange(receiver, 0, position); + index += 2; + continue; + } + if (next === 39) { + const matchEnd = position + StringModelPrimitives.length(search); + result += StringModelPrimitives.copyRange(receiver, matchEnd, StringModelPrimitives.length(receiver)); + index += 2; + continue; + } + } + result += StringModelPrimitives.fromCodeUnit(code); + index++; + } + return result; + } + + private static isWhitespace(codeUnit: number): boolean { + return (codeUnit >= 0x09 && codeUnit <= 0x0d) + || codeUnit === 0x20 || codeUnit === 0xa0 || codeUnit === 0x1680 + || (codeUnit >= 0x2000 && codeUnit <= 0x200a) + || codeUnit === 0x2028 || codeUnit === 0x2029 || codeUnit === 0x202f + || codeUnit === 0x205f || codeUnit === 0x3000 || codeUnit === 0xfeff; + } + + static toUpperCase(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let result = ""; + let index = 0; + while (index < length) { + let codeUnit = StringModelPrimitives.codeUnitAt(receiver, index); + if (codeUnit >= 0x61 && codeUnit <= 0x7a) { + codeUnit -= 0x20; + } + result += StringModelPrimitives.fromCodeUnit(codeUnit); + index++; + } + return result; + } + + static toLowerCase(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let result = ""; + let index = 0; + while (index < length) { + let codeUnit = StringModelPrimitives.codeUnitAt(receiver, index); + if (codeUnit >= 0x41 && codeUnit <= 0x5a) { + codeUnit += 0x20; + } + result += StringModelPrimitives.fromCodeUnit(codeUnit); + index++; + } + return result; + } + + static startsWith(receiver: string, searchString: string, position: number): boolean { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const start = StringModels.normalizePosition(position, length); + if (start + searchLength > length) { + return false; + } + + let searchIndex = 0; + while (searchIndex < searchLength) { + if ( + StringModelPrimitives.codeUnitAt(receiver, start + searchIndex) !== + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + return false; + } + + searchIndex++; + } + + return true; + } + + static endsWith(receiver: string, searchString: string, endPosition: number): boolean { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const end = StringModels.normalizePosition(endPosition, length); + const start = end - searchLength; + if (start < 0) { + return false; + } + + let searchIndex = 0; + while (searchIndex < searchLength) { + if ( + StringModelPrimitives.codeUnitAt(receiver, start + searchIndex) !== + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + return false; + } + + searchIndex++; + } + + return true; + } + + static lastIndexOf(receiver: string, searchString: string, position: number): number { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + let index = StringModels.normalizeLastPosition(position, length); + if (index + searchLength > length) { + index = length - searchLength; + } + + while (index >= 0) { + let searchIndex = 0; + while ( + searchIndex < searchLength && + StringModelPrimitives.codeUnitAt(receiver, index + searchIndex) === + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + searchIndex++; + } + + if (searchIndex === searchLength) { + return index; + } + + index--; + } + + return -1; + } + + private static normalizeCharIndex(value: number, length: number): number { + if (value !== value) { + return 0; + } + + if (value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= -length) { + return -length; + } + + return value < 0 ? -Math.floor(-value) : Math.floor(value); + } + + private static normalizePosition(value: number, length: number): number { + if (value !== value) { + return 0; + } + + if (value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= 0) { + return 0; + } + + return Math.floor(value); + } + + private static normalizeLastPosition(value: number, length: number): number { + if (value !== value || value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= 0) { + return 0; + } + + return Math.floor(value); + } + + private static normalizeSliceIndex(value: number, length: number): number { + if (value !== value) { + return 0; + } + if (value === Infinity) { + return length; + } + if (value === -Infinity) { + return 0; + } + + const integer = value < 0 ? -Math.floor(-value) : Math.floor(value); + if (integer < 0) { + return integer + length < 0 ? 0 : integer + length; + } + return integer > length ? length : integer; + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt new file mode 100644 index 0000000000..893ab8b2e7 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt @@ -0,0 +1,170 @@ +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.jacodb.ets.utils.loadEtsProjectAutoConvert +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.assertTrue +import kotlin.time.Duration + +class TsArrayConstructorEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayConstructorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + private val importedShadowScene = loadEtsProjectAutoConvert( + getResourcePath("/models/array-constructor-shadow"), + useArkAnalyzerTypeInference = null, + ) + + @Test + fun `callable Array creates holes and accepts in bounds writes`() { + val result = analyze(methodName = "callableArrayCreatesHolesAndAcceptsWrites") + + assertEquals(13_456.0, result.singleNumber()) + assertEquals( + listOf("ts.array.fromLength", "ts.array.primitive.allocate"), + result.modelIds, + ) + } + + @Test + fun `callable Array result can be assigned to a nested array type`() { + val result = analyze(methodName = "callableArraySupportsNestedAssignment") + + assertEquals(256.0, result.singleNumber()) + assertEquals( + listOf("ts.array.fromLength", "ts.array.primitive.allocate"), + result.modelIds, + ) + } + + @Test + fun `invalid callable Array lengths remain residual`() { + for (methodName in listOf("negativeLength", "fractionalLength", "nanLength", "infiniteLength")) { + val result = analyze(methodName = methodName) + + assertTrue(result.values.isEmpty(), methodName) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome, methodName) + } + } + + @Test + fun `valid length above model capacity remains residual`() { + val result = analyze(methodName = "oversizedLength") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `full fill initializes every slot of a sparse array`() { + val expectedResults = mapOf( + "fullFillInitializesSparseNumberArray" to 3_777.0, + "fullFillInitializesSparseBooleanArray" to 3_111.0, + ) + + for ((methodName, expected) in expectedResults) { + val result = analyze(methodName = methodName) + + assertEquals(expected, result.singleNumber(), methodName) + assertEquals(listOf("ts.array.fill", "ts.math.floor"), result.modelIds, methodName) + } + } + + @Test + fun `shadowed Array function is not modeled as the global constructor`() { + val result = analyze(methodName = "shadowedArrayIsNotModeled") + + assertEquals(2.0, result.singleNumber()) + assertTrue(result.events.isEmpty()) + } + + @Test + fun `imported Array function is not modeled as the global constructor`() { + val result = analyze( + methodName = "callImportedArray", + className = "ImportedArrayShadow", + scene = importedShadowScene, + ) + + assertEquals(99.0, result.singleNumber()) + assertTrue(result.events.isEmpty()) + } + + private fun analyze( + methodName: String, + className: String = "ArrayConstructorEtsIr", + scene: EtsScene = this.scene, + ): AnalysisResult { + val method = method(scene, className, methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + val states = machine.analyze(listOf(method)) + AnalysisResult( + values = states.map { state -> TsTestResolver().resolve(method, state).returnValue }, + events = observer.events.toList(), + ) + } + } + + private fun method(scene: EtsScene, className: String, methodName: String): EtsMethod = scene.projectClasses + .single { it.name == className } + .methods + .single { it.name == methodName } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: List, + val events: List, + ) { + fun singleNumber(): Double = assertIs(values.single()).number + + 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, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt new file mode 100644 index 0000000000..5357ecd578 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt @@ -0,0 +1,147 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +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.machine.call.intrinsic.TsArrayIsArrayIntrinsicModel +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayIsArrayIntrinsicModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayIsArrayIntrinsicModel.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(sourceFile)) + + @Test + fun `scalars are not arrays`() { + val result = analyze("scalarValuesAreNotArrays", "missingArgumentIsNotArray") + + assertTrue(result.values.values.all { values -> assertIs(values.single()).value }) + assertEquals( + List(size = 6) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `ordinary arrays are arrays`() { + val result = analyze("ordinaryArraysAreArrays") + + assertTrue(assertIs(result.values.getValue("ordinaryArraysAreArrays").single()).value) + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `null and undefined parameters are not arrays`() { + val result = analyze("nullParameterIsNotArray", "undefinedParameterIsNotArray") + + result.values.values.forEach { values -> + assertFalse(assertIs(values.single()).value) + } + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `string and nested array parameters are arrays`() { + val result = analyze("stringArrayIsArray", "nestedArrayIsArray") + + result.values.values.forEach { values -> + assertTrue(assertIs(values.single()).value) + } + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `non builtin isArray call is not claimed by the model`() { + val result = analyze("nonBuiltinIsArrayFallsBack") + + assertTrue(result.modelIds.isEmpty()) + assertTrue(result.events.none { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + private fun analyze(vararg methodNames: String): AnalysisResult { + val methods = methodNames.associateWith(::method) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(unknownCallFallback = TsResidualCallPolicy.STOP_PATH), + observer = observer, + ).use { machine -> + val states = machine.analyze(methods.values.toList()) + val values = methods.mapValues { (_, method) -> + states.filter { state -> state.entrypoint === method } + .map { state -> TsTestResolver().resolve(method, state).returnValue } + } + + AnalysisResult( + values = values, + events = observer.events.toList(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == DEFAULT_ARK_CLASS_NAME && clazz.declaringFile === sourceFile } + .methods + .single { method -> method.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: Map>, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 1_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt new file mode 100644 index 0000000000..a5bf28d744 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt @@ -0,0 +1,203 @@ +package org.usvm.machine.call + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberType +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.api.initializeArray +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsState +import org.usvm.sizeSort +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import org.usvm.util.markDenseInputArray +import org.usvm.util.mkRegisterStackLValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayMutationEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayMutationEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `push supports zero through three arguments in source`() { + val result = analyze(methodName = "pushSupportedArities") + + assertEquals(1447.0, result.singleNumber()) + assertEquals(setOf("ts.array.push", "ts.array.primitive.grow"), result.modelIds.toSet()) + assertEquals(3, result.modelIds.count { it == "ts.array.push" }) + } + + @Test + fun `push outside bounded arity uses residual fallback`() { + val result = analyze(methodName = "pushTooManyArguments") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + assertTrue(result.modelIds.isEmpty()) + } + + @Test + fun `fill normalizes negative fractions infinities and NaN`() { + val negative = analyze( + methodName = "fillNegativeFractionAndInfinity", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val nan = analyze( + methodName = "fillNaNAndFraction", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + + assertEquals(1299.0, negative.singleNumber()) + assertEquals(7234.0, nan.singleNumber()) + assertTrue(listOf(negative, nan).all { "ts.array.fill" in it.modelIds }) + } + + @Test + fun `reverse and unshift mutate bounded dense arrays`() { + val reversed = analyze( + methodName = "reverseDense", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val unshifted = analyze( + methodName = "unshiftDense", + denseInputs = listOf(listOf(1.0, 2.0)), + ) + + assertEquals(4321.0, reversed.singleNumber()) + assertEquals(47_812.0, unshifted.singleNumber()) + assertTrue("ts.array.reverse" in reversed.modelIds) + assertTrue("ts.array.unshift" in unshifted.modelIds) + } + + @Test + fun `slice and concat copy bounded dense arrays`() { + val sliced = analyze( + methodName = "sliceDense", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val concatenated = analyze( + methodName = "concatDense", + denseInputs = listOf(listOf(1.0, 2.0), listOf(3.0, 4.0)), + ) + + assertEquals(32_344.0, sliced.singleNumber()) + assertEquals(41_234.0, concatenated.singleNumber()) + assertTrue("ts.array.slice" in sliced.modelIds) + assertTrue("ts.array.concat" in concatenated.modelIds) + } + + @Test + fun `sparse reverse remains residual`() { + val result = analyze(methodName = "sparseReverseUsesResidual") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + assertTrue(result.modelIds.isEmpty()) + } + + private fun analyze( + methodName: String, + denseInputs: List> = emptyList(), + ): AnalysisResult { + val method = method(methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + initialStateConfigurator = { state -> initializeDenseInputs(state, denseInputs) }, + ).use { machine -> + val states = machine.analyze(listOf(method)) + AnalysisResult( + values = states.map { state -> TsTestResolver().resolve(method, state).returnValue }, + events = observer.events.toList(), + ) + } + } + + private fun initializeDenseInputs( + state: TsState, + inputs: List>, + ) = with(state.ctx) { + val arrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val descriptor = arrayDescriptorOf(arrayType) + val arrays = inputs.mapIndexed { index, values -> + val array = state.memory.allocConcrete(descriptor) + state.memory.initializeArray( + arrayHeapRef = array, + type = descriptor, + sort = fp64Sort, + sizeSort = sizeSort, + contents = values.asSequence().map(::mkFp64), + ) + state.memory.write( + mkRegisterStackLValue(addressSort, index + 1), + array.asExpr(addressSort), + guard = trueExpr, + ) + state.saveSortForLocal(index + 1, addressSort) + array + } + arrays.forEach { array -> + state.markDenseInputArray(array = array, type = arrayType) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "ArrayMutationEtsIr" } + .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, + ) { + fun singleNumber(): Double = assertIs(values.single()).number + + 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, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} 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 index 312b88dbd7..51c72deea0 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt @@ -66,9 +66,15 @@ class TsArrayShiftMatrixTest { } val actual = values.map { assertIs(it).number } + val removalDecision = TsUnknownCallDecision.ModelApplied(modelId = "ts.array.$methodName") + val allowedDecisions = setOf( + removalDecision, + TsUnknownCallDecision.ModelApplied(modelId = "ts.number.isNaN"), + ) + assertEquals(listOf(expected[index].toDouble()), actual) - assertEquals(case.shiftCount, events.size) - assertTrue(events.all { it.decision == TsUnknownCallDecision.ModelApplied("ts.array.$methodName") }) + assertEquals(case.shiftCount, events.count { it.decision == removalDecision }) + assertTrue(events.all { it.decision in allowedDecisions }) } } } 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 2a844e606d..1ae4fb4a33 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 @@ -39,7 +39,13 @@ class TsArrayShiftReplayTest { DynamicTest.dynamicTest(case.name) { val method = methods.getValue("case$index") - val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions()).use { machine -> + val dispatcher = TsCompatibilityUnknownCallDispatcher.takeIf { case.compatibility } + val tests = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + unknownCallDispatcher = dispatcher, + ).use { machine -> machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } } @@ -147,14 +153,70 @@ class TsArrayShiftReplayTest { """.trimIndent(), ) ) + addAll(assertionAndWriteCases()) addAll(storageOperationCases()) addAll(pairCases()) addAll(typedCases()) } + private fun assertionAndWriteCases(): List = buildList { + add( + ReplayCase( + name = "number type assertions preserve runtime values", + parameters = "value: number", + maxResult = 1, + body = """ + if (value !== 42) return 0; + const asserted = value as unknown as string; + return typeof asserted === 'number' && asserted === 42 ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "boolean type assertions do not convert to numbers", + parameters = "value: boolean", + maxResult = 1, + body = """ + if (!value) return 0; + const asserted = value as unknown as number; + return typeof asserted === 'boolean' && asserted === true ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "truthy wrapped number retains its runtime kind through assertion and write", + parameters = "values: number[]", + maxResult = 2, + body = """ + if (values.length !== 1) return 0; + const item = values[0]; + if (!item) return 1; + const asserted = item as unknown as number; + const output = [0]; + output[0] = asserted; + return output[0] === item ? 2 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "typed array copies preserve wrapped numbers including NaN", + parameters = "values: number[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = values[1]; + return values[0] === values[1] || values[0] !== values[0] ? 1 : -1; + """.trimIndent(), + ) + ) + } + private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> copyCases(type) + mutationCases(type) + concatCases(type) - } + }.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } private fun copyCases(type: String): List = listOf( ReplayCase( @@ -464,6 +526,7 @@ class TsArrayShiftReplayTest { val parameters: String, val maxResult: Int, val body: String, + val compatibility: Boolean = false, ) private companion object { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt new file mode 100644 index 0000000000..c08e58548e --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -0,0 +1,174 @@ +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.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.assertTrue +import kotlin.time.Duration + +class TsDateEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/DateEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `Date now and zero argument constructor share the configured fixed clock`() { + val method = method("fixedClock") + val states = analyze( + method = method, + tsOptions = TsOptions(dateNowMilliseconds = 1_710_067_696_789.0), + ) + val value = TsTestResolver().resolve(method, states.single()).returnValue + + assertEquals(0.0, assertIs(value).number) + } + + @Test + fun `clock dependent Date calls remain unsupported without an explicit clock`() { + assertTrue(analyze(method("fixedClock")).isEmpty()) + } + + @Test + fun `numeric constructors getters UTC and overflow execute through source models`() { + assertNumber(methodName = "epochYear", expected = 1970.0) + assertNumber(methodName = "leapDay", expected = 129.0) + assertNumber(methodName = "overflow", expected = 20_231_201.0) + } + + @Test + fun `UTC distinguishes omitted arguments from explicit undefined`() { + assertNaN(methodName = "utcNoArguments") + assertNaN(methodName = "utcUndefinedYear") + assertNumber(methodName = "utcYearOnly", expected = 1_577_836_800_000.0) + assertNaN(methodName = "utcExplicitUndefined") + } + + @Test + fun `timezone offset of an invalid Date is NaN`() { + assertNaN(methodName = "invalidTimezoneOffset") + } + + @Test + fun `Date truncates fractional timestamps and components toward zero`() { + assertNumber(methodName = "fractionalTimestamps", expected = 9.0) + assertNumber(methodName = "fractionalUtcDay", expected = 1.0) + } + + @Test + fun `Date calls through any aliases use the Date model`() { + assertNumber(methodName = "anyAliasValueOf", expected = 123.0) + assertNumber(methodName = "anyAliasGetTime", expected = 456.0) + } + + @Test + fun `UTC minute and millisecond setters execute through source models`() { + assertNumber(methodName = "utcMinuteSetters", expected = 7_318_000.0) + } + + @Test + fun `concrete ISO formatting executes through the source model`() { + val method = method("isoEpoch") + val value = TsTestResolver().resolve(method, analyze(method).single()).returnValue + + assertEquals("1970-01-01T00:00:00.000Z", assertIs(value).value) + } + + @Test + fun `setter updates the shared Date timestamp slot`() { + assertNumber(methodName = "setter", expected = 951_782_400_029.0) + } + + @Test + fun `symbolic numeric timestamp round trips through constructor and valueOf`() { + val method = method("symbolicRoundTrip") + val states = analyze(method) + + assertTrue(states.isNotEmpty()) + val tests = states.map { state -> TsTestResolver().resolve(method, state) } + tests.forEach { test -> + val timestamp = assertIs(test.before.parameters.single()).number + val actual = assertIs(test.returnValue).number + val expected = if (!timestamp.isFinite() || timestamp < -MAX_DATE_TIME || timestamp > MAX_DATE_TIME) { + Double.NaN + } else { + timestamp.toLong().toDouble() + } + + if (expected.isNaN()) { + assertTrue(actual.isNaN(), "TimeClip($timestamp) must be NaN, got $actual") + } else { + assertEquals(expected, actual, "TimeClip($timestamp)") + } + } + + assertTrue( + tests.any { test -> + assertIs(test.returnValue).number.isNaN() + } + ) + assertTrue( + tests.any { test -> + assertIs(test.returnValue).number.isFinite() + } + ) + } + + private fun assertNumber(methodName: String, expected: Double) { + val method = method(methodName) + val values = analyze(method).map { state -> TsTestResolver().resolve(method, state).returnValue } + + assertEquals(expected, assertIs(values.single()).number) + } + + private fun assertNaN(methodName: String) { + val method = method(methodName) + val values = analyze(method).map { state -> TsTestResolver().resolve(method, state).returnValue } + + assertTrue(assertIs(values.single()).number.isNaN()) + } + + private fun analyze( + method: EtsMethod, + tsOptions: TsOptions = TsOptions(), + ) = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + ).use { machine -> machine.analyze(listOf(method)) } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "DateEtsIr" } + .methods + .single { it.name == name } + + private companion object { + const val MAX_DATE_TIME = 8_640_000_000_000_000.0 + + 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/TsDateModelsArtifactTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt new file mode 100644 index 0000000000..ff2ded3ff9 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt @@ -0,0 +1,32 @@ +package org.usvm.machine.call + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TsDateModelsArtifactTest { + @Test + fun `built in catalog registers Date family`() { + val ids = TsBuiltInUnknownCallModels.catalog().modelIds + + assertTrue("ts.date.constructor" in ids, ids.toString()) + assertTrue("ts.date.getTime" in ids, ids.toString()) + assertTrue("ts.date.now" in ids, ids.toString()) + } + + @Test + fun `native frontend loads Date source model family`() { + val artifact = loadBundledEtsIrUnknownCallModelArtifact( + resourceName = "/org/usvm/machine/call/models/DateModels.ts", + sourceFileName = "DateModels.ts", + entryPointClassName = "DateModels", + entryPointMethodName = "construct", + ) + val dateModels = artifact.file.allClasses.single { it.name == "DateModels" } + + assertEquals("construct", artifact.entryPoint.name) + assertTrue(dateModels.methods.any { it.name == "getTime" }) + assertTrue(dateModels.methods.any { it.name == "toISOString" }) + assertTrue(dateModels.methods.all { method -> method.cfg.instructions.isNotEmpty() }) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt new file mode 100644 index 0000000000..70a33fa077 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.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.machine.call.intrinsic.TsErrorEtsIrModelFamily +import org.usvm.machine.state.TsMethodResult +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.assertTrue +import kotlin.time.Duration + +class TsErrorEtsIrModelTest { + private val builtInSourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ErrorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val shadowSourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/UserDefinedErrorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val builtInScene = EtsScene(projectFiles = listOf(builtInSourceFile)) + private val shadowScene = EtsScene(projectFiles = listOf(shadowSourceFile)) + + @Test + fun `Error constructor initializes name and exact message through source model`() { + val expected = mapOf( + "name" to "Error", + "message" to "expected message", + "overwrittenName" to "CustomError", + "overwrittenMessage" to "after", + "anyErrorMessage" to "aliased message", + ) + + expected.forEach { (methodName, expectedValue) -> + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = methodName) + val actual = assertIs(result.values.single()).value + + assertEquals(expectedValue, actual, methodName) + assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) + } + } + + @Test + fun `modeled Error receiver remains the thrown exception`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "throwError") + + assertEquals(1, result.states.size) + assertIs(result.states.single().methodResult) + assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) + assertTrue(result.values.single() is TsTestValue.TsException) + } + + @Test + fun `Error model storage does not affect ordinary any field resolution`() { + listOf("anyForeignNameComparison", "anyForeignMessageComparison").forEach { methodName -> + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = methodName) + val actual = result.values.map { value -> assertIs(value).number } + + assertEquals(listOf(1.0), actual, methodName) + assertTrue(result.modelIds.isEmpty(), methodName) + } + } + + @Test + fun `callback Error message remains residual`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "callbackMessage") + + assertTrue(result.states.isEmpty()) + assertEquals( + listOf(TsResidualCallPolicy.STOP_PATH), + result.events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ResidualFallback)?.policy + }, + ) + assertTrue(result.modelIds.isEmpty()) + } + + @Test + fun `user defined Error constructor does not use builtin model`() { + val result = analyze(scene = shadowScene, className = "UserDefinedErrorEtsIr", methodName = "name") + val actual = assertIs(result.values.single()).value + + assertEquals("ShadowError", actual) + assertTrue(result.modelIds.isEmpty()) + } + + private fun analyze(scene: EtsScene, className: String, methodName: String): AnalysisResult { + val method = method(scene = scene, className = className, methodName = methodName) + val observer = RecordingUnknownCallObserver() + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + val values = states.map { state -> TsTestResolver().resolve(method, state).returnValue } + + return AnalysisResult( + states = states, + values = values, + events = observer.events.toList(), + ) + } + + private fun method(scene: EtsScene, className: String, methodName: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == className } + .methods + .single { method -> method.name == methodName } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val states: List, + val values: List, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + 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/TsEtsIrUnknownCallModelExecutionTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt index 67e2155165..69e78589b6 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt @@ -38,6 +38,16 @@ class TsEtsIrUnknownCallModelExecutionTest { private val modelClass = baseArtifact.file.allClasses.single { it.name == "EtsIrSemanticModels" } private val models = TsUnknownCallModelCatalog( models = listOf( + model( + id = "test.ets-ir.namespace-adapter", + targetName = "abs", + entryPointName = "absolute", + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + } + }, + ), model( id = "test.ets-ir.absolute", targetName = "absolute", @@ -93,6 +103,17 @@ class TsEtsIrUnknownCallModelExecutionTest { ), ) + @Test + fun `custom adapter can ignore an unresolved namespace receiver`() { + val result = analyze(methodName = "namespaceReceiverCanBeIgnored") + + assertTrue( + result.values.filterIsInstance().any { value -> value.number == 2.0 }, + result.values.toString(), + ) + assertEquals(listOf("test.ets-ir.namespace-adapter"), result.modelIds.distinct()) + } + @Test fun `pure EtsIR body maps argument and return value`() { val result = analyze(methodName = "pureArgumentAndReturn") @@ -202,6 +223,7 @@ class TsEtsIrUnknownCallModelExecutionTest { targetName: String, entryPointName: String, domainGuard: TsEtsIrUnknownCallModelDomainGuard = TsEtsIrUnknownCallModelDomainGuard.ALWAYS, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, ): TsUnknownCallModel { val artifact = baseArtifact.copy( entryPoint = modelClass.methods.single { it.name == entryPointName }, @@ -212,6 +234,7 @@ class TsEtsIrUnknownCallModelExecutionTest { target = TsUnknownCallTarget(methodName = targetName), artifact = artifact, domainGuard = domainGuard, + inputAdapter = inputAdapter, ) } 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 a38ba9b3fe..4b6f4f2ad6 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 @@ -36,7 +36,8 @@ class TsInstanceCallReceiverTest { val methods = scene.projectClasses.single { it.name == "InstanceCallReceiver" }.methods.associateBy { it.name } return cases.map { case -> - DynamicTest.dynamicTest(case.method) { + val label = if (case.compatibility) "compatibility receiver: ${case.method}" else case.method + DynamicTest.dynamicTest(label) { val method = methods.getValue(case.method) val events = mutableListOf() val observer = object : TsInterpreterObserver { @@ -45,7 +46,13 @@ class TsInstanceCallReceiverTest { } } - val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions(), observer = observer) + val tests = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + unknownCallDispatcher = TsCompatibilityUnknownCallDispatcher.takeIf { case.compatibility }, + ) .use { machine -> machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } } @@ -125,30 +132,31 @@ class TsInstanceCallReceiverTest { val method: String, val results: Set, val throws: Boolean = false, + val compatibility: 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 = "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 = "wrappedPush", results = setOf(1.0), compatibility = true), + Case(method = "wrappedReverse", results = setOf(1.0), compatibility = true), + Case(method = "wrappedFill", results = setOf(1.0), compatibility = true), + Case(method = "wrappedUnshift", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlice", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceReversed", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlicePastEnd", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlicePastStart", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceNegative", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceEmpty", results = setOf(1.0), compatibility = true), + Case(method = "wrappedConcat", results = setOf(1.0), compatibility = true), 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 = "primitiveToString", results = setOf(0.0, 1.0), compatibility = true), 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), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt new file mode 100644 index 0000000000..e56aee2623 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt @@ -0,0 +1,276 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +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.machine.call.intrinsic.TsNumericIntrinsicModelFamily +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.assertTrue +import kotlin.time.Duration + +class TsNumericIntrinsicModelsTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/NumericIntrinsicModels.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(sourceFile)) + + @Test + fun `primary Math models preserve binary64 special values and signed zero`() { + val expected = linkedMapOf( + "absNegativeZero" to numberToken(0.0), + "absNaN" to NAN, + "absNegativeInfinity" to numberToken(Double.POSITIVE_INFINITY), + "minSignedZero" to numberToken(-0.0), + "minNaN" to NAN, + "minNoArguments" to numberToken(Double.POSITIVE_INFINITY), + "maxSignedZero" to numberToken(0.0), + "maxInfinity" to numberToken(Double.POSITIVE_INFINITY), + "maxNoArguments" to numberToken(Double.NEGATIVE_INFINITY), + "roundNegativeHalf" to numberToken(-0.0), + "roundPositiveHalf" to numberToken(1.0), + "roundNegativeOneHalf" to numberToken(-1.0), + "roundNaN" to NAN, + "ceilNegativeFraction" to numberToken(-0.0), + "ceilInfinity" to numberToken(Double.POSITIVE_INFINITY), + "absNoArguments" to NAN, + "absExtraArgument" to numberToken(2.0), + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedToken) -> + val actual = assertIs(result.values.getValue(methodName).single()).number + + assertEquals(expectedToken, numberToken(actual), methodName) + } + assertEquals(expected.size, result.events.size) + assertTrue(result.events.all { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `Number isInteger handles finite boundaries and non numbers`() { + val expected = linkedMapOf( + "integerPositiveZero" to true, + "integerNegativeZero" to true, + "integerFraction" to false, + "integerNaN" to false, + "integerInfinity" to false, + "integerLargeBinary64" to true, + "integerBoolean" to false, + "integerNoArguments" to false, + "integerExtraArgument" to true, + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedValue) -> + val actual = assertIs(result.values.getValue(methodName).single()).value + + assertEquals(expectedValue, actual, methodName) + } + assertEquals( + List(expected.size) { TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID }, + result.modelIds, + ) + } + + @Test + fun `symbolic numeric calls use intrinsic models`() { + val methodNames = listOf("symbolicAbs", "symbolicInteger") + + val result = analyze(methodNames) + + val absValues = result.values.getValue("symbolicAbs") + .map { value -> assertIs(value).number } + val integerValues = result.values.getValue("symbolicInteger") + .map { value -> assertIs(value).number } + + assertEquals(setOf(1.0), absValues.toSet()) + assertEquals(setOf(0.0, 1.0), integerValues.toSet()) + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.MATH_ABS_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + ), + result.modelIds.toSet(), + ) + assertTrue(result.events.all { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `unsupported Math domain uses residual fallback`() { + val methodNames = listOf("unsupportedAbsDomain") + + val result = analyze(methodNames) + + assertTrue(result.values.values.all { values -> values.isEmpty() }) + assertTrue(result.modelIds.isEmpty()) + assertEquals( + List(methodNames.size) { TsUnknownCallOutcome.PATH_STOPPED }, + result.events.map { event -> event.outcome }, + ) + } + + @Test + fun `disabled numeric model uses configured fallback`() { + val result = analyze( + methodNames = listOf("absNegativeZero"), + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, + ), + ) + + assertTrue(result.values.getValue("absNegativeZero").isEmpty()) + assertTrue(result.modelIds.isEmpty()) + assertEquals(listOf(TsUnknownCallOutcome.PATH_STOPPED), result.events.map { event -> event.outcome }) + } + + @Test + fun `adjacent Math models preserve special values`() { + val expected = linkedMapOf( + "floorNegativeFraction" to numberToken(-2.0), + "floorNegativeZero" to numberToken(-0.0), + "floorInfinity" to numberToken(Double.POSITIVE_INFINITY), + "truncNegativeFraction" to numberToken(-1.0), + "truncNegativeSmall" to numberToken(-0.0), + "truncNaN" to NAN, + "sqrtFour" to numberToken(2.0), + "sqrtNegative" to NAN, + "sqrtNegativeZero" to numberToken(-0.0), + "sqrtInfinity" to numberToken(Double.POSITIVE_INFINITY), + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedToken) -> + val actual = assertIs(result.values.getValue(methodName).single()).number + + assertEquals(expectedToken, numberToken(actual), methodName) + } + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.MATH_FLOOR_ID, + TsNumericIntrinsicModelFamily.MATH_TRUNC_ID, + TsNumericIntrinsicModelFamily.MATH_SQRT_ID, + ), + result.modelIds.toSet(), + ) + } + + @Test + fun `adjacent Number predicates handle special and non number values`() { + val expected = linkedMapOf( + "finiteNumber" to true, + "finiteNaN" to false, + "finiteInfinity" to false, + "finiteBoolean" to false, + "nanNaN" to true, + "nanNumber" to false, + "nanBoolean" to false, + "safeIntegerMaximum" to true, + "safeIntegerAboveMaximum" to false, + "safeIntegerFraction" to false, + "safeIntegerInfinity" to false, + "safeIntegerBoolean" to false, + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedValue) -> + val actual = assertIs(result.values.getValue(methodName).single()).value + + assertEquals(expectedValue, actual, methodName) + } + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.NUMBER_IS_FINITE_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_NAN_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_SAFE_INTEGER_ID, + ), + result.modelIds.toSet(), + ) + } + + private fun analyze( + methodNames: List, + tsOptions: TsOptions = TsOptions(), + ): AnalysisResult { + val methods = methodNames.associateWith(::method) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + observer = observer, + ).use { machine -> + val states = machine.analyze(methods.values.toList()) + val values = methods.mapValues { (_, method) -> + states.filter { state -> state.entrypoint === method } + .map { state -> TsTestResolver().resolve(method, state).returnValue } + } + + AnalysisResult( + values = values, + events = observer.events.toList(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == DEFAULT_ARK_CLASS_NAME && clazz.declaringFile === sourceFile } + .methods + .single { method -> method.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: Map>, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + const val NAN: String = "nan" + + fun numberToken(value: Double): String = + if (value.isNaN()) NAN else value.toRawBits().toULong().toString(radix = 16).padStart(16, '0') + + 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/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt new file mode 100644 index 0000000000..55730d0054 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -0,0 +1,437 @@ +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.assertTrue +import kotlin.time.Duration + +class TsSequenceEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/SequenceEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `array indexOf uses strict equality and offsets`() { + val result = analyze(methodName = "arrayIndexOfUsesStrictEqualityAndOffsets") + + assertEquals(-681.0, assertIs(result.values.single()).number) + assertEquals(listOf("ts.array.indexOf", "ts.math.floor"), result.modelIds.distinct()) + } + + @Test + fun `array includes uses SameValueZero`() { + val result = analyze(methodName = "arrayIncludesUsesSameValueZero") + + assertTrue(assertIs(result.values.single()).value) + assertEquals(listOf("ts.array.includes", "ts.math.floor"), result.modelIds.distinct()) + } + + @Test + fun `typed default searches fall back without array presence metadata`() { + val result = analyze(methodName = "numericDefaultSearchFallsBack") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `array offsets normalize fractions and NaN`() { + val result = analyze(methodName = "arrayOffsetsAreNormalized") + + assertEquals(131.0, assertIs(result.values.single()).number) + assertEquals(setOf("ts.array.includes", "ts.array.indexOf", "ts.math.floor"), result.modelIds.toSet()) + } + + @Test + fun `empty arrays do not match`() { + val result = analyze(methodName = "emptyArraysDoNotMatch") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `explicit undefined uses the default array offset`() { + val result = analyze(methodName = "arrayExplicitUndefinedOffset") + + assertEquals(0.0, assertIs(result.values.single()).number) + } + + @Test + fun `array lastIndexOf searches backward from normalized offsets`() { + val result = analyze(methodName = "arrayLastIndexOfHandlesOffsets") + + assertEquals(199.0, assertIs(result.values.single()).number) + assertTrue("ts.array.lastIndexOf" in result.modelIds) + } + + @Test + fun `array lastIndexOf distinguishes omitted and undefined offsets`() { + val result = analyze(methodName = "arrayLastIndexOfExplicitUndefined") + + assertEquals(0.0, assertIs(result.values.single()).number) + } + + @Test + fun `array lastIndexOf stops before indexes below negative length`() { + val result = analyze(methodName = "arrayLastIndexOfBeforeStart") + + assertEquals(-1.0, assertIs(result.values.single()).number) + } + + @Test + fun `array searches canonicalize negative zero results`() { + val result = analyze(methodName = "arraySearchReturnsPositiveZero") + + assertEquals(3.0, assertIs(result.values.single()).number) + } + + @Test + fun `numeric holes do not match zero`() { + val result = analyze(methodName = "numericHoleDoesNotMatchZero") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `numeric holes reject includes undefined without presence metadata`() { + val result = analyze(methodName = "numericHoleDoesNotIncludeUndefined") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `symbolic typed-default search uses residual fallback`() { + val result = analyze(methodName = "numericHoleWithSymbolicSearch") + + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { value -> !assertIs(value).value }) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + @Test + fun `array includes finds explicit undefined`() { + val result = analyze(methodName = "explicitUndefinedArrayIncludesUndefined") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `array indexOf rejects undefined search when slot presence is unavailable`() { + val result = analyze(methodName = "explicitUndefinedArrayIndexOfUndefined") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `string charAt handles in-range and out-of-range indexes`() { + val result = analyze(methodName = "stringCharAtHandlesBounds") + + assertEquals("b", assertIs(result.values.single()).value) + assertTrue("ts.string.charAt" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `selecting charAt also enables its primitives`() { + val result = analyze( + methodName = "stringCharAtHandlesBounds", + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf("ts.string.charAt")), + ), + ) + + assertEquals("b", assertIs(result.values.single()).value) + assertEquals( + setOf( + "ts.string.charAt", + "ts.math.floor", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.fromCodeUnit", + "ts.string.primitive.length", + ), + result.modelIds.toSet(), + ) + } + + @Test + fun `string indexOf handles offsets and empty search`() { + val result = analyze(methodName = "stringIndexOfHandlesOffsetsAndEmptySearch") + + assertEquals(330.0, assertIs(result.values.single()).number) + assertTrue("ts.string.indexOf" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `string includes handles NaN and infinity positions`() { + val result = analyze(methodName = "stringIncludesHandlesNaNPosition") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.includes" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `explicit undefined uses default string positions`() { + val result = analyze(methodName = "stringExplicitUndefinedPositions") + + assertEquals(1.0, assertIs(result.values.single()).number) + } + + @Test + fun `symbolic string position explores exact matches`() { + val result = analyze(methodName = "stringSymbolicPosition") + val numbers = result.values.filterIsInstance().map { it.number }.toSet() + + assertTrue(1.0 in numbers, "Expected first match for positions at or before 1: $numbers") + assertTrue(3.0 in numbers, "Expected second match for positions 2 or 3: $numbers") + assertTrue(-1.0 in numbers, "Expected no match after the last occurrence: $numbers") + assertTrue("ts.string.indexOf" in result.modelIds) + } + + @Test + fun `symbolic charAt preserves selected UTF16 content`() { + val result = analyze(methodName = "symbolicCharAtMatches") + + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { assertIs(it).value }) + assertTrue("ts.string.charAt" in result.modelIds) + } + + @Test + fun `substring swaps clamped bounds and preserves surrogate code units`() { + val result = analyze(methodName = "stringSubstringBounds") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.substring" in result.modelIds) + } + + @Test + fun `trim recognizes ECMAScript whitespace and preserves interior and non whitespace`() { + val result = analyze(methodName = "stringTrimWhitespace") + + assertTrue(assertIs(result.values.single()).value) + assertTrue(setOf("ts.string.trim", "ts.string.trimStart", "ts.string.trimEnd").all(result.modelIds::contains)) + } + + @Test + fun `string charCodeAt returns code units and NaN out of bounds`() { + val result = analyze(methodName = "stringCharCodeAtHandlesBounds") + + assertEquals(91.0, assertIs(result.values.single()).number) + assertTrue("ts.string.charCodeAt" in result.modelIds) + } + + @Test + fun `string startsWith and endsWith honor positions`() { + val result = analyze(methodName = "stringStartsAndEndsWithHandlePositions") + + assertTrue(assertIs(result.values.single()).value) + assertTrue(setOf("ts.string.startsWith", "ts.string.endsWith").all(result.modelIds::contains)) + } + + @Test + fun `string lastIndexOf searches backward and matches empty suffix`() { + val result = analyze(methodName = "stringLastIndexOfHandlesPositions") + + assertEquals(315.0, assertIs(result.values.single()).number) + assertTrue("ts.string.lastIndexOf" in result.modelIds) + } + + @Test + fun `string slice copies exact UTF16 code units`() { + val result = analyze(methodName = "stringSlicePreservesUtf16") + + assertEquals("😀", assertIs(result.values.single()).value) + assertTrue("ts.string.slice" in result.modelIds) + } + + @Test + fun `modeled strings compare by UTF-16 value`() { + val result = analyze(methodName = "stringModelsUseValueEquality") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.slice" in result.modelIds) + } + + @Test + fun `non-string references retain identity equality`() { + val result = analyze(methodName = "stringEqualityPreservesReferenceIdentity") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `fake reference strings compare by UTF-16 value`() { + val result = analyze(methodName = "stringValueEqualityThroughAny") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `string truthiness follows UTF-16 length`() { + val result = analyze(methodName = "stringTruthinessUsesLength") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `non-string truthiness does not read string storage`() { + val result = analyze(methodName = "nonStringTruthinessDoesNotReadStringStorage") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `mixed object equality does not read string storage`() { + val result = analyze(methodName = "mixedObjectEqualityDoesNotReadStringStorage") + + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { value -> assertIs(value).value }) + } + + @Test + fun `ASCII casing composes with charAt slice and concatenation`() { + val capitalized = analyze(methodName = "stringCapitalizeAscii") + val empty = analyze(methodName = "stringCapitalizeEmpty") + + assertEquals("Hello", assertIs(capitalized.values.single()).value) + assertEquals("", assertIs(empty.values.single()).value) + assertTrue(setOf("ts.string.toUpperCase", "ts.string.toLowerCase").all(capitalized.modelIds::contains)) + } + + @Test + fun `ASCII casing leaves non ASCII strings to residual fallback`() { + val result = analyze(methodName = "stringCapitalizeNonAscii") + + assertTrue(result.values.isEmpty()) + assertTrue(result.events.any { event -> event.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + @Test + fun `legacy approximations use observable residual fallback with empty stop catalog`() { + val methodNames = listOf( + "legacyArrayConcat", + "legacyArrayFill", + "legacyArrayJoin", + "legacyArrayPush", + "legacyArrayReverse", + "legacyArraySlice", + "legacyArrayToString", + "legacyArrayUnshift", + ) + val options = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, + ) + + methodNames.forEach { methodName -> + val result = analyze(methodName = methodName, tsOptions = options) + + assertTrue(result.values.isEmpty(), methodName) + assertEquals(listOf(TsUnknownCallOutcome.PATH_STOPPED), result.events.map(TsUnknownCallEvent::outcome)) + assertTrue(result.events.single().decision is TsUnknownCallDecision.ResidualFallback, methodName) + assertEquals(TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, result.events.single().failureReason) + } + } + + @Test + fun `legacy join uses fresh residual result instead of sentinel with empty fresh catalog`() { + val result = analyze( + methodName = "legacyArrayJoin", + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), + ) + + assertEquals(listOf(TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), result.events.map(TsUnknownCallEvent::outcome)) + assertEquals(1, result.values.size) + assertTrue( + result.values.none { value -> + value is TsTestValue.TsString && value.value == "joined_array_result" + }, + ) + } + + private fun analyze( + methodName: String, + tsOptions: TsOptions = TsOptions(), + ): AnalysisResult { + val method = method(methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + observer = observer, + ).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(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "SequenceEtsIr" } + .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 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, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt new file mode 100644 index 0000000000..f0f6df7652 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt @@ -0,0 +1,103 @@ +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.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.assertTrue +import kotlin.time.Duration + +class TsStringReplaceAllEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/StringReplaceAllEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `literal string replacement implements substitution and UTF16 boundaries`() { + val cases = mapOf( + "literalMatches" to "XX/aba", + "emptySearch" to "-A-\uD83D-\uDE00-", + "substitutions" to "\$:a::ba:\$1b\$:a:ab::\$1", + "noMatches" to "abc", + "emptyReceiver" to "\$", + "replacementIsNotSearchedAgain" to "aaaa", + ) + + cases.forEach { (methodName, expected) -> + val (values, events) = analyze(methodName) + + assertEquals(expected, assertIs(values.single()).value, methodName) + assertTrue(events.any { (it.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == MODEL_ID }) + assertTrue(events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + } + + @Test + fun `non string arguments remain explicit residual calls`() { + val cases = listOf( + "numericSearchIsResidual", + "numericReplacementIsResidual", + "callbackReplacementIsResidual", + "callableSearchIsResidual", + ) + + for (methodName in cases) { + val (values, events) = analyze(methodName) + + assertTrue(values.isEmpty(), methodName) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, events.single().outcome, methodName) + } + } + + private fun analyze(methodName: String): Pair, List> { + val method = scene.projectClasses.single { it.name == "StringReplaceAllEtsIr" }.methods + .single { it.name == methodName } + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf(MODEL_ID))), + observer = observer, + ).use { machine -> + val values = machine.analyze(listOf(method)).map { state -> + TsTestResolver().resolve(method, state).returnValue + } + values to events.toList() + } + } + + private companion object { + const val MODEL_ID = "ts.string.replaceAll" + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} 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 e0083ed8ea..82a7fe1b85 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 @@ -12,6 +12,8 @@ import org.usvm.UMachineOptions import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel +import org.usvm.machine.call.intrinsic.TsErrorEtsIrModelFamily +import org.usvm.machine.call.intrinsic.TsNumericIntrinsicModelFamily import org.usvm.machine.state.TsState import kotlin.test.Test import kotlin.test.assertEquals @@ -80,6 +82,32 @@ class TsUnknownCallModelCatalogTest { assertEquals("Unknown semantic model IDs: missing", error.message) } + @Test + fun `selection includes transitive model dependencies`() { + val catalog = TsUnknownCallModelCatalog( + models = listOf( + model(id = "entry", requiredModelIds = setOf("helper")), + model(id = "helper", requiredModelIds = setOf("primitive")), + model(id = "primitive"), + model(id = "unrelated"), + ), + selection = TsUnknownCallModelSelection.Only(setOf("entry")), + ) + + assertEquals(listOf("entry", "helper", "primitive"), catalog.modelIds) + } + + @Test + fun `missing model dependency is rejected`() { + val error = assertFailsWith { + TsUnknownCallModelCatalog( + models = listOf(model(id = "entry", requiredModelIds = setOf("missing"))), + ) + } + + assertEquals("Semantic model entry requires unknown model IDs: missing", error.message) + } + @Test fun `selection does not depend on model order`() { val forward = listOf( @@ -150,13 +178,45 @@ class TsUnknownCallModelCatalogTest { fun `built in models are discovered once and an explicit empty selection disables all`() { val catalog = TsBuiltInUnknownCallModels.catalog() - assertEquals(listOf("ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID), catalog.modelIds) + val expectedModelIds = listOf( + "ts.array.includes", + "ts.array.indexOf", + "ts.array.lastIndexOf", + "ts.array.pop", + TsArrayShiftIntrinsicModel.MODEL_ID, + TsNumericIntrinsicModelFamily.MATH_ABS_ID, + TsNumericIntrinsicModelFamily.MATH_CEIL_ID, + TsNumericIntrinsicModelFamily.MATH_FLOOR_ID, + TsNumericIntrinsicModelFamily.MATH_MAX_ID, + TsNumericIntrinsicModelFamily.MATH_MIN_ID, + TsNumericIntrinsicModelFamily.MATH_ROUND_ID, + TsNumericIntrinsicModelFamily.MATH_SQRT_ID, + TsNumericIntrinsicModelFamily.MATH_TRUNC_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_FINITE_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_NAN_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_SAFE_INTEGER_ID, + "ts.string.charAt", + "ts.string.charCodeAt", + "ts.string.endsWith", + "ts.string.includes", + "ts.string.indexOf", + "ts.string.lastIndexOf", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.fromCodeUnit", + "ts.string.primitive.length", + "ts.string.startsWith", + ) + + assertTrue(catalog.modelIds.containsAll(expectedModelIds)) + assertTrue("ts.date.constructor" in catalog.modelIds) + assertTrue(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID in catalog.modelIds) + assertTrue("ts.date.now" in catalog.modelIds) + assertEquals(expected = 38, actual = catalog.modelIds.count { it.startsWith("ts.date.") }) + assertEquals(catalog.modelIds.distinct().sorted(), catalog.modelIds) assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) assertFailsWith { (catalog.modelIds as MutableList).clear() } - assertEquals( - listOf("ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID), - TsBuiltInUnknownCallModels.catalog().modelIds, - ) + assertEquals(catalog.modelIds, TsBuiltInUnknownCallModels.catalog().modelIds) assertTrue(TsBuiltInUnknownCallModels.catalog(TsUnknownCallModelSelection.Only(emptySet())).modelIds.isEmpty()) } @@ -262,6 +322,7 @@ class TsUnknownCallModelCatalogTest { failureReason: TsUnknownCallFailureReason? = null, className: String? = null, additionalSceneFiles: List = emptyList(), + requiredModelIds: Set = emptySet(), ): TsUnknownCallModel = FakeModel( id = id, target = TsUnknownCallTarget( @@ -270,12 +331,14 @@ class TsUnknownCallModelCatalogTest { enclosingClassName = className, ), additionalSceneFiles = additionalSceneFiles, + requiredModelIds = requiredModelIds, ) private class FakeModel( override val id: String, override val target: TsUnknownCallTarget, override val additionalSceneFiles: List = emptyList(), + override val requiredModelIds: Set = emptySet(), ) : 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/expr/GlobalArrayAssignmentBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt new file mode 100644 index 0000000000..667ba69652 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt @@ -0,0 +1,72 @@ +package org.usvm.machine.expr + +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.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +class GlobalArrayAssignmentBoundaryTest { + @Test + fun `fractional global array assignment stops as a named property limitation`() { + val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/GlobalArrayAssignmentBoundary.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = EtsScene(listOf(sourceFile)) + val method = scene.projectClasses + .single { it.name == "GlobalArrayAssignmentBoundary" } + .methods + .single { it.name == "readSecondValue" } + val observer = RecordingObserver() + + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 16), + observer = observer, + ).use { machine -> + machine.analyze(listOf(method)) + } + + assertTrue(states.isEmpty()) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + observer.limitations.single().reason, + ) + } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt new file mode 100644 index 0000000000..64bf39159d --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt @@ -0,0 +1,217 @@ +package org.usvm.machine.expr + +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.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +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 +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class NumericBoundarySemanticsTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/NumericBoundarySemantics.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `bitwise operators use total ECMAScript int32 conversion`() { + val expectedResults = mapOf( + "bitwiseNaN" to 0.0, + "bitwiseInfinity" to 0.0, + "bitwiseUint32Wrap" to 1.0, + "bitwiseFraction" to 1.0, + "bitwiseNegativeFraction" to -1.0, + "bitwiseNotNaN" to -1.0, + "bitwiseAndInfinity" to 0.0, + "bitwiseXorInfinity" to 7.0, + "leftShiftMasksCount" to 2.0, + "rightShiftTruncates" to -2.0, + "unsignedRightShift" to 4_294_967_295.0, + "bitwiseNegativeZero" to 0.0, + ) + + expectedResults.forEach { (methodName, expected) -> + assertEquals(expected, singleNumber(methodName), methodName) + } + } + + @Test + fun `missing numeric array properties return undefined without integer coercion`() { + assertEquals(63.0, singleNumber("missingNumericArrayProperties")) + assertEquals(11.0, singleNumber("negativeZeroArrayIndex")) + } + + @Test + fun `unsupported named property and growth writes stop their paths`() { + val unsupportedMethods = mapOf( + "fractionalArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "negativeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "outOfRangeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + "chainedMissingArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "typedArrayCannotStoreMissingValue" to TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_KIND_WRITE, + ) + + unsupportedMethods.forEach { (methodName, reason) -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals(reason, observer.limitations.single().reason, methodName) + } + assertEquals(7.0, singleNumber("negativeZeroArrayWrite")) + } + + @Test + fun `chained array indexes preserve numeric and undefined branches`() { + assertEquals(22.0, singleNumber("chainedArrayRead")) + assertIs(singleValue("chainedMissingArrayRead")) + } + + @Test + fun `reference property keys remain explicit read limitations`() { + val unsupportedMethods = listOf( + "stringArrayIndexRead", + "objectArrayIndexRead", + ) + + unsupportedMethods.forEach { methodName -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_READ, + observer.limitations.single().reason, + methodName, + ) + } + } + + @Test + fun `numeric string indexes address UTF16 code units without coercion`() { + assertEquals("A", singleString("stringZeroIndex")) + assertEquals("A", singleString("stringNegativeZeroIndex")) + assertEquals("\uD83D", singleString("stringHighSurrogateIndex")) + assertEquals("\uDE00", singleString("stringLowSurrogateIndex")) + assertEquals(31.0, singleNumber("missingNumericStringProperties")) + } + + @Test + fun `invalid array lengths throw before conversion`() { + val invalidMethods = listOf( + "newArrayInfinity", + "newArrayUint32Overflow", + "newArrayFraction", + "assignInvalidLength", + ) + + invalidMethods.forEach { methodName -> + val state = analyze(methodName).single() + assertIs(state.methodResult, methodName) + } + } + + @Test + fun `valid but unsupported lengths remain residual`() { + val unsupportedMethods = listOf( + "newArrayBeyondModelCapacity", + "assignLengthBeyondModelCapacity", + ) + + unsupportedMethods.forEach { methodName -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + observer.limitations.single().reason, + methodName, + ) + } + } + + @Test + fun `negative zero is a valid array length`() { + assertEquals(0.0, singleNumber("newArrayNegativeZero")) + assertEquals(0.0, singleNumber("assignNegativeZeroLength")) + } + + private fun singleNumber(methodName: String): Double { + val result = singleValue(methodName) + + return assertIs(result).number + } + + private fun singleString(methodName: String): String { + val result = singleValue(methodName) + + return assertIs(result).value + } + + private fun singleValue(methodName: String): TsTestValue { + val method = method(methodName) + val state = analyze(method).single() + + return TsTestResolver().resolve(method, state).returnValue + } + + private fun analyze( + methodName: String, + observer: TsInterpreterObserver? = null, + ): List = analyze(method(methodName), observer) + + private fun analyze( + method: EtsMethod, + observer: TsInterpreterObserver? = null, + ): List = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 16), + observer = observer, + ).use { machine -> + machine.analyze(listOf(method)) + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "NumericBoundarySemantics" } + .methods + .single { it.name == name } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt index 3c9c3feeea..d828df3a45 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt @@ -2,11 +2,19 @@ package org.usvm.samples.arrays import org.jacodb.ets.model.EtsScene import org.junit.jupiter.api.Test +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.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.test.util.checkers.noResultsExpected import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import org.usvm.util.neq +import kotlin.test.assertEquals +import kotlin.test.assertTrue class AllocatedArrays : TsMethodTestRunner() { private val tsPath = "/samples/arrays/AllocatedArrays.ts" @@ -95,19 +103,37 @@ class AllocatedArrays : TsMethodTestRunner() { @Test fun `test allocatedArrayLengthExpansion`() { - val method = getMethod("allocatedArrayLengthExpansion") - discoverProperties( - method = method, - { r -> r is TsTestValue.TsException } - ) + assertArrayIndexGrowthLimitation("allocatedArrayLengthExpansion") } @Test fun `test writeInTheIndexEqualToLength`() { - val method = getMethod("writeInTheIndexEqualToLength") - discoverProperties( - method = method, - { r -> r is TsTestValue.TsException }, + assertArrayIndexGrowthLimitation("writeInTheIndexEqualToLength") + } + + private fun assertArrayIndexGrowthLimitation(methodName: String) { + val observer = RecordingObserver() + val states = TsMachine( + scene = scene, + options = UMachineOptions(), + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + machine.analyze(listOf(getMethod(methodName))) + } + + assertTrue(states.isEmpty()) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + observer.limitations.single().reason, ) } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt index 31b1d18b6c..486ec151b3 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt @@ -18,22 +18,22 @@ class InputArrays : TsMethodTestRunner() { val method = getMethod("inputArrayOfNumbers") discoverProperties, TsTestValue>( method = method, - { _, r -> r is TsTestValue.TsException }, { x, r -> r as TsTestValue.TsNumber - val x0 = x.values[0] as TsTestValue.TsNumber - (r eq 1) && (x0 eq 1) + val firstElement = x.values.firstOrNull() + (r eq 1) && firstElement is TsTestValue.TsNumber && (firstElement eq 1) }, { x, r -> r as TsTestValue.TsNumber - val x0 = x.values[0] as TsTestValue.TsNumber - (r eq 2) && (x0 neq 1) + val firstElement = x.values.firstOrNull() + val firstElementIsUndefined = firstElement == null || firstElement is TsTestValue.TsUndefined + (r eq -1) && firstElementIsUndefined + }, + { x, r -> + r as TsTestValue.TsNumber + val firstElement = x.values.firstOrNull() + (r eq 2) && firstElement is TsTestValue.TsNumber && (firstElement neq 1) }, - invariants = arrayOf( - { _, r -> - r !is TsTestValue.TsNumber || (r neq -1) - } - ) ) } @@ -62,7 +62,7 @@ class InputArrays : TsMethodTestRunner() { discoverProperties, TsTestValue.TsNumber>( method = method, { x, r -> (r eq 1) && x.values[0].value }, - { x, r -> (r eq -1) && !x.values[0].value }, + { x, r -> (r eq -1) && x.values.firstOrNull()?.value != true }, ) } @@ -73,29 +73,33 @@ class InputArrays : TsMethodTestRunner() { method = method, // TODO exception { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() (r.values == x.values) && firstElement is TsTestValue.TsNumber && (firstElement eq 1.1) }, { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() val firstElementCondition = firstElement !is TsTestValue.TsNumber || (firstElement neq 1.1) - val secondElement = x.values[1] + val secondElement = x.values.getOrNull(1) val secondElementCondition = secondElement is TsTestValue.TsBoolean && secondElement.value (r.values == x.values) && firstElementCondition && secondElementCondition }, { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() val firstElementCondition = firstElement !is TsTestValue.TsNumber || (firstElement neq 1.1) - val secondElement = x.values[1] + val secondElement = x.values.getOrNull(1) val secondElementCondition = secondElement !is TsTestValue.TsBoolean || !secondElement.value - val thirdElement = x.values[2] - val thirdElementCondition = thirdElement is TsTestValue.TsUndefined + val thirdElement = x.values.getOrNull(2) + val thirdElementCondition = thirdElement == null || thirdElement is TsTestValue.TsUndefined + val resultMatches = r.values.size == x.values.size && r.values.zip(x.values).all { (actual, expected) -> + actual == expected || + actual is TsTestValue.TsClass && expected is TsTestValue.TsClass && actual.name == expected.name + } - (r.values == x.values) && firstElementCondition && secondElementCondition && thirdElementCondition + resultMatches && firstElementCondition && secondElementCondition && thirdElementCondition } ) } @@ -127,8 +131,11 @@ class InputArrays : TsMethodTestRunner() { r is TsTestValue.TsNull && value is TsTestValue.TsNumber && (value eq 1) }, { x, r -> - val value = x.values[0] - (r == value) && (value !is TsTestValue.TsNumber || (value neq 1)) + val value = x.values.firstOrNull() + val valueIsNotOne = value !is TsTestValue.TsNumber || (value neq 1) + val resultMatches = if (value == null) r is TsTestValue.TsUndefined else r == value + + valueIsNotOne && resultMatches }, ) } @@ -155,7 +162,7 @@ class InputArrays : TsMethodTestRunner() { resultCondition && fstCondition && sndCondition }, { x, y, r -> - val fst = x.values[0] + val fst = x.values.firstOrNull() val condition = fst !is TsTestValue.TsNumber || (fst neq 1) condition && r is TsTestValue.TsArray<*> && r.values == x.values }, 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 2f950432f0..09f91dc9de 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -1,5 +1,7 @@ package org.usvm.util +import io.ksmt.expr.KBitVec16Value +import io.ksmt.expr.KBitVec32Value import io.ksmt.expr.KFpValue import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType @@ -309,8 +311,27 @@ open class TsTestStateResolver( private fun resolveAllocatedString( ref: UConcreteHeapRef, ): TsTestValue.TsString { - val value = ctx.getStringConstantValue(ref) ?: run { - error("String constant not found for ref: $ref") + ctx.getStringConstantValue(ref)?.let { value -> + return TsTestValue.TsString(value) + } + + val charactersExpr = finalStateMemory.read(mkFieldLValue(ctx.addressSort, ref, "value")) + val characters = evaluateInModel(charactersExpr) as UConcreteHeapRef + val lengthExpr = finalStateMemory.read(mkArrayLengthLValue(characters, STRING_CHARACTER_ARRAY_TYPE)) + val length = (model.eval(lengthExpr) as KBitVec32Value).intValue + val value = buildString(length) { + repeat(length) { index -> + val codeUnitExpr = finalStateMemory.read( + mkArrayIndexLValue( + sort = ctx.bv16Sort, + ref = characters, + index = ctx.mkSizeExpr(index), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + val codeUnit = (model.eval(codeUnitExpr) as KBitVec16Value).shortValue.toInt() and 0xffff + append(codeUnit.toChar()) + } } return TsTestValue.TsString(value) } diff --git a/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts b/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts new file mode 100644 index 0000000000..8fe9120e3b --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class ArrayConstructorEtsIr { + callableArrayCreatesHolesAndAcceptsWrites(): number { + const values = Array(3); + const startsWithUndefined = values[0] === undefined; + values[0] = 4; + values[1] = 5; + values[2] = 6; + + return (startsWithUndefined ? 1 : 0) * 10000 + + values.length * 1000 + + values[0] * 100 + + values[1] * 10 + + values[2]; + } + + callableArraySupportsNestedAssignment(): number { + const result: number[][] = Array(2); + result[0] = [4, 5]; + result[1] = [6]; + + return result.length * 100 + result[0][1] * 10 + result[1][0]; + } + + negativeLength(): any[] { + return Array(-1); + } + + fractionalLength(): any[] { + return Array(1.5); + } + + nanLength(): any[] { + return Array(NaN); + } + + infiniteLength(): any[] { + return Array(Infinity); + } + + oversizedLength(): any[] { + return Array(17); + } + + fullFillInitializesSparseNumberArray(): number { + const values = new Array(3).fill(7); + return values.length * 1000 + values[0] * 100 + values[1] * 10 + values[2]; + } + + fullFillInitializesSparseBooleanArray(): number { + const values = new Array(3).fill(true); + return values.length * 1000 + + (values[0] ? 1 : 0) * 100 + + (values[1] ? 1 : 0) * 10 + + (values[2] ? 1 : 0); + } + + shadowedArrayIsNotModeled(): number { + const Array = (arrayLength?: number): any[] => [arrayLength]; + return Array(2)[0]; + } +} diff --git a/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts b/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts new file mode 100644 index 0000000000..22515dc2ed --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts @@ -0,0 +1,38 @@ +export function scalarValuesAreNotArrays(): boolean { + return !Array.isArray(42) + && !Array.isArray(true) + && !Array.isArray('value') + && !Array.isArray(undefined) + && !Array.isArray(null); +} + +export function ordinaryArraysAreArrays(): boolean { + return Array.isArray([]) && Array.isArray([1, 2, 3]); +} + +export function stringArrayIsArray(value: string[]): boolean { + return Array.isArray(value); +} + +export function nestedArrayIsArray(value: number[][]): boolean { + return Array.isArray(value); +} + +export function missingArgumentIsNotArray(): boolean { + // @ts-expect-error Arity test deliberately omits the first argument. + return !Array.isArray(); +} + +export function nullParameterIsNotArray(value: null): boolean { + return Array.isArray(value); +} + +export function undefinedParameterIsNotArray(value: undefined): boolean { + return Array.isArray(value); +} + +declare const FakeArray: any; + +export function nonBuiltinIsArrayFallsBack(): boolean { + return FakeArray.isArray([]); +} diff --git a/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts new file mode 100644 index 0000000000..eaa43d30fb --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts @@ -0,0 +1,59 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class ArrayMutationEtsIr { + pushSupportedArities(): number { + const values: number[] = []; + const zero = values.push(); + const one = values.push(4); + const three = values.push(5, 6, 7); + return zero * 10000 + one * 1000 + three * 100 + values[0] * 10 + values[3]; + } + + pushTooManyArguments(): number { + const values: number[] = []; + return values.push(1, 2, 3, 4); + } + + fillNegativeFractionAndInfinity(values: number[]): number { + values.fill(9, -2.8, Infinity); + return values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + fillNaNAndFraction(values: number[]): number { + values.fill(7, NaN, 1.9); + return values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + reverseDense(values: number[]): number { + const result = values.reverse(); + return result[0] * 1000 + result[1] * 100 + result[2] * 10 + result[3]; + } + + unshiftDense(values: number[]): number { + const length = values.unshift(7, 8); + return length * 10000 + values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + sliceDense(values: number[]): number { + const result = values.slice(-3.8, Infinity); + return result.length * 10000 + + result[0] * 1000 + + result[1] * 100 + + result[2] * 10 + + values.length; + } + + concatDense(values: number[], other: number[]): number { + const result = values.concat(other); + return result.length * 10000 + + result[0] * 1000 + + result[1] * 100 + + result[2] * 10 + + result[3]; + } + + sparseReverseUsesResidual(): number[] { + return new Array(2).reverse(); + } +} diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts new file mode 100644 index 0000000000..194705cfa1 --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class DateEtsIr { + fixedClock(): number { + return Date.now() - new Date().getTime(); + } + + epochYear(): number { + return new Date(0).getUTCFullYear(); + } + + isoEpoch(): string { + return new Date(0).toISOString(); + } + + leapDay(): number { + const date = new Date(Date.UTC(2000, 1, 29, 12, 34, 56, 789)); + return date.getUTCMonth() * 100 + date.getUTCDate(); + } + + overflow(): number { + const date = new Date(Date.UTC(2024, -1, 0, 25, -1, 0, 0)); + return date.getUTCFullYear() * 10_000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate(); + } + + utcNoArguments(): number { + return Date.UTC(); + } + + utcUndefinedYear(): number { + return Date.UTC(undefined); + } + + utcYearOnly(): number { + return Date.UTC(2020); + } + + utcExplicitUndefined(): number { + return Date.UTC(2020, undefined); + } + + invalidTimezoneOffset(): number { + return new Date(NaN).getTimezoneOffset(); + } + + fractionalTimestamps(): number { + return new Date(1.9).getTime() * 10 + new Date(-1.9).getTime(); + } + + fractionalUtcDay(): number { + return new Date(Date.UTC(2024, 0, 1.9)).getUTCDate(); + } + + anyAliasValueOf(): number { + const date: any = new Date(123); + return date.valueOf(); + } + + anyAliasGetTime(): number { + const date: any = new Date(456); + return date.getTime(); + } + + utcMinuteSetters(): number { + const date = new Date(0); + const minutesTimestamp = date.setUTCMinutes(61, -2, 1_001); + const millisecondsTimestamp = date.setUTCMilliseconds(-1); + return minutesTimestamp + millisecondsTimestamp; + } + + setter(): number { + const date = new Date(0); + const timestamp = date.setUTCFullYear(2000, 1, 29); + return timestamp + date.getUTCDate(); + } + + symbolicRoundTrip(timestamp: number): number { + if (timestamp === 123.9) { + return new Date(timestamp).valueOf(); + } + if (timestamp === -123.9) { + return new Date(timestamp).valueOf(); + } + + return new Date(timestamp).valueOf(); + } +} diff --git a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs new file mode 100644 index 0000000000..0f11f6fd0e --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { DateModels, DateValue } from "../../../main/resources/org/usvm/machine/call/models/DateModels.ts"; + +function modeled(...arguments_) { + const receiver = new DateValue(); + DateModels.construct(receiver, arguments_.length, 0, ...arguments_, 0, 0, 0, 0, 0, 0, 0); + return receiver; +} + +function nativeResult(operation, arguments_) { + const date = new Date(...arguments_); + const result = operation(date); + return [result, date.getTime()]; +} + +function modelResult(operation, arguments_) { + const date = modeled(...arguments_); + const result = operation(date); + return [result, date.timestamp]; +} + +const timestamps = [ + -8_640_000_000_000_000, + -2_208_988_800_001, + -1.9, + -1, + -0, + 0, + 1.9, + 951_827_696_789, + 1_710_067_696_789, + 8_640_000_000_000_000, + 8_640_000_000_000_001, + NaN, +]; + +const getters = [ + ["getDate", (date) => date.getUTCDate(), DateModels.getDate], + ["getDay", (date) => date.getUTCDay(), DateModels.getDay], + ["getFullYear", (date) => date.getUTCFullYear(), DateModels.getFullYear], + ["getHours", (date) => date.getUTCHours(), DateModels.getHours], + ["getMilliseconds", (date) => date.getUTCMilliseconds(), DateModels.getMilliseconds], + ["getMinutes", (date) => date.getUTCMinutes(), DateModels.getMinutes], + ["getMonth", (date) => date.getUTCMonth(), DateModels.getMonth], + ["getSeconds", (date) => date.getUTCSeconds(), DateModels.getSeconds], + ["getTime", (date) => date.getTime(), DateModels.getTime], +]; + +for (const timestamp of timestamps) { + const receiver = modeled(timestamp); + const native = new Date(timestamp); + for (const [name, nativeGetter, modelGetter] of getters) { + assert.deepEqual(modelGetter(receiver), nativeGetter(native), `${name}(${timestamp})`); + } +} + +const componentCases = [ + [1970, 0], + [99, 11, 31, 23, 59, 59, 999], + [2000, 1, 29, 12, 34, 56, 789], + [1900, 1, 29], + [2024, -14, 0, -2, 120, -90, 2_001], + [2024, 0, 1.9], + [-1, 0, 1], + [275760, 8, 13], +]; + +const utcBoundaryCases = [ + [], + [undefined], + [2020], + [2020, undefined], +]; + +for (const arguments_ of utcBoundaryCases) { + assert.deepEqual( + DateModels.utc(arguments_.length, ...arguments_, 0, 0, 0, 0, 0, 0, 0), + Date.UTC(...arguments_), + `UTC(${arguments_.join(",")})`, + ); +} + +for (const arguments_ of componentCases) { + assert.deepEqual( + modeled(...arguments_).timestamp, + new Date(Date.UTC(...arguments_)).getTime(), + `constructor(${arguments_.join(",")})`, + ); + assert.deepEqual( + DateModels.utc(arguments_.length, ...arguments_, 0, 0, 0, 0, 0, 0, 0), + Date.UTC(...arguments_), + `UTC(${arguments_.join(",")})`, + ); +} + +const setterCases = [ + ["setDate", [0], (date, args) => date.setUTCDate(...args), (date, args) => DateModels.setDate(date, ...args)], + ["setFullYear", [2024, 13, 0], (date, args) => date.setUTCFullYear(...args), (date, args) => DateModels.setFullYear(date, args.length, ...args, 0, 0)], + ["setHours", [-1, 70, -80, 1_500], (date, args) => date.setUTCHours(...args), (date, args) => DateModels.setHours(date, args.length, ...args, 0, 0, 0)], + ["setMilliseconds", [-1], (date, args) => date.setUTCMilliseconds(...args), (date, args) => DateModels.setMilliseconds(date, ...args)], + ["setMinutes", [61, -2, 1_001], (date, args) => date.setUTCMinutes(...args), (date, args) => DateModels.setMinutes(date, args.length, ...args, 0, 0)], + ["setMonth", [-13, 40], (date, args) => date.setUTCMonth(...args), (date, args) => DateModels.setMonth(date, args.length, ...args, 0)], + ["setSeconds", [-61, 2_000], (date, args) => date.setUTCSeconds(...args), (date, args) => DateModels.setSeconds(date, args.length, ...args, 0)], + ["setTime", [-1.9], (date, args) => date.setTime(...args), (date, args) => DateModels.setTime(date, ...args)], + ["setUTCMilliseconds", [-1], (date, args) => date.setUTCMilliseconds(...args), (date, args) => DateModels.setUTCMilliseconds(date, ...args)], + ["setUTCMinutes", [61, -2, 1_001], (date, args) => date.setUTCMinutes(...args), (date, args) => DateModels.setUTCMinutes(date, args.length, ...args, 0, 0)], +]; + +for (const timestamp of [-1, 0, 951_827_696_789]) { + for (const [name, args, nativeSetter, modelSetter] of setterCases) { + assert.deepEqual( + modelResult((date) => modelSetter(date, args), [timestamp]), + nativeResult((date) => nativeSetter(date, args), [timestamp]), + `${name} from ${timestamp}`, + ); + } +} + +for (const timestamp of [-62_167_219_200_000, -1, 0, 253_402_300_799_999]) { + const receiver = modeled(timestamp); + assert.equal(DateModels.toISOString(receiver), new Date(timestamp).toISOString()); +} + +assert.deepEqual( + DateModels.getTimezoneOffset(modeled(NaN)), + new Date(NaN).getTimezoneOffset(), + "getTimezoneOffset(NaN)", +); + +console.log("DateModels comparison passed"); diff --git a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts new file mode 100644 index 0000000000..74c2ee6d86 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -0,0 +1,49 @@ +class ForeignFields { + name: number = 17; + message: boolean = true; +} + +export class ErrorEtsIr { + static name(): string { + return new Error("expected message").name; + } + + static message(): string { + return new Error("expected message").message; + } + + static throwError(): number { + throw new Error("expected message"); + } + + static overwrittenName(): string { + const error = new Error("expected message"); + error.name = "CustomError"; + return error.name; + } + + static overwrittenMessage(): string { + const error = new Error("before"); + error.message = "after"; + return error.message; + } + + static anyErrorMessage(): string { + const error: any = new Error("aliased message"); + return error.message; + } + + static anyForeignNameComparison(): number { + const value: any = new ForeignFields(); + return value.name === 17 ? 1 : -1; + } + + static anyForeignMessageComparison(): number { + const value: any = new ForeignFields(); + return value.message === true ? 1 : -1; + } + + static callbackMessage(): string { + return new Error((() => "x") as any).message; + } +} diff --git a/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts b/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts index 3c9c667e55..adad1b361e 100644 --- a/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts +++ b/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts @@ -11,6 +11,10 @@ declare class ExternalModels { } export class EtsIrSemanticModelCalls { + namespaceReceiverCanBeIgnored(): number { + return Math.abs(-2); + } + pureArgumentAndReturn(): number { return ExternalModels.absolute(-42); } diff --git a/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts b/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts new file mode 100644 index 0000000000..6dbbbce8f4 --- /dev/null +++ b/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +const globalValues = [11, 22]; +globalValues[0.9] = 7; + +export class GlobalArrayAssignmentBoundary { + readSecondValue(): number { + return globalValues[1]; + } +} diff --git a/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts new file mode 100644 index 0000000000..433c8737de --- /dev/null +++ b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts @@ -0,0 +1,194 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class NumericBoundarySemantics { + typedArrayCannotStoreMissingValue(): number { + const source = [11]; + const destination = [22]; + destination[0] = source[2]; + return destination[0]; + } + + bitwiseNaN(): number { + return (0 / 0) >> 1; + } + + bitwiseInfinity(): number { + return (1 / 0) >> 1; + } + + bitwiseUint32Wrap(): number { + return 4294967297 | 0; + } + + bitwiseFraction(): number { + return 1.9 | 0; + } + + bitwiseNegativeFraction(): number { + return -1.9 | 0; + } + + bitwiseNotNaN(): number { + return ~(0 / 0); + } + + bitwiseAndInfinity(): number { + return (1 / 0) & 7; + } + + bitwiseXorInfinity(): number { + return (1 / 0) ^ 7; + } + + leftShiftMasksCount(): number { + return 1 << 33; + } + + rightShiftTruncates(): number { + return -3.9 >> 1; + } + + unsignedRightShift(): number { + return -1.9 >>> 0; + } + + bitwiseNegativeZero(): number { + return -0 | 0; + } + + missingNumericArrayProperties(): number { + const values = [11, 22]; + let result = 0; + if (values[0 / 0] === undefined) result += 1; + if (values[1 / 0] === undefined) result += 2; + if (values[0.9] === undefined) result += 4; + if (values[-1] === undefined) result += 8; + if (values[2] === undefined) result += 16; + if (values[4294967297] === undefined) result += 32; + return result; + } + + negativeZeroArrayIndex(): number { + const values = [11, 22]; + return values[-0]; + } + + fractionalArrayWrite(): number { + const values = [11, 22]; + values[0.9] = 7; + return values[1]; + } + + negativeArrayWrite(): number { + const values = [11, 22]; + values[-1] = 7; + return values[1]; + } + + outOfRangeArrayWrite(): number { + const values = [11, 22]; + values[2] = 7; + return values[1]; + } + + negativeZeroArrayWrite(): number { + const values = [11, 22]; + values[-0] = 7; + return values[0]; + } + + chainedArrayRead(): number { + const values = [11, 22]; + const indices = [0, 1]; + return values[indices[1]]; + } + + chainedMissingArrayRead() { + const values = [11, 22]; + const indices = [0, 1]; + return values[indices[2]]; + } + + chainedMissingArrayWrite(): number { + const values = [11, 22]; + const indices = [0, 1]; + values[indices[2]] = 7; + return values[0]; + } + + stringArrayIndexRead(): number { + const values = [11, 22]; + return values["1"]; + } + + objectArrayIndexRead() { + const values = [11, 22]; + return values[{}]; + } + + stringZeroIndex(): string { + return "A😀B"[0]; + } + + stringNegativeZeroIndex(): string { + return "A😀B"[-0]; + } + + stringHighSurrogateIndex(): string { + return "A😀B"[1]; + } + + stringLowSurrogateIndex(): string { + return "A😀B"[2]; + } + + missingNumericStringProperties(): number { + const value = "abc"; + let result = 0; + if (value[0.9] === undefined) result += 1; + if (value[0 / 0] === undefined) result += 2; + if (value[1 / 0] === undefined) result += 4; + if (value[4294967297] === undefined) result += 8; + if (value[3] === undefined) result += 16; + return result; + } + + newArrayInfinity(): number { + return new Array(1 / 0).length; + } + + newArrayUint32Overflow(): number { + return new Array(4294967296).length; + } + + newArrayFraction(): number { + return new Array(1.9).length; + } + + newArrayBeyondModelCapacity(): number { + return new Array(17).length; + } + + newArrayNegativeZero(): number { + return new Array(-0).length; + } + + assignInvalidLength(): number { + const values = [11, 22]; + values.length = 1 / 0; + return values.length; + } + + assignLengthBeyondModelCapacity(): number { + const values = [11, 22]; + values.length = 17; + return values.length; + } + + assignNegativeZeroLength(): number { + const values = [11, 22]; + values.length = -0; + return values.length; + } +} diff --git a/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts new file mode 100644 index 0000000000..a87c3beed7 --- /dev/null +++ b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts @@ -0,0 +1,208 @@ +export function absNegativeZero(): number { + return Math.abs(-0); +} + +export function absNaN(): number { + return Math.abs(0 / 0); +} + +export function absNegativeInfinity(): number { + return Math.abs(-1 / 0); +} + +export function minSignedZero(): number { + return Math.min(0, -0); +} + +export function minNaN(): number { + return Math.min(1, 0 / 0, 2); +} + +export function minNoArguments(): number { + return Math.min(); +} + +export function maxSignedZero(): number { + return Math.max(-0, 0); +} + +export function maxInfinity(): number { + return Math.max(-1 / 0, 1 / 0, 42); +} + +export function maxNoArguments(): number { + return Math.max(); +} + +export function roundNegativeHalf(): number { + return Math.round(-0.5); +} + +export function roundPositiveHalf(): number { + return Math.round(0.5); +} + +export function roundNegativeOneHalf(): number { + return Math.round(-1.5); +} + +export function roundNaN(): number { + return Math.round(0 / 0); +} + +export function ceilNegativeFraction(): number { + return Math.ceil(-0.25); +} + +export function ceilInfinity(): number { + return Math.ceil(1 / 0); +} + +export function absNoArguments(): number { + // @ts-expect-error Arity test deliberately omits the first argument. + return Math.abs(); +} + +export function absExtraArgument(): number { + // @ts-expect-error Arity test deliberately supplies an extra argument. + return Math.abs(-2, true); +} + +export function integerPositiveZero(): boolean { + return Number.isInteger(0); +} + +export function integerNegativeZero(): boolean { + return Number.isInteger(-0); +} + +export function integerFraction(): boolean { + return Number.isInteger(1.5); +} + +export function integerNaN(): boolean { + return Number.isInteger(0 / 0); +} + +export function integerInfinity(): boolean { + return Number.isInteger(1 / 0); +} + +export function integerLargeBinary64(): boolean { + return Number.isInteger(9007199254740992); +} + +export function integerBoolean(): boolean { + return Number.isInteger(true); +} + +export function integerNoArguments(): boolean { + // @ts-expect-error Arity test deliberately omits the first argument. + return Number.isInteger(); +} + +export function integerExtraArgument(): boolean { + // @ts-expect-error Arity test deliberately supplies an extra argument. + return Number.isInteger(2, true); +} + +export function symbolicAbs(value: number): number { + return Math.abs(value) < 0 ? 0 : 1; +} + +export function symbolicInteger(value: number): number { + return Number.isInteger(value) ? 1 : 0; +} + +export function unsupportedAbsDomain(): number { + // @ts-expect-error Domain fallback deliberately supplies a non-number. + return Math.abs(true); +} + +export function floorNegativeFraction(): number { + return Math.floor(-1.25); +} + +export function floorNegativeZero(): number { + return Math.floor(-0); +} + +export function floorInfinity(): number { + return Math.floor(1 / 0); +} + +export function truncNegativeFraction(): number { + return Math.trunc(-1.75); +} + +export function truncNegativeSmall(): number { + return Math.trunc(-0.25); +} + +export function truncNaN(): number { + return Math.trunc(0 / 0); +} + +export function sqrtFour(): number { + return Math.sqrt(4); +} + +export function sqrtNegative(): number { + return Math.sqrt(-1); +} + +export function sqrtNegativeZero(): number { + return Math.sqrt(-0); +} + +export function sqrtInfinity(): number { + return Math.sqrt(1 / 0); +} + +export function finiteNumber(): boolean { + return Number.isFinite(42); +} + +export function finiteNaN(): boolean { + return Number.isFinite(0 / 0); +} + +export function finiteInfinity(): boolean { + return Number.isFinite(1 / 0); +} + +export function finiteBoolean(): boolean { + return Number.isFinite(true); +} + +export function nanNaN(): boolean { + return Number.isNaN(0 / 0); +} + +export function nanNumber(): boolean { + return Number.isNaN(42); +} + +export function nanBoolean(): boolean { + return Number.isNaN(true); +} + +export function safeIntegerMaximum(): boolean { + return Number.isSafeInteger(9007199254740991); +} + +export function safeIntegerAboveMaximum(): boolean { + return Number.isSafeInteger(9007199254740992); +} + +export function safeIntegerFraction(): boolean { + return Number.isSafeInteger(1.5); +} + +export function safeIntegerInfinity(): boolean { + return Number.isSafeInteger(1 / 0); +} + +export function safeIntegerBoolean(): boolean { + return Number.isSafeInteger(true); +} diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts new file mode 100644 index 0000000000..151ed2d73e --- /dev/null +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -0,0 +1,247 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class SequenceEtsIr { + arrayIndexOfUsesStrictEqualityAndOffsets(): number { + const values = [NaN, 2, 3, 2]; + return values.indexOf(NaN) * 1000 + + values.indexOf(2, 2) * 100 + + values.indexOf(3, -Infinity) * 10 + + values.indexOf(2, Infinity); + } + + arrayIncludesUsesSameValueZero(): boolean { + return [NaN].includes(NaN); + } + + numericDefaultSearchFallsBack(): boolean { + return [0].includes(0); + } + + arrayOffsetsAreNormalized(): number { + const values = [1, 2, 3, 2]; + return values.indexOf(2, 1.9) * 100 + + values.indexOf(2, -1.9) * 10 + + (values.includes(1, NaN) ? 1 : 0); + } + + emptyArraysDoNotMatch(): boolean { + return ![].includes(1) && [].indexOf(1) === -1; + } + + arrayExplicitUndefinedOffset(): number { + return [1].indexOf(1, undefined); + } + + arrayLastIndexOfHandlesOffsets(): number { + const values = [1, 2, 1]; + return values.lastIndexOf(1) * 100 + + values.lastIndexOf(1, -2) * 10 + + values.lastIndexOf(1, -Infinity); + } + + arrayLastIndexOfExplicitUndefined(): number { + return [1, 2, 1].lastIndexOf(1, undefined); + } + + arrayLastIndexOfBeforeStart(): number { + return [1].lastIndexOf(1, -2); + } + + arraySearchReturnsPositiveZero(): number { + const first = [1].indexOf(1, -0); + const last = [1].lastIndexOf(1, -0.9); + let result = 0; + if (1 / first === Infinity) result += 1; + if (1 / last === Infinity) result += 2; + return result; + } + + numericHoleDoesNotMatchZero(): number { + const values = new Array(1); + return values.indexOf(0); + } + + numericHoleDoesNotIncludeUndefined(): boolean { + const values = new Array(1); + return values.includes(undefined); + } + + numericHoleWithSymbolicSearch(value: number): boolean { + const values = new Array(1); + return values.includes(value); + } + + explicitUndefinedArrayIncludesUndefined(): boolean { + const values = [undefined]; + return values.includes(undefined); + } + + explicitUndefinedArrayIndexOfUndefined(): number { + const values = [undefined]; + return values.indexOf(undefined); + } + + stringCharAtHandlesBounds(): string { + return "abc".charAt(1) + "abc".charAt(-1); + } + + stringIndexOfHandlesOffsetsAndEmptySearch(): number { + return "ababa".indexOf("ba", 2) * 100 + + "abc".indexOf("", Infinity) * 10 + + "abc".indexOf("a", -Infinity); + } + + stringIncludesHandlesNaNPosition(): boolean { + return "abc".includes("a", NaN) && !"abc".includes("a", Infinity); + } + + stringExplicitUndefinedPositions(): number { + return "abc".indexOf("a", undefined) + ("abc".charAt(undefined) === "a" ? 1 : 0); + } + + stringSymbolicPosition(position: number): number { + if (position === 0) return "ababa".indexOf("ba", position); + if (position === 2) return "ababa".indexOf("ba", position); + if (position === 4) return "ababa".indexOf("ba", position); + return -100; + } + + symbolicCharAt(position: number): string { + return "abc".charAt(position); + } + + symbolicCharAtMatches(position: number): boolean { + if (position !== 0 && position !== 1) return true; + return "ab".charAt(position) === (position === 0 ? "a" : "b"); + } + + stringSubstringBounds(): boolean { + return "abc".substring(2, 1) === "b" + && "abc".substring(NaN, Infinity) === "abc" + && "abc".substring(-Infinity, -1) === "" + && "abc".substring(1.9, undefined) === "bc" + && "\ud83d\ude00".substring(1, 0).charCodeAt(0) === 0xd83d; + } + + stringTrimWhitespace(): boolean { + return "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff".trim() === "" + && " \ud83d\ude00 x \u00a0".trim() === "\ud83d\ude00 x" + && " \u0085\u180e\u200b ".trim() === "\u0085\u180e\u200b" + && " \tvalue \n".trimStart() === "value \n" + && " \tvalue \n".trimEnd() === " \tvalue"; + } + + stringCharCodeAtHandlesBounds(): number { + const outside = "AZ".charCodeAt(2); + return "AZ".charCodeAt(1) + (outside !== outside ? 1 : 0); + } + + stringStartsAndEndsWithHandlePositions(): boolean { + return "abc".startsWith("b", 1) + && "abc".startsWith("", Infinity) + && "abc".endsWith("b", 2) + && "abc".endsWith("c", undefined); + } + + stringLastIndexOfHandlesPositions(): number { + return "ababa".lastIndexOf("ba") * 100 + + "ababa".lastIndexOf("ba", 2) * 10 + + "ababa".lastIndexOf("", Infinity); + } + + stringSlicePreservesUtf16(): string { + return "A😀Z".slice(1, 3); + } + + stringModelsUseValueEquality(): boolean { + const whole = "abc".slice(0); + const independent = "abc".slice(0); + const empty = "abc".slice(0, 0); + + return whole === "abc" + && whole == "abc" + && whole === independent + && empty === "" + && empty === "x".slice(1) + && empty !== "a" + && whole + "d" === "abcd"; + } + + stringEqualityPreservesReferenceIdentity(): boolean { + const alias: number[] = [1]; + return alias === alias && alias !== [1]; + } + + stringValueEqualityThroughAny(): boolean { + const whole: any = "abc".slice(0); + const empty: any = "abc".slice(0, 0); + return whole === "abc" && whole == "abc" && empty === ""; + } + + stringTruthinessUsesLength(): boolean { + const empty = "abc".slice(0, 0); + const nonEmpty = "abc".slice(0, 1); + return !empty && !"" && !!nonEmpty; + } + + nonStringTruthinessDoesNotReadStringStorage(): boolean { + const object = { value: 1 }; + const array: number[] = []; + return !undefined && !null && !!object && !!array; + } + + mixedObjectEqualityDoesNotReadStringStorage(index: number): boolean { + if (index !== 0 && index !== 1) return true; + const dummy = "x"; + const values: any[] = [{}, {}]; + return values[index] !== ""; + } + + stringCapitalizeAscii(): string { + const value = "hELLO"; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + stringCapitalizeEmpty(): string { + const value = ""; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + stringCapitalizeNonAscii(): string { + const value = "éCOLE"; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + legacyArrayPush(): number { + return [1].push(2); + } + + legacyArrayFill(): number[] { + return [1].fill(2); + } + + legacyArrayUnshift(): number { + return [1].unshift(2); + } + + legacyArrayJoin(): string { + return [1, 2].join("-"); + } + + legacyArraySlice(): number[] { + return [1, 2].slice(1); + } + + legacyArrayConcat(): number[] { + return [1].concat([2]); + } + + legacyArrayReverse(): number[] { + return [1, 2].reverse(); + } + + legacyArrayToString(): string { + return [1, 2].toString(); + } +} diff --git a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts new file mode 100644 index 0000000000..685f56c3a4 --- /dev/null +++ b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts @@ -0,0 +1,42 @@ +// @ts-nocheck +export class StringReplaceAllEtsIr { + literalMatches(): string { + return "aaaa/aba".replaceAll("aa", "X"); + } + + emptySearch(): string { + return "A😀".replaceAll("", "-"); + } + + substitutions(): string { + return "aba".replaceAll("a", "$$:$&:$`:$':$1"); + } + + noMatches(): string { + return "abc".replaceAll("x", "$&"); + } + + emptyReceiver(): string { + return "".replaceAll("", "$$"); + } + + replacementIsNotSearchedAgain(): string { + return "aa".replaceAll("a", "aa"); + } + + numericSearchIsResidual(): string { + return "123".replaceAll(1, "x"); + } + + numericReplacementIsResidual(): string { + return "abc".replaceAll("a", 1); + } + + callbackReplacementIsResidual(): string { + return "aa".replaceAll("a", (match: string) => match); + } + + callableSearchIsResidual(): string { + return "aa".replaceAll((match: string) => match, "x"); + } +} diff --git a/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts b/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts new file mode 100644 index 0000000000..6745cccdae --- /dev/null +++ b/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts @@ -0,0 +1,14 @@ +class Error { + name: string = "ShadowError"; + message: string; + + constructor(message: string) { + this.message = message; + } +} + +export class UserDefinedErrorEtsIr { + static name(): string { + return new Error("shadow message").name; + } +} diff --git a/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts b/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts new file mode 100644 index 0000000000..8c3ccc4691 --- /dev/null +++ b/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts @@ -0,0 +1,3 @@ +// @ts-nocheck + +export const Array = (length?: number): any[] => [99]; diff --git a/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts b/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts new file mode 100644 index 0000000000..524567379e --- /dev/null +++ b/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +import { Array } from "./exported"; + +export class ImportedArrayShadow { + callImportedArray(): number { + return Array(2)[0]; + } +}