diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 700801642a..f8dedeef7c 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -6,7 +6,7 @@ object Versions { const val clikt = "5.0.0" const val detekt = "1.23.7" const val ini4j = "0.5.4" - const val jacodb = "ddb127d9ef" + const val jacodb = "873f8e99faf3f1e4736935be96ce1c319985c574" const val juliet = "1.3.2" const val junit = "5.9.3" const val kotlin = "2.1.0" diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts index bcc0ba431b..07dcd5170b 100644 --- a/usvm-ts-calls/build.gradle.kts +++ b/usvm-ts-calls/build.gradle.kts @@ -26,10 +26,13 @@ val toolStatus = providers.exec { workingDir(rootProject.projectDir) commandLine("git", "status", "--porcelain", "--untracked-files=all") }.standardOutput.asText.map(String::trim) +val nativeFrontendRevision = providers.gradleProperty("nativeFrontendRevision") + .orElse("bundled:${Versions.jacodb}") val generateBuildMetadata = tasks.register("generateBuildMetadata") { inputs.property("toolRevision", toolRevision) inputs.property("toolStatus", toolStatus) + inputs.property("nativeFrontendRevision", nativeFrontendRevision) outputs.dir(generatedBuildMetadataDirectory) doLast { @@ -39,7 +42,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=${nativeFrontendRevision.get()}\n", + Charsets.UTF_8, + ) } } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt new file mode 100644 index 0000000000..2a04d11c7f --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageCell.kt @@ -0,0 +1,202 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import org.usvm.ts.pbt.model.encodeToUtf8SafeString +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +internal data class CallsCoverageCellIdentity( + val schemaVersion: Int, + val toolRevision: String, + val nativeFrontendRevision: String, + val sourceManifest: String, + val projectIndex: Int, + val functionIndex: Int, + val profile: CallsExperimentProfile, + val seed: Long, + val budgetMillis: Long, + val solverLimitMillis: Long, + val candidateCap: Int, +) + +@Serializable +internal data class CallsCoverageReplayEvent( + val index: Int, + val replay: CallsCoverageReplayResult? = null, + val error: String? = null, +) + +@Serializable +internal data class CallsCoverageCheckpoint( + val searchBudgetMillis: Long, + val emittedCandidates: Int, + val replayedCandidates: Int, + val coveredStatements: Int, + val coveredIfArms: Int, +) + +@Serializable +internal data class CallsCoverageCellResult( + val identity: CallsCoverageCellIdentity, + val search: CallsCoverageSearchResult, + val replayErrors: Int, + val completionMismatches: Int, + val checkpoints: List, +) + +internal fun runCoverageCell(args: List) { + require(args.size == 9) { "coverage-cell requires 9 arguments" } + val manifestPath = Path.of(args[0]).toRealPath() + val manifest = CallsExperimentJson.decodeManifest(Files.readString(manifestPath)) + val projectIndex = args[1].toInt() + val functionIndex = args[2].toInt() + val profile = CallsExperimentProfile.valueOf(args[3]) + val seed = args[4].toLong() + val output = Path.of(args[5]).toAbsolutePath().normalize() + val budgetMillis = args[6].toLong() + val solverLimitMillis = args[7].toLong() + val candidateCap = args[8].toInt() + val project = manifest.projects[projectIndex] + val function = project.functions[functionIndex] + require(seed in manifest.seeds) { "Seed is outside the frozen source manifest" } + require(budgetMillis > 0 && solverLimitMillis in 1..budgetMillis && candidateCap > 0) + require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { "ETS_FRONTEND_SCRIPT must be unset" } + + val identity = CallsCoverageCellIdentity( + schemaVersion = 1, + toolRevision = CallsBuildIdentity.toolRevision, + nativeFrontendRevision = CallsBuildIdentity.nativeFrontendRevision, + sourceManifest = manifestPath.toString(), + projectIndex = projectIndex, + functionIndex = functionIndex, + profile = profile, + seed = seed, + budgetMillis = budgetMillis, + solverLimitMillis = solverLimitMillis, + candidateCap = candidateCap, + ) + require(Regex("[0-9a-f]{40}").matches(identity.toolRevision)) { "Coverage build must be clean" } + + Files.createDirectories(output) + val identityPath = output.resolve("identity.json") + val expectedIdentity = encode(identity) + if (Files.exists(identityPath)) { + require(Files.readString(identityPath).trim() == expectedIdentity) { "Cell identity changed" } + } else { + require(Files.list(output).use { entries -> entries.findAny().isEmpty }) { "Output directory is not empty" } + atomicWrite(identityPath, expectedIdentity) + } + require(!Files.exists(output.resolve("result.json"))) { "Cell already completed" } + + val candidatePath = output.resolve("candidates.jsonl") + val searchPath = output.resolve("search.json") + val search = if (Files.exists(searchPath)) { + CallsExperimentJson.json.decodeFromString(Files.readString(searchPath)) + } else { + require(!Files.exists(candidatePath)) { "Interrupted search requires investigation before restart" } + val request = CallsCoverageSearchRequest( + sourceRoot = manifestPath.parent.resolve(project.sourceRoot).toRealPath(), + project = project, + function = function, + profile = profile, + frozenModelIds = manifest.modelSet.ids, + expectedNativeFrontendRevision = identity.nativeFrontendRevision, + seed = seed, + budget = budgetMillis.milliseconds, + solverQueryLimit = solverLimitMillis.milliseconds, + candidateCap = candidateCap, + onCandidate = { candidate -> appendDurably(candidatePath, encode(candidate)) }, + ) + val result = CurrentTsCallsCoverageEngine().search(request) + val persistedCandidates = readLines(candidatePath) + require(result.candidates == persistedCandidates) { "Persisted candidate journal differs from search result" } + atomicWrite(searchPath, encode(result)) + result + } + + val replayPath = output.resolve("replays.jsonl") + val events = readLines(replayPath).toMutableList() + require(events.map(CallsCoverageReplayEvent::index) == events.indices.toList()) { + "Replay journal has missing or duplicate indices" + } + if (search.candidates.isNotEmpty()) { + OriginalTypeScriptCoverageReplayer( + sourceRoot = manifestPath.parent.resolve(project.sourceRoot), + function = function, + ).use { replayer -> + search.candidates.indices.drop(events.size).forEach { index -> + val candidate = search.candidates[index] + val replay = runCatching { + replayer.replay(inputs = candidate.inputs, timeoutMillis = REPLAY_TIMEOUT_MILLIS) + } + val event = CallsCoverageReplayEvent( + index = index, + replay = replay.getOrNull(), + error = replay.exceptionOrNull()?.let { error -> error.message ?: error::class.java.name }, + ) + appendDurably(replayPath, encode(event)) + events += event + } + } + } + + val checkpoints = listOf(30_000L, 60_000L, 120_000L).filter { it <= budgetMillis }.map { checkpoint -> + val indices = search.candidates.indices.filter { index -> + search.candidates[index].emittedAtMillis <= checkpoint + } + val replayed = indices.mapNotNull { index -> events[index].replay } + CallsCoverageCheckpoint( + searchBudgetMillis = checkpoint, + emittedCandidates = indices.size, + replayedCandidates = replayed.size, + coveredStatements = replayed.flatMap { it.coveredStatementKeys }.toSet().size, + coveredIfArms = replayed.flatMap { it.coveredIfArmKeys }.toSet().size, + ) + } + val result = CallsCoverageCellResult( + identity = identity, + search = search, + replayErrors = events.count { it.error != null }, + completionMismatches = events.indices.count { index -> + events[index].replay?.completion != null && + events[index].replay?.completion != search.candidates[index].completion + }, + checkpoints = checkpoints, + ) + atomicWrite(output.resolve("result.json"), encode(result)) + println(encode(result)) +} + +private inline fun readLines(path: Path): List = if (Files.exists(path)) { + Files.readAllLines(path).filter(String::isNotBlank).map { line -> + CallsExperimentJson.json.decodeFromString(line) + } +} else { + emptyList() +} + +private inline fun encode(value: T): String = CallsExperimentJson.json.encodeToUtf8SafeString(value) + +private fun appendDurably(path: Path, line: String) { + val bytes = ByteBuffer.wrap((line + "\n").toByteArray(Charsets.UTF_8)) + FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND).use { channel -> + while (bytes.hasRemaining()) channel.write(bytes) + channel.force(true) + } +} + +private fun atomicWrite(path: Path, contents: String) { + val temporary = path.resolveSibling("${path.fileName}.tmp") + Files.writeString(temporary, contents + "\n") + FileChannel.open(temporary, StandardOpenOption.READ).use { it.force(true) } + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE) +} + +private const val REPLAY_TIMEOUT_MILLIS = 20_000L diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt new file mode 100644 index 0000000000..01d5b983bf --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageReplay.kt @@ -0,0 +1,250 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import org.usvm.ts.pbt.backend.CoverageScope +import org.usvm.ts.pbt.backend.PropertyCoverageRequest +import org.usvm.ts.pbt.backend.PropertyRunConfiguration +import org.usvm.ts.pbt.backend.PropertyRunStatus +import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.fastcheck.FastCheckBackend +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.model.encodeToUtf8SafeString +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.time.TimeSource + +@Serializable +internal data class CallsCoverageReplayObservation( + val inputs: List, + val completion: CallsCoverageCompletion, + val errorName: String? = null, +) + +@Serializable +internal data class CallsCoverageReplayResult( + val coveredStatementKeys: Set, + val coveredIfArmKeys: Set, + val supportedIfArmKeys: Set, + val completion: CallsCoverageCompletion, + val diagnostics: List, + val replayElapsedMillis: Long, +) + +/** Replays one generated input in a fresh Node.js process and checks its exact transport value. */ +internal class OriginalTypeScriptCoverageReplayer( + sourceRoot: Path, + private val function: CallsFunctionCase, +) : AutoCloseable { + private val sourceRoot = sourceRoot.toRealPath() + private val workspace = Files.createTempDirectory("usvm-ts-calls-coverage-") + private val overlayRoot = workspace.resolve("source-overlay") + private val observationPath = workspace.resolve("observation.json") + private val wrapperName = ".usvm-coverage-${UUID.randomUUID()}.ts" + private val usesSentinel = function.inputs.isEmpty() + private val allowedFiles: Set + private val backend: FastCheckBackend + + init { + try { + require(function.entryPoint.executionKind == org.usvm.ts.pbt.model.ExecutionKind.SYNC) { + "Coverage replay requires a synchronous entry point" + } + val source = this.sourceRoot.resolve(function.sourceFile).normalize() + require(source.startsWith(this.sourceRoot) && Files.isRegularFile(source)) { + "Coverage entry source is outside its frozen root" + } + val closure = TypeScriptSourceInspector.localSourceClosure(sourceRoot = this.sourceRoot, source = source) + require(closure.reasonCode == null) { closure.diagnostic ?: "Unsupported local source closure" } + allowedFiles = closure.files.toSet() + + Files.createDirectory(overlayRoot) + Files.newDirectoryStream(this.sourceRoot).use { entries -> + entries.forEach { entry -> + Files.createSymbolicLink(overlayRoot.resolve(entry.fileName), entry.toAbsolutePath()) + } + } + Files.writeString( + overlayRoot.resolve(wrapperName), + coverageWrapper( + sourceFile = function.sourceFile, + exportName = function.entryPoint.exportName, + observationPath = observationPath, + dropSentinel = usesSentinel, + ), + ) + backend = FastCheckBackend(sourceRoots = listOf(overlayRoot, this.sourceRoot)) + } catch (error: Throwable) { + deleteWorkspace() + throw error + } + } + + fun replay(inputs: List, timeoutMillis: Long): CallsCoverageReplayResult { + require(timeoutMillis > 0) + require(inputs.size == function.inputs.size) + Files.deleteIfExists(observationPath) + + val started = TimeSource.Monotonic.markNow() + val result = backend.run( + property = PropertyDefinition( + id = PropertyId("calls.coverage.replay"), + inputs = if (usesSentinel) { + listOf(PropertyInput(name = "__usvmCoverageSentinel", domain = BooleanDomain)) + } else { + function.inputs + }, + predicate = TypeScriptEntryPoint(module = wrapperName, exportName = "coverageProbe"), + ), + configuration = PropertyRunConfiguration( + seed = 0, + numRuns = 1, + timeoutMillis = timeoutMillis, + examples = listOf(if (usesSentinel) listOf(JsConcreteValue.Boolean(true)) else inputs), + coverageRequest = PropertyCoverageRequest(scopes = setOf(CoverageScope.SOURCE_UNDER_TEST)), + ), + ) + val elapsedMillis = started.elapsedNow().inWholeMilliseconds + require(result.status == PropertyRunStatus.SUCCESS && result.numRuns == 1) { + "Exact coverage replay did not complete once: $result" + } + val observation = CallsExperimentJson.json.decodeFromString( + Files.readString(observationPath), + ) + require(observation.inputs == inputs) { + "Concrete replay received a different tagged input than the symbolic candidate" + } + val coverage = requireNotNull(result.coverage) { "Concrete replay produced no c8 coverage artifact" } + val statementKeys = linkedSetOf() + val ifArmKeys = linkedSetOf() + val supportedIfArmKeys = linkedSetOf() + + coverage.files.forEach { file -> + val relative = relativeSourcePath(file.path) ?: return@forEach + if (relative !in allowedFiles) return@forEach + + collectHits( + relative = relative, + file = file, + statementKeys = statementKeys, + ifArmKeys = ifArmKeys, + supportedIfArmKeys = supportedIfArmKeys, + ) + } + + return CallsCoverageReplayResult( + coveredStatementKeys = statementKeys, + coveredIfArmKeys = ifArmKeys, + supportedIfArmKeys = supportedIfArmKeys, + completion = observation.completion, + diagnostics = coverage.diagnostics.map { diagnostic -> "${diagnostic.code}: ${diagnostic.message}" }, + replayElapsedMillis = elapsedMillis, + ) + } + + private fun relativeSourcePath(path: String): String? { + val sourcePath = Path.of(path).toAbsolutePath().normalize() + val root = when { + sourcePath.startsWith(overlayRoot) -> overlayRoot + sourcePath.startsWith(sourceRoot) -> sourceRoot + else -> return null + } + + return root.relativize(sourcePath).toString().replace('\\', '/') + } + + override fun close() = deleteWorkspace() + + private fun deleteWorkspace() { + Files.walk(workspace).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } +} + +private fun collectHits( + relative: String, + file: SourceFileCoverage, + statementKeys: MutableSet, + ifArmKeys: MutableSet, + supportedIfArmKeys: MutableSet, +) { + file.statements.filter { statement -> statement.hits > 0 }.forEach { statement -> + statementKeys += "$relative:statement:${statement.location.stableKey()}" + } + + file.branches.filter { branch -> branch.type == "if" && branch.arms.size == 2 }.forEach { branch -> + branch.arms.forEachIndexed { index, arm -> + val key = "$relative:if:${branch.location.stableKey()}:$index" + supportedIfArmKeys += key + if (arm.hits > 0) ifArmKeys += key + } + } +} + +private fun org.usvm.ts.pbt.backend.SourceRange.stableKey(): String = + "${start.line}:${start.column}-${end.line}:${end.column}" + +private fun coverageWrapper( + sourceFile: String, + exportName: String, + observationPath: Path, + dropSentinel: Boolean, +): String { + val moduleName = CallsExperimentJson.json.encodeToUtf8SafeString("./$sourceFile") + val exportKey = CallsExperimentJson.json.encodeToUtf8SafeString(exportName) + val outputPath = CallsExperimentJson.json.encodeToUtf8SafeString(observationPath.toString()) + val sentinelExpression = if (dropSentinel) "args.slice(1)" else "args" + + return """ + import { writeFileSync } from 'node:fs'; + import * as targetModule from $moduleName; + + const callable = targetModule[$exportKey]; + if (typeof callable !== 'function') throw new Error('Coverage export is not a function'); + + function encodeValue(value: unknown): unknown { + if (value === undefined) return { kind: 'undefined' }; + if (value === null) return { kind: 'null' }; + if (typeof value === 'boolean') return { kind: 'boolean', value }; + if (typeof value === 'string') return { kind: 'string', value }; + if (typeof value === 'number') { + if (Number.isNaN(value)) return { kind: 'number', value: 'nan' }; + if (value === Infinity) return { kind: 'number', value: 'positive-infinity' }; + if (value === -Infinity) return { kind: 'number', value: 'negative-infinity' }; + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value, false); + const bits = view.getBigUint64(0, false).toString(16).padStart(16, '0'); + return { kind: 'number', value: 'finite', bits }; + } + if (Array.isArray(value)) return { kind: 'array', elements: value.map(encodeValue) }; + throw new Error('Unsupported replay argument type'); + } + + export function coverageProbe(...args: unknown[]): boolean { + const actualArgs = $sentinelExpression; + let completion: 'RETURNED' | 'THREW' = 'RETURNED'; + let errorName: string | undefined; + try { + const result = callable(...actualArgs); + if (result !== null && (typeof result === 'object' || typeof result === 'function') + && typeof (result as { then?: unknown }).then === 'function') { + throw new Error('Synchronous coverage export returned an awaitable value'); + } + } catch (error: unknown) { + completion = 'THREW'; + errorName = error instanceof Error ? error.name : typeof error; + } + writeFileSync($outputPath, JSON.stringify({ inputs: actualArgs.map(encodeValue), completion, errorName }), 'utf8'); + + return true; + } + """.trimIndent() + "\n" +} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageSearch.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageSearch.kt new file mode 100644 index 0000000000..87a7b710bf --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCoverageSearch.kt @@ -0,0 +1,259 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.Serializable +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsStmt +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.machine.TsAnalysisStopReason +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.TsUnknownCallModelSelection +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState +import org.usvm.statistics.UMachineObserver +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.contains +import org.usvm.ts.pbt.model.encodeToUtf8SafeString +import kotlin.time.Duration +import kotlin.time.TimeSource + +@Serializable +internal data class CallsCoverageCandidate( + val emittedAtMillis: Long, + val inputs: List, + val newSymbolicStatements: Int, + val completion: CallsCoverageCompletion, +) + +@Serializable +internal enum class CallsCoverageCompletion { + RETURNED, + THREW, +} + +@Serializable +internal enum class CallsCoverageSearchStatus { + EXHAUSTED, + TIMEOUT, + STOPPED, + UNSUPPORTED, + UNMAPPED, + AMBIGUOUS, + TOOL_ERROR, +} + +@Serializable +internal data class CallsCoverageSearchResult( + val status: CallsCoverageSearchStatus, + val candidates: List, + val selectedStates: Int, + val extractionFailures: List, + val candidateCapReached: Boolean, + val preparationElapsedMillis: Long, + val searchElapsedMillis: Long, + val unsupportedCall: Boolean = false, + val engineFailed: Boolean = false, + val runtimeLimited: Boolean = false, + val diagnostic: String? = null, +) + +internal data class CallsCoverageSearchRequest( + val sourceRoot: java.nio.file.Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val profile: CallsExperimentProfile, + val frozenModelIds: Set, + val expectedNativeFrontendRevision: String, + val seed: Long, + val budget: Duration, + val solverQueryLimit: Duration, + val candidateCap: Int, + val onCandidate: (CallsCoverageCandidate) -> Unit = {}, +) { + init { + require(budget > Duration.ZERO) + require(solverQueryLimit > Duration.ZERO && solverQueryLimit <= budget) + require(candidateCap > 0) + } +} + +/** Runs one function without a return target and records completed coverage-novel paths. */ +internal class CurrentTsCallsCoverageEngine( + private val sourceEngine: CurrentTsCallsSymbolicEngine = CurrentTsCallsSymbolicEngine(), +) { + fun search(request: CallsCoverageSearchRequest): CallsCoverageSearchResult { + val preparationStarted = TimeSource.Monotonic.markNow() + val preparation = sourceEngine.prepareFunction( + CallsFunctionPreflightRequest( + sourceRoot = request.sourceRoot, + project = request.project, + function = request.function, + expectedNativeFrontendRevision = request.expectedNativeFrontendRevision, + ), + ) + val preparationElapsedMillis = preparationStarted.elapsedNow().inWholeMilliseconds + if (preparation is CallsFunctionPreparation.Rejected) { + return CallsCoverageSearchResult( + status = preparation.status.toCoverageStatus(), + candidates = emptyList(), + selectedStates = 0, + extractionFailures = emptyList(), + candidateCapReached = false, + preparationElapsedMillis = preparationElapsedMillis, + searchElapsedMillis = 0, + diagnostic = "${preparation.reasonCode}: ${preparation.diagnostic}", + ) + } + + preparation as CallsFunctionPreparation.Eligible + val symbolicInputs = CallsSymbolicInputs( + inputs = request.function.inputs, + bindings = preparation.inputBindings, + lexicalEnvironment = preparation.lexicalEnvironment, + ) + val candidates = mutableListOf() + val extractionFailures = mutableListOf() + val searchStarted = TimeSource.Monotonic.markNow() + val observer = CompletedCoverageCandidateObserver( + method = preparation.method, + inputDomains = request.function.inputs, + symbolicInputs = symbolicInputs, + candidateCap = request.candidateCap, + searchStarted = searchStarted, + onCandidate = { candidate -> + request.onCandidate(candidate) + candidates += candidate + }, + onExtractionFailure = extractionFailures::add, + ) + val modelSelection = if (request.profile.usesFrozenModels) { + TsUnknownCallModelSelection.Only(request.frozenModelIds) + } else { + TsUnknownCallModelSelection.Only(emptySet()) + } + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(CALLS_PATH_SELECTION_STRATEGY), + stateCollectionStrategy = StateCollectionStrategy.COVERED_NEW, + randomSeed = request.seed, + timeout = request.budget, + solverTimeout = request.solverQueryLimit, + solverType = SolverType.Z3, + stopOnCoverage = CALLS_STOP_ON_COVERAGE, + stopOnTargetsReached = false, + throwExceptionOnStepFailure = true, + ) + val machineResult = runCatching { + TsMachine( + scene = preparation.scene, + options = machineOptions, + tsOptions = TsOptions( + unknownCallModelSelection = modelSelection, + unknownCallFallback = request.profile.fallback, + ), + initialStateConfigurator = symbolicInputs::initialize, + initialParameterSortOverride = symbolicInputs::sortOverride, + machineObserver = observer, + ).use { machine -> machine.analyzeWithMetadata(methods = listOf(preparation.method)) } + } + val searchElapsedMillis = searchStarted.elapsedNow().inWholeMilliseconds + val outcome = machineResult.getOrNull() + val status = when { + machineResult.isFailure -> CallsCoverageSearchStatus.TOOL_ERROR + outcome?.timedOut == true -> CallsCoverageSearchStatus.TIMEOUT + outcome?.stopReason == TsAnalysisStopReason.EXHAUSTED -> CallsCoverageSearchStatus.EXHAUSTED + else -> CallsCoverageSearchStatus.STOPPED + } + + return CallsCoverageSearchResult( + status = status, + candidates = candidates, + selectedStates = observer.selectedStates, + extractionFailures = extractionFailures, + candidateCapReached = observer.capReached, + preparationElapsedMillis = preparationElapsedMillis, + searchElapsedMillis = searchElapsedMillis, + unsupportedCall = outcome?.unsupportedCall == true, + engineFailed = outcome?.engineFailed == true, + runtimeLimited = outcome?.runtimeLimited == true, + diagnostic = machineResult.exceptionOrNull()?.let { error -> error.message ?: error::class.java.name }, + ) + } +} + +private class CompletedCoverageCandidateObserver( + private val method: EtsMethod, + private val inputDomains: List, + private val symbolicInputs: CallsSymbolicInputs, + private val candidateCap: Int, + private val searchStarted: TimeSource.Monotonic.ValueTimeMark, + private val onCandidate: (CallsCoverageCandidate) -> Unit, + private val onExtractionFailure: (String) -> Unit, +) : UMachineObserver { + private val entryStatements: Set = method.cfg.stmts.toSet() + private val coveredStatements = hashSetOf() + private val inputKeys = hashSetOf() + + var selectedStates: Int = 0 + private set + + var capReached: Boolean = false + private set + + override fun onStateTerminated(state: TsState, stateReachable: Boolean) { + if (!stateReachable) return + + val completion = when (val result = state.methodResult) { + is TsMethodResult.Success.RegularCall -> { + if (result.method != method) return + CallsCoverageCompletion.RETURNED + } + is TsMethodResult.TsException -> CallsCoverageCompletion.THREW + else -> return + } + val pathStatements = state.pathNode.allStatements.filterTo(hashSetOf()) { statement -> + statement in entryStatements + } + val newStatements = pathStatements.count { statement -> statement !in coveredStatements } + if (newStatements == 0) return + + if (selectedStates >= candidateCap) { + capReached = true + return + } + selectedStates++ + val candidate = runCatching { + val inputs = symbolicInputs.resolve(state) + require(inputs.zip(inputDomains).all { (value, input) -> value in input.domain }) { + "Extracted input is outside the frozen domain" + } + val key = CallsExperimentJson.json.encodeToUtf8SafeString(inputs) + if (inputKeys.add(key)) { + CallsCoverageCandidate( + emittedAtMillis = searchStarted.elapsedNow().inWholeMilliseconds, + inputs = inputs, + newSymbolicStatements = newStatements, + completion = completion, + ) + } else { + null + } + }.getOrElse { error -> + onExtractionFailure(error.message ?: error::class.java.name) + null + } + + if (candidate != null) { + onCandidate(candidate) + coveredStatements += pathStatements + } + } +} + +private fun CallsSymbolicStatus.toCoverageStatus(): CallsCoverageSearchStatus = when (this) { + CallsSymbolicStatus.UNSUPPORTED, CallsSymbolicStatus.RUNTIME_LIMITATION -> CallsCoverageSearchStatus.UNSUPPORTED + CallsSymbolicStatus.UNMAPPED -> CallsCoverageSearchStatus.UNMAPPED + CallsSymbolicStatus.AMBIGUOUS -> CallsCoverageSearchStatus.AMBIGUOUS + else -> CallsCoverageSearchStatus.TOOL_ERROR +} 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..5aabcb08cb 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 @@ -3,13 +3,15 @@ package org.usvm.ts.calls import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable 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 +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption @@ -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, @@ -240,26 +247,36 @@ internal object CallsExperimentJson { fun decodeManifest(value: String): CallsExperimentManifest = json.decodeFromString(value) - fun encodeManifest(manifest: CallsExperimentManifest): String = json.encodeToString(manifest) + fun encodeManifest(manifest: CallsExperimentManifest): String = json.encodeToUtf8SafeString(manifest) } 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( private val symbolicEngine: CallsSymbolicEngine, private val targetReplayer: CallsTargetReplayer, private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, + private val eventRecordWriter: ((Path, CallsRawRecord) -> Unit)? = null, ) { fun run( manifest: CallsExperimentManifest, @@ -297,6 +314,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, ) } } @@ -370,6 +388,10 @@ internal class CallsExperimentRunner( target = target, seed = seed, profile = profile, + appendUnknownCall = { event -> + val writer = eventRecordWriter + if (writer == null) append(rawOutput, event) else writer(rawOutput, event) + }, ) append(rawOutput, result) @@ -386,20 +408,35 @@ 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 eventWrites = CallsEventWriteTracker() + val unknownCallSink = callsUnknownCallEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ) + val runtimeLimitationSink = callsRuntimeLimitationEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ) 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 = eventWrites.track(unknownCallSink), + runtimeLimitationEventSink = eventWrites.track(runtimeLimitationSink), ), ) + eventWrites.requireComplete() val replay = symbolic.inputs?.let { inputs -> targetReplayer.replay( sourceRoots = listOf(sourceRoot), @@ -418,6 +455,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, profile = profile, seed = seed, symbolicStatus = symbolic.status, @@ -440,13 +478,30 @@ internal class CallsExperimentRunner( private fun append(path: Path, record: CallsRawRecord) { Files.writeString( path, - CallsExperimentJson.json.encodeToString(record) + "\n", + CallsExperimentJson.json.encodeToUtf8SafeString(record) + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND, ) } } +private class CallsEventWriteTracker { + private var firstFailure: Throwable? = null + + fun track(sink: (T) -> Unit): (T) -> Unit = { event -> + runCatching { sink(event) }.getOrElse { error -> + if (firstFailure == null) firstFailure = error + throw error + } + } + + fun requireComplete() { + firstFailure?.let { error -> + throw IllegalStateException("Could not persist a Calls event", error) + } + } +} + internal data class CallsValidatedRawResults( val metadata: CallsRunMetadata, val results: List, @@ -488,7 +543,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/CallsExperimentCli.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt index fda9021f1c..1229d14d8d 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperimentCli.kt @@ -1,7 +1,7 @@ package org.usvm.ts.calls -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import java.nio.file.Files import java.nio.file.Path @@ -10,6 +10,7 @@ fun main(args: Array) { when (args.first()) { "run" -> runExperiment(args.drop(1)) + "coverage-cell" -> runCoverageCell(args.drop(1)) "replay-witness" -> replayWitness(args.drop(1)) "summarize" -> summarize(args.drop(1)) else -> error(usage()) @@ -47,7 +48,7 @@ internal fun replayWitness(args: List) { selector = selector, ) - val encoded = CallsExperimentJson.json.encodeToString(result) + val encoded = CallsExperimentJson.json.encodeToUtf8SafeString(result) System.out.appendLine(encoded) } @@ -82,12 +83,13 @@ private fun summarize(args: List) { prettyPrint = true } output.parent?.let(Files::createDirectories) - Files.writeString(output, json.encodeToString(summary) + "\n") + Files.writeString(output, json.encodeToUtf8SafeString(summary) + "\n") } private fun usage(): String = """ Usage: calls run + calls coverage-cell calls replay-witness calls summarize """.trimIndent() 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..b7517f2ed6 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt @@ -0,0 +1,236 @@ +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, + sourceByFile: Map, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): CallsIrReadinessIssue? { + val pending = ArrayDeque().apply { addLast(method) } + val visited = hashSetOf() + + while (pending.isNotEmpty()) { + val currentMethod = pending.removeFirst() + if (!visited.add(currentMethod)) continue + + currentMethod.irReadinessIssue( + source = sourceByFile.getValue(currentMethod.signature.enclosingClass.file), + admittedLexicalEnvironment = admittedLexicalEnvironment, + )?.let { issue -> return issue } + + currentMethod.projectCallees(graph = graph, sourceFiles = sourceByFile.keys) + .forEach(pending::addLast) + currentMethod.projectStaticInitializers(graph = graph, sourceFiles = sourceByFile.keys) + .forEach(pending::addLast) + } + + return null +} + +private fun EtsMethod.projectCallees( + graph: TsGraph, + sourceFiles: Set, +): Sequence = cfg.stmts.asSequence() + .flatMap { statement -> + runCatching { graph.callees(statement).toList() } + .getOrDefault(emptyList()) + .asSequence() + } + .filter { callee -> callee.signature.enclosingClass.file in sourceFiles } + +private fun EtsMethod.projectStaticInitializers( + graph: TsGraph, + sourceFiles: Set, +): Sequence = cfg.stmts.asSequence() + .flatMap { it.walkEntities().asSequence() } + .filterIsInstance() + .map { it.field.enclosingClass } + .filter { it.file in sourceFiles } + .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/CallsSourceProject.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt new file mode 100644 index 0000000000..c5f6bce4b2 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt @@ -0,0 +1,107 @@ +package org.usvm.ts.calls + +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.loadEtsProjectFromIR +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector +import java.nio.file.Files +import java.nio.file.Path +import kotlin.time.Duration.Companion.seconds + +internal sealed interface CallsSourceProject { + data class Loaded( + val scene: EtsScene, + val entryModule: String, + val sourceByFile: Map, + ) : CallsSourceProject + + data class Unsupported(val issue: CallsIrReadinessIssue) : CallsSourceProject +} + +internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSourceProject { + val closure = TypeScriptSourceInspector.localSourceClosure(sourceRoot = sourceRoot, source = source) + if (closure.reasonCode != null) { + return CallsSourceProject.Unsupported( + issue = CallsIrReadinessIssue( + reasonCode = CallsSymbolicPreflightReasonCode.valueOf(requireNotNull(closure.reasonCode)), + diagnostic = requireNotNull(closure.diagnostic), + ), + ) + } + + val entryModule = sourceRoot.relativize(source).toString() + require(entryModule in closure.files) { "Dependency closure does not contain its entry source" } + if (closure.files.size == 1) { + val entry = loadSingleCallsSource(source) + return CallsSourceProject.Loaded( + scene = EtsScene(projectFiles = listOf(entry)), + entryModule = source.fileName.toString(), + sourceByFile = mapOf(entry.signature to Files.readString(source)), + ) + } + + val sources = closure.files.associateWith { relative -> + val path = sourceRoot.resolve(relative).normalize() + require(path.startsWith(sourceRoot) && path.toRealPath().startsWith(sourceRoot.toRealPath())) { + "Dependency source escapes its frozen root: $relative" + } + Files.readString(path) + } + val temporaryProject = Files.createTempDirectory("usvm-calls-source-closure-") + try { + for (relative in closure.files) { + val copy = temporaryProject.resolve(relative) + Files.createDirectories(copy.parent) + Files.copy(sourceRoot.resolve(relative), copy) + } + val generatedIr = generateEtsIR( + keepPartialOutputOnFailure = false, + projectPath = temporaryProject, + isProject = true, + loadEntrypoints = false, + timeout = 30.seconds, + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = try { + loadEtsProjectFromIR(projectFilesPath = generatedIr, sdkFilesPath = null) + } finally { + generatedIr.toFile().deleteRecursively() + } + val sourceByFile = scene.projectFiles.associate { file -> + val filePath = Path.of(file.signature.fileName) + val relative = if (filePath.isAbsolute) { + temporaryProject.relativize(filePath).toString() + } else { + filePath.toString() + } + file.signature to requireNotNull(sources[relative]) { + "Frontend source identity is outside the inspected closure: ${file.signature}" + } + } + require(sourceByFile.size == closure.files.size) { "Frontend omitted or duplicated a dependency source" } + + return CallsSourceProject.Loaded(scene = scene, entryModule = entryModule, sourceByFile = sourceByFile) + } finally { + temporaryProject.toFile().deleteRecursively() + } +} + +private fun loadSingleCallsSource(source: Path): EtsFile { + val generatedIr = generateEtsIR( + keepPartialOutputOnFailure = false, + projectPath = source, + useArkAnalyzerTypeInference = 1, + provider = EtsIrProvider.TS_FRONTEND, + ) + + return try { + Files.newInputStream(generatedIr).use { stream -> EtsFileDto.loadFromJson(stream).toEtsFile() } + } finally { + Files.deleteIfExists(generatedIr) + } +} 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..23bfa160e4 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 @@ -3,19 +3,24 @@ package org.usvm.ts.calls import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.backend.PropertyFailureKind 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 org.usvm.ts.pbt.model.encodeToUtf8SafeString import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path @@ -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,8 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName = entryPoint.exportName, marker = marker, resultPath = resultPath, + targetMode = target.mode, + sourceArgumentCount = inputs.size, ), ) val replayRoots = sourceRoots.mapIndexed { index, root -> @@ -163,7 +197,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 +259,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 +344,8 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName: String, marker: String, resultPath: Path, + targetMode: CallsSourceTargetMode, + sourceArgumentCount: Int, ): String = """ import { writeFileSync } from 'node:fs'; import * as targetModule from ${jsString("./$sourcePath")}; @@ -308,7 +363,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { let invocation: 'returned' | 'threw' = 'returned'; let caught: unknown; try { - const result = callable(...args); + const result = callable(...args.slice(0, $sourceArgumentCount)); if (result !== null && (typeof result === 'object' || typeof result === 'function') && typeof (result as { then?: unknown }).then === 'function') { void Promise.resolve(result).catch(() => undefined); @@ -318,7 +373,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; @@ -330,7 +387,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } """.trimIndent() + "\n" - private fun jsString(value: String): String = CallsExperimentJson.json.encodeToString(value) + private fun jsString(value: String): String = CallsExperimentJson.json.encodeToUtf8SafeString(value) private data class ResolvedTarget( val sourceRootIndex: Int, @@ -344,6 +401,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..dae470c898 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt @@ -0,0 +1,356 @@ +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.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.mkArrayIndexLValue +import org.usvm.util.mkFieldLValue +import org.usvm.util.mkRegisterStackLValue +import org.usvm.util.mkStringFromCodeUnits + +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 length: UExpr = makeSymbolicPrimitive(sizeSort) + val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + val stringRef = mkStringFromCodeUnits(length = length, codeUnits = codeUnits) + 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..9af0ae2733 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt @@ -0,0 +1,190 @@ +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.jacodb.ets.model.EtsStmt +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 record = CallsRuntimeLimitationRecord( + cell = cell, + eventIndex = eventIndex, + callSite = event.statement.toCallsUnknownCallSite(), + 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 decision = event.decision + + return CallsUnknownCallRecord( + cell = this, + eventIndex = eventIndex, + callSite = event.callSite.toCallsUnknownCallSite(), + 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, + ) +} + +private fun EtsStmt.toCallsUnknownCallSite(): CallsUnknownCallSite { + val stmtLocation = location + val origin = stmtLocation.origin + val containingMethod = stmtLocation.method.signature + + return CallsUnknownCallSite( + sourcePath = containingMethod.enclosingClass.file.fileName, + statementIndex = stmtLocation.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) }, + ) +} + +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..05cbfe76c5 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,54 +1,136 @@ 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 -import org.jacodb.ets.utils.loadEtsFileAutoConvert 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.TsRuntimeFeatureLimitationReason +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 data class CallsFunctionPreflightRequest( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val expectedNativeFrontendRevision: String, +) + +internal sealed interface CallsFunctionPreparation { + data class Eligible( + val scene: EtsScene, + val method: EtsMethod, + val inputBindings: List, + val lexicalEnvironment: EtsLexicalEnvironmentBinding?, + ) : CallsFunctionPreparation + + data class Rejected( + val status: CallsSymbolicStatus, + val reasonCode: CallsSymbolicPreflightReasonCode, + val diagnostic: String, + ) : CallsFunctionPreparation +} + +internal enum class CallsSymbolicPreflightStatus { + ELIGIBLE, + UNSUPPORTED, + UNMAPPED, + AMBIGUOUS, + TOOL_ERROR, +} + +internal enum class CallsSymbolicPreflightReasonCode { + INPUT_DOMAIN_UNSUPPORTED, + IMPORTED_CALLEES_UNSUPPORTED, + TOP_LEVEL_INITIALIZATION_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 preparedFunctions = mutableMapOf() + private val loadedSources = mutableMapOf() + private var preparedTarget: Pair? = null + 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,11 +140,46 @@ 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, + ) + } + + internal fun prepareFunction(request: CallsFunctionPreflightRequest): CallsFunctionPreparation { + preparedFunctions[request]?.takeIf { + verifiedProjects[request.sourceRoot] == request.project.revision + }?.let { return it } + + val preparation = runCatching { prepareFunctionSupported(request) }.getOrElse { error -> + CallsFunctionPreparation.Rejected( + status = CallsSymbolicStatus.TOOL_ERROR, + reasonCode = CallsSymbolicPreflightReasonCode.FRONTEND_OR_PREFLIGHT_ERROR, + diagnostic = error.message ?: error::class.java.name, + ) + } + preparedFunctions[request] = preparation + + return preparation + } + @Suppress("LongMethod") - private fun searchSupported( - request: CallsSymbolicSearchRequest, - startedAt: TimeSource.Monotonic.ValueTimeMark, - ): CallsSymbolicSearchResult { + private fun prepareFunctionSupported(request: CallsFunctionPreflightRequest): CallsFunctionPreparation { + callsSymbolicInputPreflight(request.function.inputs)?.let { diagnostic -> + return CallsFunctionPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, + diagnostic = diagnostic, + ) + } + verifyGitCheckoutOnce( checkout = request.sourceRoot, expectedRevision = request.project.revision, @@ -73,50 +190,95 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { 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( + val sourceKey = LoadedSourceKey( + sourceRoot = request.sourceRoot, + sourceFile = request.function.sourceFile, + revision = request.project.revision, + ) + val loaded = loadedSources.getOrPut(sourceKey) { + loadCallsSourceProject(sourceRoot = request.sourceRoot, source = source) + } + if (loaded is CallsSourceProject.Unsupported) { + return CallsFunctionPreparation.Rejected( status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "Single-file symbolic replay does not support imported project callees", + reasonCode = loaded.issue.reasonCode, + diagnostic = loaded.issue.diagnostic, ) } - val scene = EtsScene(projectFiles = listOf(sourceFile)) - val frontendEntryPoint = request.function.entryPoint.copy( - module = requireNotNull(source.fileName).toString(), - ) + + loaded as CallsSourceProject.Loaded + val scene = loaded.scene + val frontendEntryPoint = request.function.entryPoint.copy(module = loaded.entryModule) val propertyManifest = PropertyManifest( - propertyId = "calls.mapping", + propertyId = "calls.coverage.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( + return CallsFunctionPreparation.Rejected( status = mapping.predicate.status.toSymbolicStatus(), - startedAt = startedAt, + reasonCode = mapping.predicate.status.toPreflightReasonCode(), 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 mappedTarget = mapping.predicate.targets.single() + val lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment + if (lexicalEnvironment != null && lexicalEnvironment.parameter.type !is EtsLexicalEnvType) { + return CallsFunctionPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, + diagnostic = "Mapped lexical environment does not have a lexical-environment type", ) } - val statementEntry = sourceStatementEntry(method, request.target) - if (statementEntry == null) { - return result( + + val unsupportedCaptures = (lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType) + ?.closures + ?.map { closure -> closure.name } + ?.filterNot { closure -> closure in SUPPORTED_BUILTIN_CAPTURES } + .orEmpty() + if (unsupportedCaptures.isNotEmpty()) { + return CallsFunctionPreparation.Rejected( status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "EtsIR origins do not prove entry before evaluation of the frozen source statement", + 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), + sourceByFile = loaded.sourceByFile, + admittedLexicalEnvironment = lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType, + )?.let { issue -> + return CallsFunctionPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = issue.reasonCode, + diagnostic = issue.diagnostic, + ) + } + + return CallsFunctionPreparation.Eligible( + scene = scene, + method = method, + inputBindings = mappedTarget.bindings.inputs, + lexicalEnvironment = lexicalEnvironment, + ) + } + + @Suppress("LongMethod") + private fun searchSupported( + request: CallsSymbolicSearchRequest, + startedAt: TimeSource.Monotonic.ValueTimeMark, + prepared: CallsTargetPreparation.Eligible, + ): CallsSymbolicSearchResult { + val scene = prepared.scene + val method = prepared.method + val targetObserver = prepared.targetObservation.createObserver(method) + val modelSelection = if (request.profile.usesFrozenModels) { TsUnknownCallModelSelection.Only(request.frozenModelIds) } else { @@ -127,6 +289,7 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { stateCollectionStrategy = StateCollectionStrategy.REACHED_TARGET, randomSeed = request.seed, timeout = request.budget, + solverTimeout = request.budget, solverType = SolverType.Z3, stopOnCoverage = CALLS_STOP_ON_COVERAGE, stopOnTargetsReached = false, @@ -136,37 +299,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 +365,189 @@ 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 { + preparedTarget?.takeIf { (cachedRequest, _) -> cachedRequest == 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, + ) + } + if (preparation !is CallsTargetPreparation.Rejected || preparation.status != CallsSymbolicStatus.TOOL_ERROR) { + preparedTarget = request to 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 sourceKey = LoadedSourceKey( + sourceRoot = request.sourceRoot, + sourceFile = request.function.sourceFile, + revision = request.project.revision, + ) + val loaded = loadedSources.getOrPut(sourceKey) { + loadCallsSourceProject(sourceRoot = request.sourceRoot, source = source) + } + if (loaded is CallsSourceProject.Unsupported) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = loaded.issue.reasonCode, + diagnostic = loaded.issue.diagnostic, + ) + } + + loaded as CallsSourceProject.Loaded + val scene = loaded.scene + val frontendEntryPoint = request.function.entryPoint.copy( + module = loaded.entryModule, + ) + 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), + sourceByFile = loaded.sourceByFile, + 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 +562,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 +649,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 +692,145 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { cache[checkout] = expectedRevision } + private data class LoadedSourceKey( + val sourceRoot: Path, + val sourceFile: String, + val revision: String, + ) + + 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() } + } + } + + internal class UnknownCallEventSinkObserver( + private val sink: ((TsUnknownCallEvent) -> Unit)?, + private val runtimeLimitationSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)?, + ) : TsInterpreterObserver { + val runtimeLimitations = linkedSetOf() + + // Keep one telemetry record per unsupported storage and statement in this analysis. + private val reportedArrayStorageLimitations = hashSetOf>() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + sink?.invoke(event) + } + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + runtimeLimitations += event.reason.name + if (event.reason != TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE || + reportedArrayStorageLimitations.add(event.statement to event.detail) + ) { + 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 +914,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/CallsCoverageExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsCoverageExperimentTest.kt new file mode 100644 index 0000000000..1a545e3288 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsCoverageExperimentTest.kt @@ -0,0 +1,162 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.io.TempDir +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +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.assertTrue +import kotlin.time.Duration.Companion.seconds + +class CallsCoverageExperimentTest { + @TempDir + lateinit var directory: Path + + @Test + fun `two symbolic branches replay to distinct original source if arms`() { + val fixture = fixture( + source = """ + export function classify(flag: boolean): boolean { + if (flag) { + return true; + } else { + return false; + } + } + """.trimIndent(), + exportName = "classify", + inputs = listOf(PropertyInput(name = "flag", domain = BooleanDomain)), + ) + val emitted = mutableListOf() + + val search = CurrentTsCallsCoverageEngine(testSourceEngine()).search( + request = coverageRequest(fixture, onCandidate = emitted::add), + ) + + assertEquals(CallsCoverageSearchStatus.EXHAUSTED, search.status, search.toString()) + assertEquals(search.candidates, emitted) + assertEquals(2, search.candidates.map { it.inputs }.distinct().size, search.toString()) + assertTrue(search.candidates.all { candidate -> candidate.emittedAtMillis <= search.searchElapsedMillis }) + + val replayed = OriginalTypeScriptCoverageReplayer( + sourceRoot = fixture.sourceRoot, + function = fixture.function, + ).use { replayer -> + search.candidates.map { candidate -> + replayer.replay(inputs = candidate.inputs, timeoutMillis = 20_000L) + } + } + assertEquals(2, replayed.flatMap { it.coveredIfArmKeys }.toSet().size, replayed.toString()) + assertTrue(replayed.all { replay -> replay.coveredStatementKeys.isNotEmpty() }) + } + + @Test + fun `coverage replay preserves an isolated UTF-16 surrogate`() { + val fixture = fixture( + source = "export function lengthOf(value: string): number { return value.length; }", + exportName = "lengthOf", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain(maxLength = 1))), + ) + + val replay = OriginalTypeScriptCoverageReplayer( + sourceRoot = fixture.sourceRoot, + function = fixture.function, + ).use { replayer -> + replayer.replay( + inputs = listOf(JsConcreteValue.String("\uD800")), + timeoutMillis = 20_000L, + ) + } + + assertEquals(CallsCoverageCompletion.RETURNED, replay.completion) + assertTrue(replay.coveredStatementKeys.isNotEmpty()) + } + + @Test + fun `coverage replay accepts an entry function with no inputs`() { + val fixture = fixture( + source = "export function constant(): number { return 42; }", + exportName = "constant", + inputs = emptyList(), + ) + + val replay = OriginalTypeScriptCoverageReplayer( + sourceRoot = fixture.sourceRoot, + function = fixture.function, + ).use { replayer -> + replayer.replay(inputs = emptyList(), timeoutMillis = 20_000L) + } + + assertEquals(CallsCoverageCompletion.RETURNED, replay.completion) + assertTrue(replay.coveredStatementKeys.isNotEmpty()) + } + + private fun coverageRequest( + fixture: CoverageFixture, + onCandidate: (CallsCoverageCandidate) -> Unit, + ) = CallsCoverageSearchRequest( + sourceRoot = fixture.sourceRoot, + project = fixture.project, + function = fixture.function, + profile = CallsExperimentProfile.EMPTY_STOP, + frozenModelIds = emptySet(), + expectedNativeFrontendRevision = "bundled:test", + seed = 17, + budget = 10.seconds, + solverQueryLimit = 2.seconds, + candidateCap = 8, + onCandidate = onCandidate, + ) + + private fun testSourceEngine() = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:test", + ) + + private fun fixture(source: String, exportName: String, inputs: List): CoverageFixture { + val sourceRoot = Files.createDirectory(directory.resolve(exportName)) + Files.writeString(sourceRoot.resolve("Fixture.ts"), source) + runGit(sourceRoot, "init") + runGit(sourceRoot, "config", "user.name", "USVM Tests") + runGit(sourceRoot, "config", "user.email", "usvm@example.test") + runGit(sourceRoot, "add", ".") + runGit(sourceRoot, "commit", "-m", "fixture") + val revision = runGit(sourceRoot, "rev-parse", "HEAD").trim() + val function = CallsFunctionCase( + functionId = exportName, + sourceFile = "Fixture.ts", + entryPoint = TypeScriptEntryPoint(module = "Fixture.ts", exportName = exportName), + inputs = inputs, + targets = emptyList(), + ) + val project = CallsProjectCase( + projectId = exportName, + revision = revision, + sourceRoot = ".", + development = true, + functions = listOf(function), + ) + + return CoverageFixture(sourceRoot = sourceRoot, project = project, function = function) + } + + 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 CoverageFixture( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + ) +} 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..3c17bc6916 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,118 @@ 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 `runner does not publish completion after an observer write failure`(@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 event = TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH), + ) + val engine = CallsSymbolicEngine { request -> + runCatching { checkNotNull(request.unknownCallEventSink).invoke(event) } + result(status = CallsSymbolicStatus.UNREACHED) + } + val rawOutput = directory.resolve("results.jsonl") + + val failure = assertFailsWith { + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + eventRecordWriter = { _, _ -> error("Event write failed") }, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + } + + assertTrue(failure.message.orEmpty().contains("Calls event")) + assertFalse(Files.exists(rawOutput)) + } + + @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/CallsSourceProjectTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt new file mode 100644 index 0000000000..4aa94ff589 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt @@ -0,0 +1,67 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertFails +import kotlin.test.assertFalse + +class CallsSourceProjectTest { + @Test + fun `single and multi file conversion failures remove partial IR`() { + checkCleanup(frontendExitCode = 1) + } + + @Test + fun `single and multi file IR decoding failures remove generated output`() { + checkCleanup(frontendExitCode = 0) + } + + private fun checkCleanup(frontendExitCode: Int) { + val root = createTempDirectory(prefix = "calls-frontend-cleanup-") + val frontend = root.resolve("frontend") + val script = frontend.resolve("dist/index.js") + val recordedPath = script.resolveSibling("generated-path") + val sourceRoot = root.resolve("sources").createDirectories() + val source = sourceRoot.resolve("entry.ts") + val previousFrontend = System.getProperty("ets.frontend.dir") + script.parent.createDirectories() + script.writeText( + """ + const fs = require('node:fs'); + const path = require('node:path'); + const output = process.argv.filter(arg => arg !== '-v').at(-1); + fs.writeFileSync(path.join(__dirname, 'generated-path'), output); + const file = fs.statSync(output).isDirectory() ? path.join(output, 'partial.json') : output; + fs.writeFileSync(file, '{'); + process.exit($frontendExitCode); + """.trimIndent(), + ) + System.setProperty("ets.frontend.dir", frontend.toString()) + + try { + source.writeText("export function entry(value: number): number { return value; }") + assertFails { loadCallsSourceProject(sourceRoot = sourceRoot, source = source) } + assertFalse(Files.exists(Path.of(recordedPath.readText()))) + + sourceRoot.resolve("helper.ts").writeText("export function helper(value: number): number { return value; }") + source.writeText( + "import { helper } from './helper'; " + + "export function entry(value: number): number { return helper(value); }", + ) + assertFails { loadCallsSourceProject(sourceRoot = sourceRoot, source = source) } + assertFalse(Files.exists(Path.of(recordedPath.readText()))) + } finally { + if (previousFrontend == null) { + System.clearProperty("ets.frontend.dir") + } else { + System.setProperty("ets.frontend.dir", previousFrontend) + } + root.toFile().deleteRecursively() + } + } +} 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..82615b6b23 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 @@ -10,6 +10,43 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull class CallsSourceReplayTest { + @Test + fun `original source replay preserves an isolated UTF-16 surrogate`() { + val fixture = fixture() + val target = fixture.target(functionName = "isolatedSurrogate", statement = "return 13;") + + val surrogate = fixture.replay( + exportName = "isolatedSurrogate", + inputs = listOf(JsConcreteValue.String("\ud800")), + target = target, + ) + val replacement = fixture.replay( + exportName = "isolatedSurrogate", + inputs = listOf(JsConcreteValue.String("?")), + target = target, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, surrogate.status, surrogate.toString()) + assertEquals(CallsReplayStatus.REJECTED, replacement.status, replacement.toString()) + } + + @Test + fun `replays a zero-input export without the FastCheck placeholder`() { + val fixture = fixture() + val zeroArguments = fixture.target(functionName = "checksArgumentCount", statement = "return 0;") + val extraArgument = fixture.target(functionName = "checksArgumentCount", statement = "return 1;") + + val reached = fixture.replay(exportName = "checksArgumentCount", inputs = emptyList(), target = zeroArguments) + val notReached = fixture.replay( + exportName = "checksArgumentCount", + inputs = emptyList(), + target = extraArgument, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, reached.status, reached.toString()) + assertEquals(CallsReplayStatus.REJECTED, notReached.status, notReached.toString()) + } + @Test fun `confirms only the exact source statement reached by original TypeScript`() { val fixture = fixture() @@ -53,6 +90,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 +292,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 +323,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..7497bd22ba --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt @@ -0,0 +1,135 @@ +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.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +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 `observer records array storage limitation once per statement and detail`() { + 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 statements = method.cfg.stmts.take(2) + val records = mutableListOf() + val observer = CurrentTsCallsSymbolicEngine.UnknownCallEventSinkObserver( + sink = null, + runtimeLimitationSink = records::add, + ) + val arrayStorage = TsRuntimeFeatureLimitationEvent( + statement = statements[0], + reason = TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE, + detail = "storage=Uint8Array", + ) + val otherStorage = arrayStorage.copy(detail = "storage=Uint16Array") + val otherStatement = arrayStorage.copy(statement = statements[1]) + val otherReason = arrayStorage.copy(reason = TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_READ) + + // Repeated callbacks represent different states reaching the same unsupported access. + observer.onRuntimeFeatureLimitation(arrayStorage) + observer.onRuntimeFeatureLimitation(arrayStorage) + observer.onRuntimeFeatureLimitation(otherStorage) + observer.onRuntimeFeatureLimitation(otherStatement) + observer.onRuntimeFeatureLimitation(otherReason) + observer.onRuntimeFeatureLimitation(otherReason) + + assertEquals( + listOf(arrayStorage, otherStorage, otherStatement, otherReason, otherReason), + records, + ) + assertEquals( + setOf("ARRAY_STORAGE_TYPE", "ARRAY_NAMED_PROPERTY_READ"), + observer.runtimeLimitations, + ) + + val nextAnalysisRecords = mutableListOf() + val nextAnalysis = CurrentTsCallsSymbolicEngine.UnknownCallEventSinkObserver( + sink = null, + runtimeLimitationSink = nextAnalysisRecords::add, + ) + nextAnalysis.onRuntimeFeatureLimitation(arrayStorage) + + assertEquals(listOf(arrayStorage), nextAnalysisRecords) + } + + @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..dd03abcbc2 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -0,0 +1,1305 @@ +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 +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 `fresh unknown string index branches remain executable and extractable`() { + val alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~!@#${'$'}%^&*_-+=|:.>= 1); + return ret; + } + + let nUid = 0; + + export function uid(): string { + return encodeNum(nUid++); + } + """.trimIndent(), + exportName = "uid", + inputs = emptyList(), + targetStatement = "return encodeNum(nUid++);", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "encodeNum(nUid++)", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = emptySet(), + profile = CallsExperimentProfile.EMPTY_FRESH, + unknownCallEventSink = unknownCalls::add, + ) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, "$result; unknownCalls=$unknownCalls") + assertEquals(emptyList(), result.inputs) + } + + @Test + fun `partial array join with fresh string residual does not fail search`() { + val fixture = fixture( + source = """ + export function lineWrap(text: string, MAX: number = 15): string[] { + const lines: string[] = [] + + const segments = text.split(' ') + + const l = segments.length + + let line_segments: string[] = [] + let line_segments_char_count = 0 + + for (let i = 0; i < l; i++) { + const segment = segments[i] + const segment_l = segment.length + + if (line_segments_char_count + line_segments.length - 1 + segment_l > MAX) { + lines.push(line_segments.join(' ')) + line_segments = [] + line_segments_char_count = 0 + } + + line_segments.push(segment) + line_segments_char_count += segment_l + } + + if (line_segments.length > 0) { + lines.push(line_segments.join(' ')) + } + + return lines + } + """.trimIndent(), + exportName = "lineWrap", + inputs = listOf( + PropertyInput(name = "text", domain = StringDomain(maxLength = 10)), + PropertyInput(name = "MAX", domain = NumberDomain()), + ), + targetStatement = "return lines", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "lines", + sourceFileName = "src/spec/lineWrap.ts", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.string.split", "ts.array.join", "ts.array.push"), + profile = CallsExperimentProfile.FROZEN_FRESH, + unknownCallEventSink = unknownCalls::add, + seed = 29, + budget = 30.seconds, + ) + + assertTrue(result.status != CallsSymbolicStatus.TOOL_ERROR, result.toString()) + assertTrue(unknownCalls.any { it.callee.name == "join" }, "Array.join was not exercised") + } + + @Test + fun `fresh array element can be assigned to numeric object field`() { + val returnExpression = "{ major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }" + val targetStatement = "return $returnExpression;" + val fixture = fixture( + source = """ + export function parseVersion(version: string): { + major: number; + minor: number; + patch: number; + } { + const parts = version.replace('v', '').split('.').map(Number); + $targetStatement + } + """.trimIndent(), + exportName = "parseVersion", + inputs = listOf(PropertyInput(name = "version", domain = StringDomain())), + targetStatement = targetStatement, + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = returnExpression, + ) + + val result = fixture.search( + modelIds = emptySet(), + profile = CallsExperimentProfile.EMPTY_FRESH, + ) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `source closure is reloaded after checkout revision changes`() { + val fixture = importedFixture( + exportName = "revisionSensitive", + dependency = "export function helper(value: string): boolean { return true; }", + ) + val first = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, first.status, first.toString()) + + Files.writeString( + fixture.sourceRoot.resolve("helper.ts"), + "export function replacement(value: string): boolean { return true; }", + ) + runGit(fixture.sourceRoot, "add", "helper.ts") + runGit(fixture.sourceRoot, "commit", "-m", "replace helper") + val nextRevision = runGit(fixture.sourceRoot, "rev-parse", "HEAD").trim() + + val second = fixture.preflight(project = fixture.project.copy(revision = nextRevision)) + + assertEquals( + CallsSymbolicPreflightReasonCode.IMPORTED_CALLEES_UNSUPPORTED, + second.reasonCode, + second.toString(), + ) + } + + @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 `preflight retries a transient tool error for the same target`() { + val fixture = fixture( + source = "export function answer(): number { return 42; }", + exportName = "answer", + inputs = emptyList(), + targetStatement = "return 42;", + ) + val environment = mutableMapOf("ETS_FRONTEND_DIR" to "/unused/frontend") + val engine = CurrentTsCallsSymbolicEngine( + environment = environment::get, + bundledNativeFrontendRevision = "bundled:test", + ) + val request = CallsSymbolicPreflightRequest( + sourceRoot = fixture.sourceRoot, + project = fixture.project, + function = fixture.function, + target = fixture.target, + expectedNativeFrontendRevision = "bundled:test", + ) + + val first = engine.preflight(request) + environment.clear() + val second = engine.preflight(request) + + assertEquals(CallsSymbolicPreflightStatus.TOOL_ERROR, first.status) + assertTrue(second.status != CallsSymbolicPreflightStatus.TOOL_ERROR, second.toString()) + } + + @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 block module initialization 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.TOP_LEVEL_INITIALIZATION_UNSUPPORTED, result.reasonCode) + } + + @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) + } + + @Test + fun `loads local dependency closure and replays a nested source target`() { + val fixture = fixture( + source = """ + import { magnitude } from '../helpers'; + export function positiveMagnitude(value: number): boolean { + if (magnitude(value) === 42) return true; + return false; + } + """.trimIndent(), + exportName = "positiveMagnitude", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "true", + sourceFileName = "entry/Fixture.ts", + dependencies = mapOf( + "helpers/index.ts" to """ + import { absolute } from './Fixture.js'; + export function magnitude(value: number): number { return absolute(value); } + """.trimIndent(), + "helpers/Fixture.ts" to "export function absolute(value: number): number { return Math.abs(value); }", + "unrelated.ts" to "throw new Error('This module must not be loaded');", + ), + ) + val events = mutableListOf() + + val preflight = fixture.preflight() + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + val result = fixture.search(modelIds = setOf("ts.math.abs"), unknownCallEventSink = events::add) + + val inputs = assertNotNull(result.inputs, "$result; events=$events") + assertEquals(42.0, kotlin.math.abs(assertIs(inputs.single()).toDouble())) + assertTrue(events.any { (it.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.math.abs" }) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `preflight checks regex in a reachable imported helper using its own source`() { + val fixture = importedFixture( + exportName = "importedRegex", + dependency = "export function helper(value: string): boolean { return /a/.test(value); }", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight does not inspect an unreachable imported helper body`() { + val fixture = importedFixture( + exportName = "unreachableRegex", + dependency = """ + export function helper(value: string): boolean { return value.length > 0; } + export function unused(value: string): boolean { return /a/.test(value); } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight rejects transitive external imports`() { + val fixture = importedFixture( + exportName = "externalImport", + dependency = """ + import { existsSync } from 'node:fs'; + export function helper(value: string): boolean { return existsSync(value); } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals( + CallsSymbolicPreflightReasonCode.IMPORTED_CALLEES_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + assertTrue(result.diagnostic.orEmpty().contains("node:fs")) + } + + @Test + fun `preflight rejects dependency module initialization effects`() { + val fixture = importedFixture( + exportName = "moduleEffects", + dependency = """ + let threshold = 0; + threshold = 1; + export function helper(value: string): boolean { return value.length > threshold; } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals( + CallsSymbolicPreflightReasonCode.TOP_LEVEL_INITIALIZATION_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects missing imports and does not treat them as unknown calls`() { + val fixture = importedFixture( + exportName = "missingImport", + dependency = """ + import { absent } from './missing'; + export function helper(value: string): boolean { return absent(value); } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals( + CallsSymbolicPreflightReasonCode.IMPORTED_CALLEES_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + assertTrue(result.diagnostic.orEmpty().contains("./missing")) + } + + @Test + fun `preflight ignores explicitly type-only external imports`() { + val fixture = importedFixture( + exportName = "typeOnlyImport", + dependency = """ + import type { ExternalType } from 'missing-type-package'; + export function helper(value: string): boolean { return value.length > 0; } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight checks a regex initializer in an imported module`() { + val fixture = importedFixture( + exportName = "importedInitializer", + dependency = """ + const pattern = /a/; + export function helper(value: string): boolean { return pattern.test(value); } + """.trimIndent(), + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + private fun importedFixture(exportName: String, dependency: String): SymbolicFixture = fixture( + source = """ + import { helper } from './helper'; + export function $exportName(value: string): boolean { return helper(value); } + """.trimIndent(), + exportName = exportName, + inputs = listOf(PropertyInput(name = "value", domain = StringDomain(maxLength = 3))), + targetStatement = "return helper(value);", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "helper(value)", + dependencies = mapOf("helper.ts" to dependency), + ) + + private fun fixture( + source: String, + exportName: String, + inputs: List, + targetStatement: String, + targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + returnExpression: String? = null, + sourceFileName: String = "Fixture.ts", + dependencies: Map = emptyMap(), + ): SymbolicFixture { + val sourceRoot = Files.createDirectory(directory.resolve(exportName)) + val sourceFile = sourceRoot.resolve(sourceFileName) + Files.createDirectories(sourceFile.parent) + Files.writeString(sourceFile, source) + dependencies.forEach { (relative, content) -> + val dependency = sourceRoot.resolve(relative) + Files.createDirectories(dependency.parent) + Files.writeString(dependency, content) + } + runGit(sourceRoot, "init") + runGit(sourceRoot, "config", "user.name", "USVM Tests") + runGit(sourceRoot, "config", "user.email", "usvm@example.test") + runGit(sourceRoot, "add", ".") + 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 = sourceFileName, + 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 = sourceFileName, + entryPoint = TypeScriptEntryPoint(module = sourceFileName, 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(project: CallsProjectCase = this.project): CallsSymbolicPreflightResult = engine.preflight( + CallsSymbolicPreflightRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + expectedNativeFrontendRevision = "bundled:test", + ) + ) + + fun search( + modelIds: Set, + profile: CallsExperimentProfile = CallsExperimentProfile.FROZEN_STOP, + unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + runtimeLimitationEventSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)? = null, + seed: Long = 0, + budget: Duration = 10.seconds, + ): CallsSymbolicSearchResult = engine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + frozenModelIds = modelIds, + expectedNativeFrontendRevision = "bundled:test", + seed = seed, + budget = budget, + 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..847ca6aa25 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -1,9 +1,23 @@ export function inlineChoose(value: number): number { if (value > 0) { return 1; } return 0; } +export function isolatedSurrogate(value: string): number { + if (value.length === 1 && value.charCodeAt(0) === 0xd800) { + return 13; + } + return 0; +} + export function throwsAtTarget(): never { throw new Error('expected'); } +export function checksArgumentCount(): number { + if (arguments.length !== 0) { + return 1; + } + return 0; +} + export function importOnlyTarget(): number { return 7; } @@ -13,3 +27,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-dataflow/src/main/resources/logback.xml b/usvm-ts-dataflow/src/main/resources/logback.xml index 40d03b09a6..4ba96c668c 100644 --- a/usvm-ts-dataflow/src/main/resources/logback.xml +++ b/usvm-ts-dataflow/src/main/resources/logback.xml @@ -1,11 +1,12 @@ - + + System.err %highlight([%level]) %replace(%c{0}){'(\$Companion)?\$logger\$1',''} - %msg%n - + diff --git a/usvm-ts-fast-check/README.md b/usvm-ts-fast-check/README.md index 746c3ea1ab..97bf0d95c6 100644 --- a/usvm-ts-fast-check/README.md +++ b/usvm-ts-fast-check/README.md @@ -7,6 +7,20 @@ runtime. The public property model, validation, registries, coverage contracts and decoders, and property-to-EtsIR mapping remain in [`usvm-ts-pbt`](../usvm-ts-pbt/README.md). +## Branch coverage boundary + +One c8 execution produces the existing source-mapped Istanbul statement report and raw V8 ranges. The bounded +TypeScript branch converter reads those same raw ranges, the executed source snapshot, and the source map. It emits +ordered `if` arms only when the original TypeScript points map back exactly, each arm has a distinct V8 execution +point, and the arm counts add up to the count at the condition. A supported `if` without `else` additionally needs +a single terminating `return` or `throw` in its true arm and a following statement that is reached only on false. +Nested `if` statements are supported under those conditions. Ambiguous counts or ranges and unsupported constructs +produce diagnostics; exact statement mappings remain available. + +Loops, `switch`, conditional expressions, logical short-circuit ranges, and function or script ranges are never +reinterpreted as `if` arms. The converter does not instrument the TypeScript program or execute it a second time. +The resulting edges are coverage artifacts; target-selection integration belongs to #399/#355. + Run the backend checks with: ```shell diff --git a/usvm-ts-fast-check/build.gradle.kts b/usvm-ts-fast-check/build.gradle.kts index bd10be1065..de9c78d804 100644 --- a/usvm-ts-fast-check/build.gradle.kts +++ b/usvm-ts-fast-check/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(Libs.clikt) implementation(Libs.kotlinx_serialization_json) + testImplementation(Libs.jacodb_ets) testImplementation(Libs.logback) } diff --git a/usvm-ts-fast-check/fast-check-adapter/package-lock.json b/usvm-ts-fast-check/fast-check-adapter/package-lock.json index 594dfb5981..3db9388792 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package-lock.json +++ b/usvm-ts-fast-check/fast-check-adapter/package-lock.json @@ -8,13 +8,14 @@ "name": "@usvm/fast-check-adapter", "version": "0.1.0", "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", "c8": "10.1.3", "fast-check": "4.9.0", - "tsx": "4.23.12" + "tsx": "4.23.12", + "typescript": "5.9.2" }, "devDependencies": { - "@types/node": "18.19.130", - "typescript": "5.9.2" + "@types/node": "18.19.130" }, "engines": { "node": ">=18.18.0" @@ -1304,7 +1305,6 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/usvm-ts-fast-check/fast-check-adapter/package.json b/usvm-ts-fast-check/fast-check-adapter/package.json index fdac679842..44d0002d31 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package.json +++ b/usvm-ts-fast-check/fast-check-adapter/package.json @@ -9,16 +9,17 @@ "build": "tsc --project tsconfig.json", "pretest": "npm run build", "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" + "test:compiled": "node --test dist/test/coverage-branches.test.js dist/test/entry-point.test.js dist/test/local-source-closure.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" }, "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", "c8": "10.1.3", "fast-check": "4.9.0", - "tsx": "4.23.12" + "tsx": "4.23.12", + "typescript": "5.9.2" }, "devDependencies": { - "@types/node": "18.19.130", - "typescript": "5.9.2" + "@types/node": "18.19.130" }, "overrides": { "c8": { diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts new file mode 100644 index 0000000000..4aa78be072 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts @@ -0,0 +1,509 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + generatedPositionFor, + originalPositionFor, + TraceMap, +} from '@jridgewell/trace-mapping'; +import ts from 'typescript'; + +interface V8Range { + startOffset: number; + endOffset: number; + count: number; +} + +interface V8Function { + ranges: V8Range[]; + isBlockCoverage: boolean; +} + +interface V8Script { + url: string; + functions: V8Function[]; +} + +interface SourceMapCacheEntry { + lineLengths: number[]; + data: { + version: 3; + sources: string[]; + sourcesContent?: (string | null)[]; + mappings: string; + names: string[]; + }; +} + +interface V8Report { + result?: V8Script[]; + 'source-map-cache'?: Record; +} + +interface Position { + line: number; + column: number; +} + +interface Range { + start: Position; + end: Position; +} + +interface Branch { + branchId: number; + type: 'if'; + location: Range; + arms: { location: Range; hits: number }[]; +} + +interface BranchDiagnostic { + code: string; + message: string; + path: string; +} + +interface BranchReport { + files: { path: string; branches: Branch[] }[]; + diagnostics: BranchDiagnostic[]; +} + +interface ScriptCoverage { + script: V8Script; + cache: SourceMapCacheEntry; + path: string; + sourceUrl: string; + scriptPath: string; +} + +export interface GeneratedModuleIdentity { + originalPath: string; + originalSha256: string; + generatedModule: string; + generatedSha256: string; + sourceMapSha256: string; +} + +/** Reconstructs only source-mapped TypeScript if arms whose V8 counters agree. */ +export async function convertV8IfBranches( + rawDirectory: string, + sourceRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise { + const roots = await Promise.all(sourceRoots.map(root => realpath(root))); + const lexicalRoots = [...sourceRoots.map(root => path.resolve(root)), ...roots.flatMap(root => + process.platform === 'darwin' && root.startsWith('/private/') ? [root.slice('/private'.length)] : [])]; + const identity = generatedIdentity === undefined ? undefined : { + ...generatedIdentity, + originalPath: await realpath(generatedIdentity.originalPath).catch(() => generatedIdentity.originalPath), + generatedModule: await realpath(generatedIdentity.generatedModule).catch(() => generatedIdentity.generatedModule), + }; + const names = (await readdir(rawDirectory)).filter(name => name.endsWith('.json')).sort(); + const reports = await Promise.all(names.map(async name => + JSON.parse(await readFile(path.join(rawDirectory, name), 'utf8')) as V8Report, + )); + const collected = await collectScripts(reports, roots, lexicalRoots, identity); + const scripts = collected.scripts; + const files: BranchReport['files'] = []; + const diagnostics: BranchDiagnostic[] = [...collected.diagnostics]; + + for (const [sourcePath, candidates] of scripts) { + if (identity !== undefined && sourcePath === identity.originalPath && + (collected.generatedScriptCount !== 1 || collected.diagnostics.some(entry => + entry.path === sourcePath && ['coverage.branch.ambiguous', 'coverage.branch.unknown'].includes(entry.code)))) { + continue; + } + + if (candidates.length !== 1 || candidates[0] === undefined) { + diagnostics.push(diagnostic( + sourcePath, + 'ambiguous', + 'The TypeScript source has several V8 scripts or a script without source-map data', + )); + continue; + } + + const candidate = candidates[0]; + if (identity !== undefined && candidate.scriptPath === identity.generatedModule) { + const identityDiagnostic = await verifyGeneratedIdentity(candidate, identity); + if (identityDiagnostic !== undefined) { + diagnostics.push(identityDiagnostic); + continue; + } + } + + const converted = await convertScript(candidate); + files.push({ path: sourcePath, branches: converted.branches.map((branch, branchId) => ({ + ...branch, + branchId, + })) }); + diagnostics.push(...converted.diagnostics); + } + + if (identity !== undefined && !scripts.has(identity.originalPath)) { + diagnostics.push(diagnostic(identity.originalPath, 'unknown', + 'Generated V8 script has no unambiguous source-map link to the requested TypeScript source')); + } + + return { files, diagnostics }; +} + +async function collectScripts( + reports: V8Report[], + roots: string[], + lexicalRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise<{ + scripts: Map; + diagnostics: BranchDiagnostic[]; + generatedScriptCount: number; +}> { + const scripts = new Map(); + const diagnostics: BranchDiagnostic[] = []; + let generatedScriptCount = 0; + + for (const report of reports) { + for (const script of report.result ?? []) { + if (!script.url.startsWith('file:')) continue; + + const scriptPath = await realpath(fileURLToPath(script.url)).catch(() => undefined); + if (scriptPath === undefined) continue; + const cache = report['source-map-cache']?.[script.url]; + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule) { + generatedScriptCount += 1; + if (cache?.data === undefined) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated V8 script has no usable source-map cache entry')); + continue; + } + } + + const sources = cache?.data?.sources ?? []; + const mappedSources = await Promise.all(sources.map(async sourceUrl => { + if (!sourceUrl.startsWith('file:')) return { sourceUrl, sourcePath: undefined }; + + let lexicalPath: string; + try { + lexicalPath = path.resolve(fileURLToPath(sourceUrl)); + } catch { + return { sourceUrl, sourcePath: undefined }; + } + if (!lexicalPath.endsWith('.ts') || + (!isUnderRoot(lexicalPath, lexicalRoots) && !isUnderRoot(lexicalPath, roots))) { + return { sourceUrl, sourcePath: undefined }; + } + + const sourcePath = await realpath(lexicalPath).catch(() => undefined); + return { sourceUrl, sourcePath }; + })); + const selected = mappedSources.filter(({ sourcePath }) => sourcePath !== undefined && + sourcePath.endsWith('.ts') && isUnderRoot(sourcePath, roots)); + + if (selected.length === 0 && scriptPath.endsWith('.ts') && isUnderRoot(scriptPath, roots)) { + selected.push({ sourcePath: scriptPath, sourceUrl: pathToFileURL(scriptPath).href }); + } + + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule && + (selected.length !== 1 || selected[0]?.sourcePath !== generatedIdentity.originalPath)) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated V8 script does not map to exactly the requested TypeScript source')); + continue; + } + + for (const { sourcePath, sourceUrl } of selected) { + if (sourcePath === undefined) continue; + if (scriptPath !== sourcePath && scriptPath !== generatedIdentity?.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'unknown', + 'Generated V8 script requires a pinned module identity for exact branch conversion')); + continue; + } + if (generatedIdentity !== undefined && sourcePath === generatedIdentity.originalPath && + scriptPath !== generatedIdentity.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'ambiguous', + 'Requested TypeScript source also appears under another V8 script')); + continue; + } + + const candidates = scripts.get(sourcePath) ?? []; + candidates.push(cache?.data === undefined || cache.data === null + ? undefined + : { script, cache, path: sourcePath, sourceUrl, scriptPath }); + scripts.set(sourcePath, candidates); + } + } + } + + if (generatedIdentity !== undefined && generatedScriptCount === 0) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated module is absent from raw V8 coverage')); + } + if (generatedIdentity !== undefined && generatedScriptCount > 1) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated module appears in several raw V8 scripts')); + } + + return { scripts, diagnostics, generatedScriptCount }; +} + +function isUnderRoot(file: string, roots: string[]): boolean { + return roots.some(root => { + const relative = path.relative(root, file); + return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); + }); +} + +async function verifyGeneratedIdentity( + coverage: ScriptCoverage, + identity: GeneratedModuleIdentity, +): Promise { + const generated = await readFile(identity.generatedModule, 'utf8').catch(() => undefined); + const sourceMap = await readFile(`${identity.generatedModule}.map`, 'utf8').catch(() => undefined); + const source = await readFile(identity.originalPath, 'utf8').catch(() => undefined); + if (generated === undefined || sourceMap === undefined || source === undefined || + sha256(generated) !== identity.generatedSha256 || sha256(sourceMap) !== identity.sourceMapSha256 || + sha256(source) !== identity.originalSha256) { + return diagnostic(identity.originalPath, 'unsupported', 'Covered generated module or original source changed'); + } + + const lineLengths = generated.split('\n').map(line => line.endsWith('\r') ? line.length - 1 : line.length); + if (generated.endsWith('\n')) lineLengths.pop(); + if (JSON.stringify(lineLengths) !== JSON.stringify(coverage.cache.lineLengths)) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 generated script lengths differ from the pinned module'); + } + + let parsedMap: { mappings?: string }; + try { + parsedMap = JSON.parse(sourceMap) as { mappings?: string }; + } catch { + return diagnostic(identity.originalPath, 'unsupported', 'Pinned generated source map is invalid'); + } + if (parsedMap.mappings !== coverage.cache.data.mappings) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 source-map cache differs from the pinned map'); + } + + return undefined; +} + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + +async function convertScript(coverage: ScriptCoverage): Promise<{ + branches: Branch[]; + diagnostics: BranchDiagnostic[]; +}> { + const source = await readFile(coverage.path, 'utf8'); + const sourceIndexes = coverage.cache.data.sources.flatMap((candidate, index) => + candidate === coverage.sourceUrl ? [index] : [], + ); + if (sourceIndexes.length !== 1) { + return { + branches: [], + diagnostics: [diagnostic(coverage.path, 'ambiguous', 'V8 source map does not identify one TypeScript source')], + }; + } + + const sourceIndex = sourceIndexes[0] as number; + const coveredSource = coverage.cache.data.sourcesContent?.[sourceIndex]; + if (coveredSource !== source) { + return { + branches: [], + diagnostics: [diagnostic(coverage.path, 'unsupported', 'Covered TypeScript source differs from the current file')], + }; + } + + const ast = ts.createSourceFile(coverage.path, source, ts.ScriptTarget.Latest, true); + const map = new TraceMap(coverage.cache.data); + const branches: Branch[] = []; + const diagnostics: BranchDiagnostic[] = []; + + function visit(node: ts.Node, unsupportedAncestor: boolean): void { + const unsupported = unsupportedAncestor || isRepeatedOrExceptional(node); + + if (ts.isIfStatement(node)) { + const result = convertIf(node, ast, coverage, map, unsupported); + if (result.branch !== undefined) branches.push(result.branch); + if (result.reason !== undefined) diagnostics.push(diagnostic(coverage.path, result.kind, result.reason)); + } + + ts.forEachChild(node, child => visit(child, unsupported)); + } + + visit(ast, false); + + return { branches, diagnostics }; +} + +function convertIf( + node: ts.IfStatement, + ast: ts.SourceFile, + coverage: ScriptCoverage, + map: TraceMap, + unsupportedAncestor: boolean, +): { branch?: Branch; reason?: string; kind: 'unsupported' | 'ambiguous' } { + if (unsupportedAncestor || hasUnsupportedExpression(node.expression)) { + return { kind: 'unsupported', reason: 'If condition is inside an unsupported control-flow construct' }; + } + + const trueBody = firstExecutable(node.thenStatement); + const falseBody = node.elseStatement === undefined + ? falseSuccessor(node) + : firstExecutable(node.elseStatement); + if (trueBody === undefined) { + return { kind: 'unsupported', reason: 'Empty true arm has no V8 execution point' }; + } + if (falseBody === undefined) { + const reason = node.elseStatement === undefined + ? 'An if without else requires a terminating true arm and a following false successor' + : 'Empty false arm has no V8 execution point'; + + return { kind: 'unsupported', reason }; + } + + const conditionHits = countAt(node.getStart(ast), ast, coverage, map); + const trueHits = countAt(trueBody.getStart(ast), ast, coverage, map); + const falseHits = countAt(falseBody.getStart(ast), ast, coverage, map); + + if (conditionHits === undefined || trueHits === undefined || falseHits === undefined || + trueHits < 0 || falseHits < 0 || trueHits + falseHits !== conditionHits) { + return { kind: 'ambiguous', reason: 'V8 ranges and exact TypeScript source-map points do not identify both if arms' }; + } + + const branch: Branch = { + branchId: 0, + type: 'if', + // The predicate span excludes nested if statements from this branch's EtsIR target candidates. + location: sourceRange(node.expression, ast), + arms: [ + { location: sourceRange(node.thenStatement, ast), hits: trueHits }, + { + location: node.elseStatement === undefined + ? sourceRange(node.expression, ast) + : sourceRange(node.elseStatement, ast), + hits: falseHits, + }, + ], + }; + + return { branch, kind: 'ambiguous' }; +} + +function countAt( + sourceOffset: number, + ast: ts.SourceFile, + coverage: ScriptCoverage, + map: TraceMap, +): number | undefined { + const sourcePosition = ast.getLineAndCharacterOfPosition(sourceOffset); + const sourceUrl = coverage.sourceUrl; + + const generated = generatedPositionFor(map, { + source: sourceUrl, + line: sourcePosition.line + 1, + column: sourcePosition.character, + }); + if (generated.line === null || generated.column === null) return undefined; + + const original = originalPositionFor(map, { line: generated.line, column: generated.column }); + if (original.source !== sourceUrl || original.line !== sourcePosition.line + 1 || + original.column !== sourcePosition.character) return undefined; + + const lineLengths = coverage.cache.lineLengths; + if (lineLengths.some(length => !Number.isSafeInteger(length) || length < 0) || + generated.line > lineLengths.length || generated.column > (lineLengths[generated.line - 1] ?? -1)) { + return undefined; + } + + const offset = lineLengths.slice(0, generated.line - 1).reduce((sum, length) => sum + length + 1, 0) + + generated.column; + const functions = coverage.script.functions.filter(fn => fn.isBlockCoverage && + fn.ranges.length > 0 && contains(fn.ranges[0] as V8Range, offset)); + const orderedFunctions = functions.sort((left, right) => + rangeLength(left.ranges[0] as V8Range) - rangeLength(right.ranges[0] as V8Range), + ); + const functionCoverage = orderedFunctions[0]; + if (functionCoverage === undefined) return undefined; + if (orderedFunctions[1] !== undefined && + rangeLength(orderedFunctions[1].ranges[0] as V8Range) === rangeLength(functionCoverage.ranges[0] as V8Range)) { + return undefined; + } + + const ranges = functionCoverage.ranges.filter(range => contains(range, offset)); + const orderedRanges = ranges.sort((left, right) => rangeLength(left) - rangeLength(right)); + const innermost = orderedRanges[0]; + if (innermost === undefined || orderedRanges[1] !== undefined && + rangeLength(orderedRanges[1]) === rangeLength(innermost)) return undefined; + + return Number.isSafeInteger(innermost.count) && innermost.count >= 0 ? innermost.count : undefined; +} + +function firstExecutable(statement: ts.Statement): ts.Statement | undefined { + if (ts.isBlock(statement)) return statement.statements[0]; + + return statement; +} + +function falseSuccessor(node: ts.IfStatement): ts.Statement | undefined { + const consequent = node.thenStatement; + const soleStatement = ts.isBlock(consequent) ? consequent.statements[0] : consequent; + const terminates = soleStatement !== undefined && + (!ts.isBlock(consequent) || consequent.statements.length === 1) && + (ts.isReturnStatement(soleStatement) || ts.isThrowStatement(soleStatement)); + if (!terminates) return undefined; + + const parent = node.parent; + if (!ts.isBlock(parent) && !ts.isSourceFile(parent)) return undefined; + + const index = parent.statements.indexOf(node); + + return index < 0 ? undefined : parent.statements[index + 1]; +} + +const isRepeatedOrExceptional = (node: ts.Node): boolean => + ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) || + ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isSwitchStatement(node) || + ts.isTryStatement(node) || ts.isConditionalExpression(node) || + ts.isBinaryExpression(node) && [ + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken, + ].includes(node.operatorToken.kind); + +function hasUnsupportedExpression(node: ts.Node): boolean { + if (isRepeatedOrExceptional(node)) return true; + + return ts.forEachChild(node, child => hasUnsupportedExpression(child) ? true : undefined) === true; +} + +function sourceRange(node: ts.Node, ast: ts.SourceFile): Range { + const start = ast.getLineAndCharacterOfPosition(node.getStart(ast)); + const end = ast.getLineAndCharacterOfPosition(node.getEnd()); + + return { + start: { line: start.line + 1, column: start.character }, + end: { line: end.line + 1, column: end.character }, + }; +} + +const contains = (range: V8Range, offset: number): boolean => + range.startOffset <= offset && offset < range.endOffset; + +const rangeLength = (range: V8Range): number => range.endOffset - range.startOffset; + +function diagnostic(sourcePath: string, kind: string, message: string): BranchDiagnostic { + return { code: `coverage.branch.${kind}`, message, path: sourcePath }; +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [rawDirectory, ...argumentsAfterRaw] = process.argv.slice(2); + if (rawDirectory === undefined) throw new Error('Raw V8 coverage directory is required'); + const identityArguments = argumentsAfterRaw.filter(argument => argument.startsWith('--generated-identity=')); + if (identityArguments.length > 1) throw new Error('Only one generated identity is supported'); + + const identityArgument = identityArguments[0]; + const generatedIdentity = identityArgument === undefined ? undefined + : JSON.parse(identityArgument.slice('--generated-identity='.length)) as GeneratedModuleIdentity; + const sourceRoots = argumentsAfterRaw.filter(argument => !argument.startsWith('--generated-identity=')); + + process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots, generatedIdentity))}\n`); +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts index 59caaf7117..e2fce65034 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts @@ -1,4 +1,5 @@ import { adapterDiagnostic } from './diagnostics.js'; +import { stringifyProtocolJson } from './protocol-json.js'; import { executeProperty, type FastCheckExecutionSuccess } from './execute-property.js'; import { ProtocolError, protocolError } from './js-value.js'; import type { ProtocolDiagnostic } from './js-value.js'; @@ -59,7 +60,7 @@ function protocolErrorResponse(error: ProtocolError): FastCheckExecutionFailure } async function writeResponse(response: FastCheckExecutionSuccess | FastCheckExecutionFailure): Promise { - const document = `${JSON.stringify(response)}\n`; + const document = `${stringifyProtocolJson(response)}\n`; await new Promise((resolve, reject) => { writeProtocolOutput(document, (error?: Error | null) => { diff --git a/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts b/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts index 39d3a400be..f7578c744e 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts @@ -134,7 +134,16 @@ export function decodeJsNumber(taggedNumber: unknown, path = 'number'): number { ); } - return bitsToDouble(taggedNumber.bits); + const finiteValue = bitsToDouble(taggedNumber.bits); + if (!Number.isFinite(finiteValue)) { + throw protocolError( + adapterDiagnostic.jsNumberEncodingInvalid, + 'Finite JavaScript numbers require finite IEEE-754 bits', + path, + ); + } + + return finiteValue; case 'nan': requireNoBits(taggedNumber, path); diff --git a/usvm-ts-fast-check/fast-check-adapter/src/local-source-closure.ts b/usvm-ts-fast-check/fast-check-adapter/src/local-source-closure.ts new file mode 100644 index 0000000000..16c84df32f --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/local-source-closure.ts @@ -0,0 +1,228 @@ +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import path from 'node:path' +import ts from 'typescript' + +interface LocalSourceClosure { + files: string[] + reasonCode?: string + diagnostic?: string +} + +/** Inspect source only. Never execute a project or consult a profile/model result. */ +export function inspectLocalSourceClosure(rootPath: string, entryPath: string): LocalSourceClosure { + const root = realpathSync(rootPath) + const pending = [path.resolve(root, path.relative(path.resolve(rootPath), path.resolve(entryPath)))] + const files = new Set() + const runtimeImportRanges = new Map>() + const sources = new Map() + const dependencies = new Map() + + const reject = (reasonCode: string, diagnostic: string): LocalSourceClosure => ({ + files: [...files].sort(), reasonCode, diagnostic, + }) + + while (pending.length > 0) { + const file = pending.shift()! + const relative = path.relative(root, file) + if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative) + || realpathSync(file) !== file) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `Source escapes the project or uses a symlink: ${file}`) + } + if (files.has(relative)) continue + files.add(relative) + + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + sources.set(file, source) + const edges: string[] = [] + dependencies.set(file, edges) + const imports: string[] = [] + const ranges: Array<{ start: number, end: number }> = [] + runtimeImportRanges.set(file, ranges) + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause + if (clause?.isTypeOnly) continue + if (clause && !clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings) + && clause.namedBindings.elements.length > 0 + && clause.namedBindings.elements.every(element => element.isTypeOnly)) continue + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `Nonliteral module specifier in ${relative}`) + } + imports.push(statement.moduleSpecifier.text) + ranges.push({ start: statement.getStart(source), end: statement.end }) + } else if (ts.isExportDeclaration(statement)) { + if (statement.isTypeOnly) continue + if (statement.exportClause && ts.isNamedExports(statement.exportClause) + && statement.exportClause.elements.length > 0 + && statement.exportClause.elements.every(element => element.isTypeOnly)) continue + ranges.push({ start: statement.getStart(source), end: statement.end }) + if (statement.moduleSpecifier) { + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `Nonliteral re-export in ${relative}`) + } + imports.push(statement.moduleSpecifier.text) + } + } else if (ts.isImportEqualsDeclaration(statement)) { + if (!statement.isTypeOnly) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `Import assignment in ${relative}`) + } + } else if (isAmbientRuntimeDeclaration(statement)) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', + `Ambient runtime declaration in ${relative} at offset ${statement.getStart(source)}`) + } else if (!safeModuleStatement(statement, source)) { + return reject('TOP_LEVEL_INITIALIZATION_UNSUPPORTED', + `Unsupported module initialization in ${relative} at offset ${statement.getStart(source)}`) + } + } + + let dynamicImport = false + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword + || ts.isIdentifier(node.expression) && node.expression.text === 'require')) dynamicImport = true + ts.forEachChild(node, visit) + } + visit(source) + if (dynamicImport) return reject('IMPORTED_CALLEES_UNSUPPORTED', `Dynamic import or require in ${relative}`) + + for (const specifier of imports) { + if (!specifier.startsWith('./') && !specifier.startsWith('../')) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `External or aliased module ${specifier} in ${relative}`) + } + const target = path.resolve(path.dirname(file), specifier) + if (existsSync(path.join(target, 'package.json'))) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `Directory package resolution is unsupported: ${specifier} in ${relative}`) + } + if (target.endsWith('.js') && existsSync(target)) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', `JavaScript runtime source is unsupported: ${specifier} in ${relative}`) + } + const candidates = (target.endsWith('.js') + ? [target.slice(0, -3) + '.ts'] + : [target, `${target}.ts`, path.join(target, 'index.ts')]) + .filter(candidate => candidate.endsWith('.ts') && !candidate.endsWith('.d.ts')) + .filter(candidate => existsSync(candidate) && statSync(candidate).isFile()) + if (candidates.length !== 1) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', + `Expected one local TypeScript module for ${specifier} in ${relative}, found ${candidates.length}`) + } + pending.push(candidates[0]!) + edges.push(candidates[0]!) + } + } + + // Replay overlays link dependency files to the original checkout. A back-import + // would then create a second entry-module instance with different mutable state. + if (hasDependencyCycle(dependencies)) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', 'Cyclic local module dependencies are unsupported by source replay') + } + + // Ask TypeScript to validate the imported bindings as well as the files. Diagnostics + // in unrelated function bodies do not determine closure admission. + if ([...runtimeImportRanges.values()].some(ranges => ranges.length > 0)) { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, noLib: true, noEmit: true, + allowImportingTsExtensions: true, types: [], + } + const host = ts.createCompilerHost(options) + const directories = new Set() + for (const file of sources.keys()) { + let directory = path.dirname(file) + while (directory.startsWith(root)) { + directories.add(directory) + if (directory === root) break + directory = path.dirname(directory) + } + } + host.fileExists = file => sources.has(path.resolve(file)) + host.readFile = file => sources.get(path.resolve(file))?.text + host.getSourceFile = file => sources.get(path.resolve(file)) + host.directoryExists = directory => directories.has(path.resolve(directory)) + host.getDirectories = () => [] + host.realpath = file => path.resolve(file) + const program = ts.createProgram([...sources.keys()], options, host) + for (const [file, ranges] of runtimeImportRanges) { + if (ranges.length === 0) continue + const source = program.getSourceFile(file)! + const invalidImport = program.getSemanticDiagnostics(source).find(diagnostic => + diagnostic.category === ts.DiagnosticCategory.Error && diagnostic.start !== undefined + && ranges.some(range => diagnostic.start! >= range.start && diagnostic.start! < range.end)) + if (invalidImport) { + return reject('IMPORTED_CALLEES_UNSUPPORTED', + `${path.relative(root, file)}: ${ts.flattenDiagnosticMessageText(invalidImport.messageText, '\n')}`) + } + } + } + + return { files: [...files].sort() } +} + +function hasDependencyCycle(dependencies: Map): boolean { + const active = new Set() + const visited = new Set() + const visit = (file: string): boolean => { + if (active.has(file)) return true + if (visited.has(file)) return false + active.add(file) + for (const dependency of dependencies.get(file) ?? []) { + if (visit(dependency)) return true + } + active.delete(file) + visited.add(file) + return false + } + return [...dependencies.keys()].some(visit) +} + +function isAmbientRuntimeDeclaration(statement: ts.Statement): boolean { + if (!ts.isVariableStatement(statement) && !ts.isFunctionDeclaration(statement) + && !ts.isClassDeclaration(statement) && !ts.isEnumDeclaration(statement)) return false + return statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DeclareKeyword) ?? false +} + +function safeModuleStatement(statement: ts.Statement, source: ts.SourceFile): boolean { + if (ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) || ts.isEmptyStatement(statement)) return true + if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) return true + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.every(declaration => ts.isIdentifier(declaration.name) + && (!declaration.initializer || safeInitializer(declaration.initializer, source))) + } + if (ts.isExportAssignment(statement)) return !statement.isExportEquals && safeInitializer(statement.expression, source) + if (ts.isClassDeclaration(statement)) { + if (statement.heritageClauses?.length || ts.getDecorators(statement)?.length) return false + return statement.members.every(member => !ts.isClassStaticBlockDeclaration(member) + && !(member.name && ts.isComputedPropertyName(member.name)) + && !(ts.canHaveDecorators(member) && ts.getDecorators(member)?.length) + && !(ts.isPropertyDeclaration(member) && member.initializer + && member.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword))) + } + if (ts.isEnumDeclaration(statement)) { + return statement.members.every(member => !member.initializer || ts.isLiteralExpression(member.initializer)) + } + return false +} + +function safeInitializer(expression: ts.Expression, source: ts.SourceFile, seen = new Set()): boolean { + if (seen.has(expression)) return false + seen.add(expression) + if (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) + || ts.isTypeAssertionExpression(expression) || ts.isSatisfiesExpression(expression) + || ts.isNonNullExpression(expression)) return safeInitializer(expression.expression, source, seen) + if (ts.isLiteralExpression(expression) || ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) + || expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword + || expression.kind === ts.SyntaxKind.NullKeyword) return true + if (ts.isPrefixUnaryExpression(expression) + && [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken, ts.SyntaxKind.ExclamationToken, ts.SyntaxKind.TildeToken] + .includes(expression.operator)) return safeInitializer(expression.operand, source, seen) + if (!ts.isIdentifier(expression)) return false + for (const statement of source.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === expression.text) return Boolean(statement.body) + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.name.text === expression.text && declaration.initializer + && declaration.getStart(source) < expression.getStart(source) + && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return true + } + } + return false +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts b/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts new file mode 100644 index 0000000000..d54bed0d30 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts @@ -0,0 +1,52 @@ +// Protocol responses contain JSON data only. Walk them without consulting an +// object's toJSON method, including one inherited by an internal array. +const stringifyScalar = JSON.stringify.bind(JSON); +const ownKeys = Object.keys.bind(Object); +const isArray = Array.isArray.bind(Array); + +export function stringifyProtocolJson(value: unknown): string { + const ancestors = new Set(); + + function serialize(current: unknown, inArray: boolean): string | undefined { + if (current === null || typeof current === 'string' || typeof current === 'boolean' + || typeof current === 'number') { + return stringifyScalar(current); + } + if (current === undefined || typeof current === 'function' || typeof current === 'symbol') { + return inArray ? 'null' : undefined; + } + if (typeof current !== 'object') throw new TypeError('Unsupported protocol JSON value'); + if (ancestors.has(current)) throw new TypeError('Circular protocol JSON value'); + + ancestors.add(current); + try { + if (isArray(current)) { + let elements = '['; + for (let index = 0; index < current.length; index += 1) { + if (index > 0) elements += ','; + elements += serialize(current[index], true); + } + + return `${elements}]`; + } + + let fields = '{'; + for (const key of ownKeys(current)) { + const encoded = serialize((current as Record)[key], false); + if (encoded !== undefined) { + if (fields.length > 1) fields += ','; + fields += `${stringifyScalar(key)}:${encoded}`; + } + } + + return `${fields}}`; + } finally { + ancestors.delete(current); + } + } + + const encoded = serialize(value, false); + if (encoded === undefined) throw new TypeError('Protocol response has no JSON representation'); + + return encoded; +} 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..468a5cc615 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts @@ -0,0 +1,323 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { inspectLocalSourceClosure } from './local-source-closure.js' + +if (process.argv[2] === 'inspect-local-closure') { + const root = process.argv[3] + const entry = process.argv[4] + if (!root || !entry) throw new Error('Expected project root and entry source') + process.stdout.write(JSON.stringify(inspectLocalSourceClosure(root, entry))) + process.exit(0) +} + +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/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts new file mode 100644 index 0000000000..259b8fb25a --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import ts from 'typescript'; +import { convertV8IfBranches, type GeneratedModuleIdentity } from '../src/coverage-branches.js'; + +const adapterRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url)), '..'); +const c8 = path.join(adapterRoot, 'node_modules/c8/bin/c8.js'); + +async function runCoverage(source: string): Promise<{ + root: string; + raw: string; + sourcePath: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'usvm-branch-test-')); + const sourcePath = path.join(root, 'subject.ts'); + const raw = path.join(root, 'raw'); + await writeFile(sourcePath, source); + + const result = spawnSync(process.execPath, [ + c8, + '--reporter=json', + `--reports-dir=${path.join(root, 'report')}`, + `--temp-directory=${raw}`, + '--allowExternal', + '--exclude=__usvm_no_default_excludes__', + process.execPath, + '--import', + 'tsx', + sourcePath, + ], { cwd: adapterRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + + return { root, raw, sourcePath }; +} + +async function runGeneratedCoverage(source: string): Promise<{ + root: string; + raw: string; + identity: GeneratedModuleIdentity; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'usvm-generated-branch-')); + const sourceRoot = path.join(root, 'original'); + const generatedRoot = path.join(root, 'generated'); + await Promise.all([mkdir(sourceRoot), mkdir(generatedRoot)]); + const originalPath = path.join(sourceRoot, 'subject.ts'); + const generatedModule = path.join(generatedRoot, 'subject.mjs'); + const raw = path.join(root, 'raw'); + await writeFile(originalPath, source); + + const output = ts.transpileModule(source, { + fileName: originalPath, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + sourceMap: true, inlineSources: true }, + }); + const map = JSON.parse(output.sourceMapText ?? '{}') as Record; + map.file = path.basename(generatedModule); + map.sourceRoot = ''; + map.sources = [originalPath]; + const sourceMap = JSON.stringify(map); + const generatedSource = output.outputText.replace(/\/\/# sourceMappingURL=.*$/m, + `//# sourceMappingURL=${path.basename(generatedModule)}.map`); + await writeFile(generatedModule, generatedSource); + await writeFile(`${generatedModule}.map`, sourceMap); + + const result = spawnSync(process.execPath, [ + c8, '--reporter=json', `--reports-dir=${path.join(root, 'report')}`, + `--temp-directory=${raw}`, '--exclude-after-remap', '--allowExternal', + '--exclude=__usvm_no_default_excludes__', process.execPath, generatedModule, + ], { cwd: adapterRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + + const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + return { root, raw, identity: { + originalPath: await realpath(originalPath), originalSha256: sha256(source), + generatedModule: await realpath(generatedModule), + generatedSha256: sha256(generatedSource), sourceMapSha256: sha256(sourceMap), + } }; +} + +test('generated JavaScript maps only original ordered if arms across true, false, and mixed runs', async () => { + for (const [calls, hits] of [ + ['subject(1);', [1, 0]], + ['subject(-1);', [0, 1]], + ['subject(1); subject(-1);', [1, 1]], + ] as const) { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) { return true; } + else { return false; } +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [path.dirname(fixture.identity.originalPath)], + fixture.identity); + + assert.deepEqual(report.files.map(file => file.path), [fixture.identity.originalPath]); + assert.deepEqual(report.files[0]?.branches.map(branch => branch.arms.map(arm => arm.hits)), [hits]); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('missing, duplicate, and changed generated maps cannot produce exact branch counts', async () => { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) return true; + else return false; +} +subject(1); +`); + + try { + const sourceRoot = path.dirname(fixture.identity.originalPath); + const changed = await convertV8IfBranches(fixture.raw, [sourceRoot], { + ...fixture.identity, generatedSha256: '0'.repeat(64), + }); + assert.deepEqual(changed.files, []); + assert.equal(changed.diagnostics[0]?.code, 'coverage.branch.unsupported'); + + const reports = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + const reportPath = path.join(fixture.raw, reports[0] as string); + const rawReport = JSON.parse(await readFile(reportPath, 'utf8')) as { + result?: { url: string }[]; + 'source-map-cache'?: Record; + }; + const generatedUrl = rawReport.result?.find(script => script.url.includes('/subject.mjs'))?.url; + assert.ok(generatedUrl); + + delete rawReport['source-map-cache']?.[generatedUrl]; + await writeFile(reportPath, JSON.stringify(rawReport)); + const missing = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(missing.files, []); + assert.equal(missing.diagnostics.some(entry => entry.code === 'coverage.branch.unknown'), true); + + await cp(reportPath, path.join(fixture.raw, 'duplicate.json')); + const duplicate = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(duplicate.files, []); + assert.equal(duplicate.diagnostics.some(entry => entry.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('real c8 and tsx preserve true, false, mixed, and nested TypeScript arms', async () => { + for (const [calls, expected] of [ + ['nested(20);', [[1, 0], [1, 0]]], + ['nested(-1);', [[0, 1], [0, 0]]], + ['nested(20); nested(5); nested(-1);', [[2, 1], [1, 1]]], + ] as const) { + const fixture = await runCoverage(` +export function nested(value: number): string { + if (value > 0) { + if (value > 10) { + return 'large'; + } else { + return 'small'; + } + } else { + return 'negative'; + } +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + const branches = report.files[0]?.branches ?? []; + + assert.deepEqual(branches.map(branch => branch.arms.map(arm => arm.hits)), expected); + assert.deepEqual(branches.map(branch => branch.location.start.line), [3, 4]); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('if without else observes an actual false successor and rejects throwing conditions', async () => { + const fixture = await runCoverage(` +function gate(value: number): boolean { + if (value < 0) throw new Error('guard failed'); + return value > 0; +} +function subject(value: number): number { + if (gate(value)) { + return 1; + } + return 0; +} +for (const value of [-1, 1]) { + try { subject(value); } catch { /* expected */ } +} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.equal(report.files[0]?.branches.some(branch => branch.location.start.line === 7), false); + assert.equal(report.diagnostics.some(diagnostic => diagnostic.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('if without else maps both arms when the true arm terminates', async () => { + for (const [calls, expected] of [ + ['subject(1);', [1, 0]], + ['subject(-1);', [0, 1]], + ['subject(1); subject(-1);', [1, 1]], + ] as const) { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) { + return 1; + } + return 0; +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(report.files[0]?.branches[0]?.arms.map(arm => arm.hits), expected); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('duplicate V8 scripts remain ambiguous', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) return 1; + else return 0; +} +subject(1); +`); + + try { + const reports = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + let scriptReport: string | undefined; + for (const name of reports) { + const report = JSON.parse(await readFile(path.join(fixture.raw, name), 'utf8')) as { + result?: { url: string }[]; + }; + + if (report.result?.some(script => script.url.endsWith('/subject.ts'))) { + scriptReport = name; + break; + } + } + assert.ok(scriptReport); + await cp(path.join(fixture.raw, scriptReport), path.join(fixture.raw, 'duplicate.json')); + + const duplicate = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(duplicate.files, []); + assert.equal(duplicate.diagnostics[0]?.code, 'coverage.branch.ambiguous'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('unmapped source-map positions do not produce branch counts', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + if (value > 0) return 1; + else return 0; +} +subject(1); +`); + + try { + let changed = 0; + for (const name of await readdir(fixture.raw)) { + if (!name.endsWith('.json')) continue; + + const reportPath = path.join(fixture.raw, name); + const report = JSON.parse(await readFile(reportPath, 'utf8')) as { + 'source-map-cache'?: Record; + }; + const entry = Object.entries(report['source-map-cache'] ?? {}) + .find(([url]) => url.endsWith('/subject.ts'))?.[1]; + if (entry === undefined) continue; + + entry.data.mappings = ''; + await writeFile(reportPath, JSON.stringify(report)); + changed += 1; + } + assert.ok(changed > 0); + + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + assert.equal(converted.diagnostics[0]?.code, 'coverage.branch.ambiguous'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('function, script, switch, conditional, and logical ranges are not if arms', async () => { + const fixture = await runCoverage(` +function subject(value: number): boolean { + switch (value) { + case 1: return value > 0 && value < 2; + default: return value === 0 ? true : false; + } +} +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('if ranges inside loops are explicitly unsupported', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + for (let index = 0; index < 2; index += 1) { + if (value > index) return index; + else value += 1; + } + return -1; +} +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + + assert.deepEqual(converted.files[0]?.branches, []); + assert.equal(converted.diagnostics[0]?.code, 'coverage.branch.unsupported'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('early exits and UTF-16 columns retain exact source positions', async () => { + const fixture = await runCoverage(` +function subject(value: number): number { + const marker = 'πŸ˜€'; if (value < 0) return -1; + if (value > 0) return marker.length; + return 0; +} +subject(-1); +subject(1); +`); + + try { + const converted = await convertV8IfBranches(fixture.raw, [fixture.root]); + const branches = converted.files[0]?.branches ?? []; + + assert.deepEqual(branches.map(branch => branch.arms.map(arm => arm.hits)), [[1, 1], [1, 0]]); + assert.equal(branches[0]?.location.start.column, 27); + assert.deepEqual(converted.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts index 1128155aef..f8c5339820 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts @@ -40,6 +40,27 @@ test('execution CLI reports malformed JSON without crashing', async () => { assert.equal(response.diagnostics[0]?.code, 'protocol.json.invalid'); }); +test('execution CLI keeps one JSON response after a predicate adds inherited toJSON', async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); + try { + await writeFile(sourceRoot + '/property.ts', ` +export function predicate(value: boolean) { + Object.prototype.toJSON = () => { throw new Error('sentinel toJSON'); }; + return value; +} +`); + + const invocation = await invokeCli(JSON.stringify(executionRequest(sourceRoot))); + const response = JSON.parse(invocation.stdout) as Record; + + assert.equal(invocation.exitCode, 0); + assert.equal(invocation.stdout.trim().split('\n').length, 1); + assert.equal(response.status, 'ok'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } +}); + test('execution CLI keeps user logging outside the protocol response', async () => { const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); try { diff --git a/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts index 15154526fd..2cf1b03299 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts @@ -61,6 +61,22 @@ test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits } }); +test('finite tags reject nonfinite bit patterns at the tagged input boundary', () => { + for (const bits of ['7ff0000000000000', 'fff0000000000000', '7ff8000000000000']) { + assert.throws( + () => decodeJsValue({ kind: 'number', value: 'finite', bits }), + /js-number\.encoding\.invalid/, + ); + assert.throws( + () => decodeJsValue({ + kind: 'array', + elements: [{ kind: 'array', elements: [{ kind: 'number', value: 'finite', bits }] }], + }), + /js-number\.encoding\.invalid/, + ); + } +}); + test('unknown tagged value kinds are rejected explicitly', () => { assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/); }); diff --git a/usvm-ts-fast-check/fast-check-adapter/test/local-source-closure.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/local-source-closure.test.ts new file mode 100644 index 0000000000..b8441521cd --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/test/local-source-closure.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' +import ts from 'typescript' +import { inspectLocalSourceClosure } from '../src/local-source-closure.js' + +function workspace(files: Record, action: (root: string) => void): void { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'usvm-closure-test-'))) + try { + for (const [relative, source] of Object.entries(files)) { + const file = path.join(root, relative) + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, source) + } + action(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('local closure follows reexports while ignoring unrelated effects and type imports', () => { + workspace({ + 'entry.ts': "import { helper } from './barrel'; export function entry() { return helper(); }", + 'barrel.ts': "export { helper } from './nested/helper.js';", + 'nested/helper.ts': "import type { Missing } from 'external'; " + + 'export function helper() { return 1; }', + 'unrelated.ts': "throw new Error('unrelated');", + }, root => { + assert.deepEqual(inspectLocalSourceClosure(root, path.join(root, 'entry.ts')), { + files: ['barrel.ts', 'entry.ts', 'nested/helper.ts'], + }) + }) +}) + +for (const [name, entry, helper, expected] of [ + ['external import', "import { x } from 'node:fs';", '', 'IMPORTED_CALLEES_UNSUPPORTED'], + ['missing module', "import { helper } from './missing';", '', 'IMPORTED_CALLEES_UNSUPPORTED'], + ['missing export', "import { missing } from './helper';", 'export const helper = 1;', 'IMPORTED_CALLEES_UNSUPPORTED'], + ['side effect dependency', "import './helper';", 'console.log(1);', 'TOP_LEVEL_INITIALIZATION_UNSUPPORTED'], + ['top level call', 'const value = Math.random();', '', 'TOP_LEVEL_INITIALIZATION_UNSUPPORTED'], + ['dynamic import', "export function load() { return import('./helper'); }", '', 'IMPORTED_CALLEES_UNSUPPORTED'], + ['require', "export function load() { return require('./helper'); }", '', 'IMPORTED_CALLEES_UNSUPPORTED'], +] as const) { + test(`local closure rejects ${name}`, () => { + workspace({ 'entry.ts': entry, 'helper.ts': helper }, root => { + assert.equal(inspectLocalSourceClosure(root, path.join(root, 'entry.ts')).reasonCode, expected) + }) + }) +} + +for (const [name, declaration] of [ + ['constant', 'export declare const helper: () => number;'], + ['function', 'export declare function helper(): number;'], + ['class', 'export declare class helper { value(): number; }'], +] as const) { + test(`local closure rejects ambient runtime ${name} exports`, () => { + workspace({ + 'entry.ts': "import { helper } from './helper'; export function entry() { return helper(); }", + 'helper.ts': declaration, + }, root => { + const result = inspectLocalSourceClosure(root, path.join(root, 'entry.ts')) + assert.equal(result.reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + assert.match(result.diagnostic!, /Ambient runtime declaration/) + }) + }) +} + +test('local closure rejects ambiguous extensionless imports', () => { + workspace({ + 'entry.ts': "import './helper';", + 'helper.ts': 'export const x = 1;', + 'helper/index.ts': 'export const x = 2;', + }, root => { + assert.equal(inspectLocalSourceClosure(root, path.join(root, 'entry.ts')).reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + }) +}) + +test('local closure accepts a symlink alias for the root but rejects dependency symlinks', () => { + workspace({ 'real/entry.ts': 'export function value() { return 1; }' }, root => { + const alias = path.join(root, 'alias') + symlinkSync(path.join(root, 'real'), alias) + assert.deepEqual(inspectLocalSourceClosure(alias, path.join(alias, 'entry.ts')), { files: ['entry.ts'] }) + + writeFileSync(path.join(root, 'entry.ts'), "import './link';") + symlinkSync(path.join(root, 'real/entry.ts'), path.join(root, 'link.ts')) + assert.equal(inspectLocalSourceClosure(root, path.join(root, 'entry.ts')).reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + }) +}) + +test('local closure rejects a local export of an absent binding', () => { + workspace({ + 'entry.ts': "import { missing } from './barrel'; export function entry() { return missing(); }", + 'barrel.ts': 'export { missing };', + }, root => { + assert.equal(inspectLocalSourceClosure(root, path.join(root, 'entry.ts')).reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + }) +}) + +test('local closure rejects package directory redirection before checking different source files', () => { + workspace({ + 'entry.ts': "import { helper } from './lib'; export function entry() { return helper(); }", + 'lib/index.ts': 'export const wrongExport = 1;', + 'lib/actual.ts': 'export function helper() { return 2; }', + 'lib/package.json': '{"main":"./actual.ts"}', + }, root => { + const result = inspectLocalSourceClosure(root, path.join(root, 'entry.ts')) + assert.equal(result.reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + assert.match(result.diagnostic!, /Directory package resolution/) + }) +}) + +test('local closure rejects a cycle that would split mutable module state in replay', () => { + workspace({ + 'entry.ts': "import { readCount } from './helper'; export let count = 0; " + + 'export function entry(value: number) { count = value; return readCount() === value; }', + 'helper.ts': "import { count } from './entry'; export function readCount() { return count; }", + }, root => { + const result = inspectLocalSourceClosure(root, path.join(root, 'entry.ts')) + assert.equal(result.reasonCode, 'IMPORTED_CALLEES_UNSUPPORTED') + assert.match(result.diagnostic!, /Cyclic local module/) + }) +}) + +test('binding inspection does not read ignored type imports outside the runtime closure', () => { + workspace({ + 'project/entry.ts': "import type { Outside } from '../outside'; import { helper } from './helper'; " + + 'export function entry() { return helper(); }', + 'project/helper.ts': 'export function helper() { return 1; }', + 'outside.ts': 'export interface Outside { value: number }', + }, root => { + const originalRead = ts.sys.readFile + const reads: string[] = [] + ts.sys.readFile = file => { reads.push(path.resolve(file)); return originalRead(file) } + try { + const project = path.join(root, 'project') + assert.deepEqual(inspectLocalSourceClosure(project, path.join(project, 'entry.ts')), { + files: ['entry.ts', 'helper.ts'], + }) + assert.equal(reads.includes(path.join(root, 'outside.ts')), false) + } finally { + ts.sys.readFile = originalRead + } + }) +}) diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt index 00a80ff686..9bfb02c65b 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/cli/FastCheckCli.kt @@ -3,7 +3,6 @@ package org.usvm.ts.pbt.cli import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.CoverageCapabilityLevel import org.usvm.ts.pbt.backend.PropertyBasedTestingBackend @@ -15,6 +14,7 @@ import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import org.usvm.ts.pbt.registry.DuplicatePropertyIdException import org.usvm.ts.pbt.registry.PropertyRegistry import org.usvm.ts.pbt.registry.PropertyRegistryProvider @@ -127,7 +127,7 @@ class FastCheckCli( requireCoverageSupport(options, backend) val results = properties.map { property -> backend.run(property, configuration) } - output.appendLine(PropertyManifestJson.json.encodeToString(results)) + output.appendLine(PropertyManifestJson.json.encodeToUtf8SafeString(results)) val hasPropertyFailure = results.any { result -> result.status == PropertyRunStatus.FAILURE } return if (hasPropertyFailure) { @@ -311,7 +311,7 @@ class FastCheckCli( kind = kind, ) - errors.appendLine(PropertyManifestJson.json.encodeToString(diagnostic)) + errors.appendLine(PropertyManifestJson.json.encodeToUtf8SafeString(diagnostic)) } private companion object { diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt index 7b2d95510d..01f4233986 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt @@ -7,7 +7,6 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.JsNumberKind import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.contains import org.usvm.ts.pbt.validation.requireValid @@ -104,7 +103,7 @@ class FastCheckBackend( value: JsConcreteValue, path: String, ) { - if (value is JsConcreteValue.Number && !hasValidEncoding(value)) { + if (value is JsConcreteValue.Number && !value.number.hasValidEncoding()) { throw invalidRequest( code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_VALUE_INVALID, message = "Explicit example contains an invalid tagged JavaScript number", @@ -124,11 +123,6 @@ class FastCheckBackend( } } - private fun hasValidEncoding(value: JsConcreteValue.Number): Boolean = when (value.number.value) { - JsNumberKind.FINITE -> value.number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true - else -> value.number.bits == null - } - private fun invalidRequest( code: String, message: String, @@ -145,8 +139,6 @@ class FastCheckBackend( companion object { const val FAST_CHECK_BACKEND_ID = "fast-check" - private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") - private fun canonicalizeSourceRoots(sourceRoots: List): List { if (sourceRoots.isEmpty()) { throw PbtBackendException( diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt index d4567c9f77..acaf37c560 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt @@ -1,17 +1,22 @@ package org.usvm.ts.pbt.fastcheck +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString import org.usvm.ts.pbt.FastCheckDiagnosticCode +import org.usvm.ts.pbt.backend.BranchCoverage +import org.usvm.ts.pbt.backend.CoverageDiagnostic import org.usvm.ts.pbt.backend.CoverageScope +import org.usvm.ts.pbt.backend.PropertyCoverageArtifact import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.coverage.CoverageArtifactException import org.usvm.ts.pbt.coverage.IstanbulCoverageContext import org.usvm.ts.pbt.coverage.decodeIstanbulCoverageReport import org.usvm.ts.pbt.coverage.inspectRawV8SourceMapDiagnostics import org.usvm.ts.pbt.coverage.mergeCoverageDiagnostics +import org.usvm.ts.pbt.manifest.PropertyManifestJson import java.io.IOException import java.nio.file.Files import java.nio.file.Path -import java.util.concurrent.TimeUnit /** Prepared c8 invocation and the temporary artifacts produced by one property run. */ internal class FastCheckCoverageSession private constructor( @@ -68,7 +73,15 @@ internal class FastCheckCoverageSession private constructor( rawDiagnostics = rawDiagnostics, ) - finalArtifact.copy(diagnostics = diagnostics) + val branchReport = if (finalArtifact.files.isEmpty()) { + RawBranchReport(files = emptyList(), diagnostics = emptyList()) + } else { + convertBranches() + } + appendExactBranches( + artifact = finalArtifact.copy(diagnostics = diagnostics), + report = branchReport, + ) } catch (error: CoverageArtifactException) { fail( code = error.diagnostic.code, @@ -96,6 +109,71 @@ internal class FastCheckCoverageSession private constructor( return paths } + private fun convertBranches(): RawBranchReport { + val converter = workspace.adapterRoot.resolve("dist/src/coverage-branches.js") + val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = 1, + maxStdoutBytes = MAX_BRANCH_REPORT_BYTES, + maxStderrBytes = MAX_BRANCH_ERROR_BYTES, + shutdownGraceMillis = BRANCH_SHUTDOWN_GRACE_MILLIS, + ) + val output = try { + transport.invoke( + command = listOf(nodeExecutable, converter.toString(), workspace.rawDirectory.toString()) + + request.sourceRoots, + request = "", + timeoutMillis = BRANCH_CONVERSION_TIMEOUT_MILLIS, + reportedTimeoutMillis = BRANCH_CONVERSION_TIMEOUT_MILLIS, + description = "TypeScript branch converter", + ) + } catch (error: FastCheckTransportException) { + return unsupportedBranches("Cannot convert TypeScript branches: ${error.message}") + } + if (output.exitCode != 0) { + return unsupportedBranches("TypeScript branch conversion failed: ${output.stderr.trim()}") + } + + return runCatching { + PropertyManifestJson.json.decodeFromString(output.stdout) + }.getOrElse { error -> + unsupportedBranches("Cannot read TypeScript branch coverage: ${error.message}") + } + } + + private fun appendExactBranches( + artifact: PropertyCoverageArtifact, + report: RawBranchReport, + ): PropertyCoverageArtifact { + val exactByPath = report.files.associate { file -> file.path to file.branches } + val retainedPaths = artifact.files.mapTo(hashSetOf()) { file -> file.path } + val files = artifact.files.map { file -> + val nextId = (file.branches.maxOfOrNull(BranchCoverage::branchId) ?: -1) + 1 + val exactBranches = exactByPath[file.path].orEmpty().mapIndexed { index, branch -> + branch.copy(branchId = nextId + index) + } + + file.copy(branches = file.branches + exactBranches) + } + + return artifact.copy( + files = files, + diagnostics = artifact.diagnostics + report.diagnostics.filter { diagnostic -> + diagnostic.path == null || diagnostic.path in retainedPaths + }, + ) + } + + private fun unsupportedBranches(message: String) = RawBranchReport( + files = emptyList(), + diagnostics = listOf( + CoverageDiagnostic( + code = "coverage.branch.unsupported", + message = message, + ), + ), + ) + override fun close() { workspace.root.toFile().deleteRecursively() } @@ -172,59 +250,46 @@ internal class FastCheckCoverageSession private constructor( nodeExecutable: String, request: FastCheckExecutionRequest, ): String { - val process = try { - ProcessBuilder(nodeExecutable, "--version").start() - } catch (error: IOException) { - failPreparation( - request = request, - code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version: ${error.message}", - cause = error, + val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = 1, + maxStdoutBytes = MAX_VERSION_OUTPUT_BYTES, + maxStderrBytes = MAX_VERSION_OUTPUT_BYTES, + shutdownGraceMillis = VERSION_SHUTDOWN_GRACE_MILLIS, + useProcessSupervisor = false, + ) + val output = try { + transport.invoke( + command = listOf(nodeExecutable, "--version"), + request = "", + timeoutMillis = NODE_VERSION_TIMEOUT_MILLIS, + reportedTimeoutMillis = NODE_VERSION_TIMEOUT_MILLIS, + description = "Node.js version probe", ) - } - - try { - awaitNodeVersion(process, request) - val version = process.inputStream.bufferedReader(Charsets.UTF_8).use { input -> - input.readLine().orEmpty().trim() - } - if (process.exitValue() != 0 || version.isBlank()) { - failPreparation( - request = request, - code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version", - ) - } - - return requireSupportedNodeVersion(version, request) - } finally { - runCatching { process.outputStream.close() } - runCatching { process.inputStream.close() } - runCatching { process.errorStream.close() } - } - } - - private fun awaitNodeVersion(process: Process, request: FastCheckExecutionRequest) { - val completed = try { - process.waitFor(NODE_VERSION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() + } catch (error: FastCheckTransportException) { + val interrupted = error.code == FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED failPreparation( request = request, - code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while querying the Node.js runtime version", - kind = BackendErrorKind.PROCESS_FAILURE, + code = if (interrupted) { + error.code + } else { + FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE + }, + message = "Cannot query the Node.js runtime version: ${error.message}", + kind = if (interrupted) BackendErrorKind.PROCESS_FAILURE else BackendErrorKind.COVERAGE, cause = error, ) } - if (!completed) { - process.destroyForcibly() + val version = output.stdout.trim() + if (output.exitCode != 0 || version.isBlank()) { failPreparation( request = request, code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Timed out while querying the Node.js runtime version", + message = "Cannot query the Node.js runtime version", ) } + + return requireSupportedNodeVersion(version, request) } private fun requireSupportedNodeVersion(version: String, request: FastCheckExecutionRequest): String { @@ -270,12 +335,30 @@ internal class FastCheckCoverageSession private constructor( ) private const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L + private const val VERSION_SHUTDOWN_GRACE_MILLIS = 100L + private const val MAX_VERSION_OUTPUT_BYTES = 1_000 + private const val BRANCH_CONVERSION_TIMEOUT_MILLIS = 10_000L + private const val BRANCH_SHUTDOWN_GRACE_MILLIS = 500L + private const val MAX_BRANCH_REPORT_BYTES = 10_000_000 + private const val MAX_BRANCH_ERROR_BYTES = 1_000 private const val MINIMUM_NODE_MAJOR_VERSION = 18 private const val MINIMUM_NODE_MINOR_VERSION = 18 private val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") } } +@Serializable +private data class RawBranchReport( + val files: List, + val diagnostics: List, +) + +@Serializable +private data class RawBranchFile( + val path: String, + val branches: List, +) + private data class CoverageWorkspace( val root: Path, val configPath: Path, diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt index 43d3980eed..5f71da5460 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClient.kt @@ -1,11 +1,11 @@ package org.usvm.ts.pbt.fastcheck import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.PropertyId +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import java.nio.file.Path /** Encodes one execution request and validates the private fast-check adapter response. */ @@ -74,7 +74,7 @@ internal class FastCheckProcessClient( } private fun encodeRequest(request: FastCheckExecutionRequest): String { - val encodedRequest = PropertyManifestJson.json.encodeToString(request) + val encodedRequest = PropertyManifestJson.json.encodeToUtf8SafeString(request) if (encodedRequest.toByteArray(Charsets.UTF_8).size > MAX_REQUEST_BYTES) { throw backendError( diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt index fa2cb7205f..72448b653f 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt @@ -39,6 +39,7 @@ internal class FastCheckProcessTransport( private val maxStdoutBytes: Int, private val maxStderrBytes: Int, private val shutdownGraceMillis: Long, + private val useProcessSupervisor: Boolean = true, ) { init { require(maxRequestBytes > 0) { "Maximum request size must be positive" } @@ -91,7 +92,7 @@ internal class FastCheckProcessTransport( tasks.all.forEach { task -> task.cancel() } terminate(managedProcess, deadlineNanos) closeStreams(managedProcess.process) - runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } + managedProcess.processGroupFile?.let { file -> runCatching { Files.deleteIfExists(file) } } executor.shutdownNow() } } @@ -193,6 +194,16 @@ internal class FastCheckProcessTransport( supervisedCommand: List, description: String, ): SupervisedProcessHandle { + if (!useProcessSupervisor) { + val process = try { + ProcessBuilder(supervisedCommand).start() + } catch (error: IOException) { + processStartFailure(description, error) + } + + return SupervisedProcessHandle(process = process, processGroupFile = null) + } + val processGroupFile = try { Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid") } catch (error: IOException) { @@ -228,6 +239,13 @@ internal class FastCheckProcessTransport( private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) { val process = managedProcess.process + if (managedProcess.processGroupFile == null) { + process.toHandle().descendants().forEach { child -> child.destroyForcibly() } + if (process.isAlive) process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + return + } + if (!process.isAlive) { forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) return @@ -274,6 +292,15 @@ internal class FastCheckProcessTransport( } private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + val wasInterrupted = Thread.interrupted() + try { + return pollProcessExit(process, deadlineNanos) + } finally { + if (wasInterrupted) Thread.currentThread().interrupt() + } + } + + private fun pollProcessExit(process: Process, deadlineNanos: Long): Boolean { while (process.isAlive) { val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) if (waitMillis == 0L) return false @@ -318,7 +345,7 @@ internal class FastCheckProcessTransport( private data class SupervisedProcessHandle( val process: Process, - val processGroupFile: Path, + val processGroupFile: Path?, ) private data class ProcessIoTasks( diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt index c00614d8d4..adc64a1ffa 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProjectionClient.kt @@ -1,10 +1,10 @@ package org.usvm.ts.pbt.fastcheck import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.FastCheckDiagnosticCode import org.usvm.ts.pbt.manifest.PropertyManifestJson import org.usvm.ts.pbt.model.contains +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import java.nio.file.Path /** Limits for one projection request to the private Node adapter. */ @@ -55,7 +55,7 @@ class FastCheckProjectionClient private constructor( fun sample(request: FastCheckProjectionRequest): FastCheckProjectionResponse { validateRequest(request) - val encodedRequest = PropertyManifestJson.json.encodeToString(request) + val encodedRequest = PropertyManifestJson.json.encodeToUtf8SafeString(request) val output = invokeAdapter(encodedRequest) val response = decodeResponse(output) 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..d830ba0347 --- /dev/null +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt @@ -0,0 +1,153 @@ +package org.usvm.ts.pbt.fastcheck + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.nio.file.Path + +@Serializable +data class TypeScriptLocalSourceClosure( + val files: List, + val reasonCode: String? = null, + val diagnostic: String? = null, +) + +enum class TypeScriptCompletedReturnTargetKind { + EXPRESSION_ARROW, + RETURN_STATEMENT, +} + +/** Exact TypeScript-AST validation and instrumentation for completed-return source targets. */ +object TypeScriptSourceInspector { + fun localSourceClosure(sourceRoot: Path, source: Path): TypeScriptLocalSourceClosure { + val output = invokeCommand( + command = listOf( + "node", + FastCheckRuntime.sourceInspectorEntryPoint().toString(), + "inspect-local-closure", + sourceRoot.toString(), + source.toString(), + ), + nodeExecutable = "node", + ) + return Json.decodeFromString(output) + } + + 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) + } + return invokeCommand(command = command, nodeExecutable = nodeExecutable) + } + + private fun invokeCommand(command: List, nodeExecutable: String): String { + 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-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt index 764c6fefcb..22d06a9bd7 100644 --- a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt @@ -193,19 +193,21 @@ class FastCheckBackendTest { assertEquals(BackendErrorKind.INVALID_REQUEST, error.kind) assertEquals("backend.examples.arity", error.code) - val invalidNumber = JsConcreteValue.Number( - JsNumber(value = JsNumberKind.FINITE, bits = "invalid"), - ) - val encodingError = assertFailsWith { - missingNodeBackend.run( - property = property(predicate = "alwaysTrue"), - configuration = configuration.copy(examples = listOf(listOf(invalidNumber))), + for (bits in listOf("invalid", "7ff0000000000000", "fff0000000000000", "7ff8000000000000")) { + val invalidNumber = JsConcreteValue.Number( + JsNumber(value = JsNumberKind.FINITE, bits = bits), ) + val encodingError = assertFailsWith { + missingNodeBackend.run( + property = property(predicate = "alwaysTrue"), + configuration = configuration.copy(examples = listOf(listOf(invalidNumber))), + ) + } + + assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind) + assertEquals("backend.examples.value.invalid", encodingError.code) + assertEquals("examples[0][0]", encodingError.path) } - - assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind) - assertEquals("backend.examples.value.invalid", encodingError.code) - assertEquals("examples[0][0]", encodingError.path) } @Test diff --git a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt index cfcfa58d4d..4c4dee032c 100644 --- a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageTest.kt @@ -1,5 +1,8 @@ package org.usvm.ts.pbt.fastcheck +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.CoverageScope import org.usvm.ts.pbt.backend.PropertyCoverageRequest @@ -7,7 +10,11 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.backend.SourceFileCoverage +import org.usvm.ts.pbt.manifest.toManifest +import org.usvm.ts.pbt.mapping.EtsMappingStatus +import org.usvm.ts.pbt.mapping.PropertyEtsMapper import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.PropertyId import org.usvm.ts.pbt.model.PropertyInput @@ -58,12 +65,9 @@ class FastCheckCoverageTest { assertEquals(listOf(2), zeroHitBranchLines(nonPositiveFile)) assertEquals(1L, statementHitsAtLine(positiveFile, line = 2)) assertEquals(1L, statementHitsAtLine(nonPositiveFile, line = 2)) - assertTrue( - (positiveFile.branches + nonPositiveFile.branches).all { branch -> - branch.type == "branch" && branch.arms.size == 1 - }, - "The pinned c8/V8 collector must expose its backend-specific single-arm branch shape", - ) + assertEquals(listOf(1L, 0L), exactIf(positiveFile).arms.map { arm -> arm.hits }) + assertEquals(listOf(0L, 1L), exactIf(nonPositiveFile).arms.map { arm -> arm.hits }) + assertTrue(positiveFile.branches.any { branch -> branch.type == "branch" && branch.arms.size == 1 }) } @Test @@ -84,6 +88,97 @@ class FastCheckCoverageTest { assertEquals(listOf(5), zeroHitBranchLines(file)) } + @Test + fun `real backend reports mixed TypeScript branch hits`() { + val result = backend.run( + property = property( + exportName = "alwaysCovers", + domain = IntegerDomain(min = -1, max = 1), + ), + configuration = configuration.copy( + numRuns = 3, + examples = listOf( + listOf(JsConcreteValue.number(-1.0)), + listOf(JsConcreteValue.number(1.0)), + ), + ), + ) + + assertEquals(PropertyRunStatus.SUCCESS, result.status) + val armHits = exactIf(sourceUnderTest(result)).arms.map { arm -> arm.hits } + assertEquals(3L, armHits.sum()) + assertTrue(armHits.all { hits -> hits >= 1L }) + } + + @Test + fun `real TypeScript branch arms map to ordered EtsIR successors`() { + val definition = property( + exportName = "coversPositive", + domain = IntegerDomain(min = 1, max = 1), + ) + val result = backend.run(property = definition, configuration = configuration) + val source = sourceRoot().resolve("properties/coverage/source-under-test.ts").toRealPath() + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val mapped = mapper.map(definition.toManifest(), assertNotNull(result.coverage)) + val branch = mapped.coverage.branches.single { candidate -> candidate.coverage.type == "if" } + + assertEquals(EtsMappingStatus.EXACT, branch.mapping.status) + assertEquals(listOf(1L, 0L), branch.arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(true, false), branch.arms.map { arm -> arm.mapping.targets.single().outcome }) + } + + @Test + fun `nested TypeScript branches map to distinct EtsIR conditions`() { + val definition = property( + exportName = "coversNested", + domain = IntegerDomain(min = 20, max = 20), + ) + val result = backend.run(property = definition, configuration = configuration) + val source = sourceRoot().resolve("properties/coverage/nested-source-under-test.ts").toRealPath() + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val mapper = PropertyEtsMapper( + scene = EtsScene(listOf(file)), + sourceRoots = listOf(source.parent), + ) + + val mapped = mapper.map(definition.toManifest(), assertNotNull(result.coverage)) + val branches = mapped.coverage.branches.filter { branch -> branch.coverage.type == "if" } + + assertEquals(2, branches.size) + assertTrue(branches.all { branch -> branch.mapping.status == EtsMappingStatus.EXACT }) + assertEquals(listOf(1L, 0L), branches[0].arms.map { arm -> arm.coverage.hits }) + assertEquals(listOf(1L, 0L), branches[1].arms.map { arm -> arm.coverage.hits }) + val outcomes = branches.map { branch -> + branch.arms.map { arm -> arm.mapping.targets.single().outcome } + } + assertEquals(listOf(listOf(true, false), listOf(true, false)), outcomes) + } + + @Test + fun `unsupported TypeScript branch retains exact statement coverage`() { + val result = backend.run( + property = property( + exportName = "coversNonterminal", + domain = IntegerDomain(min = 1, max = 1), + ), + configuration = configuration, + ) + + val coverage = assertNotNull(result.coverage) + val file = coverage.files.single { candidate -> + candidate.path.endsWith("properties/coverage/nonterminal-source-under-test.ts") + } + + assertTrue(file.statements.any { statement -> statement.hits > 0 }) + assertTrue(file.branches.none { branch -> branch.type == "if" }) + assertTrue(coverage.diagnostics.any { diagnostic -> diagnostic.code == "coverage.branch.unsupported" }) + } + @Test fun `real c8 reports a missing referenced source map when the final report omits the script`() { val module = "properties/coverage/missing-map-entry.js" @@ -168,6 +263,8 @@ class FastCheckCoverageTest { return artifact.files.single { file -> file.path.endsWith("properties/coverage/source-under-test.ts") } } + private fun exactIf(file: SourceFileCoverage) = file.branches.single { branch -> branch.type == "if" } + private fun statementHitsAtLine(file: SourceFileCoverage, line: Int): Long = file.statements .filter { statement -> statement.location.start.line == line } .maxOf { statement -> statement.hits } diff --git a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 08220b6ff1..5f6b47dce2 100644 --- a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt @@ -13,6 +13,9 @@ import org.usvm.ts.pbt.testResourcesRoot import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory @@ -26,6 +29,49 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class FastCheckProcessClientTest { + @Test + fun `interrupted coverage version probe terminates its process and preserves interruption`() { + val runtime = createTempDirectory(prefix = "interrupted-version-probe-") + val node = runtime.resolve("fake-node") + val pidFile = runtime.resolve("pid") + val failure = AtomicReference() + val interrupted = AtomicBoolean() + node.writeText("#!/bin/sh\necho ${'$'}${'$'} > '$pidFile'\nexec sleep 30\n") + check(node.toFile().setExecutable(true)) + val worker = thread(name = "interrupted-version-probe") { + try { + FastCheckProcessClient( + nodeExecutable = node.toString(), + adapterEntryPoint = runtime.resolve("unused.js"), + ).check(validRequest.copy(coverageRequest = PropertyCoverageRequest())) + } catch (error: Exception) { + failure.set(error) + interrupted.set(Thread.currentThread().isInterrupted) + } + } + + try { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!processIsAlive(pidFile) && worker.isAlive && System.nanoTime() < deadline) { + Thread.sleep(10) + } + assertTrue(processIsAlive(pidFile), "Version probe did not start") + + worker.interrupt() + worker.join(2_000) + + assertFalse(worker.isAlive, "Interrupted version probe did not finish") + assertEquals("backend.process.interrupted", (failure.get() as PbtBackendException).code) + assertTrue(interrupted.get()) + assertFalse(processIsAlive(pidFile), "Version probe process survived interruption") + } finally { + worker.interrupt() + terminateProcess(pidFile) + worker.join(2_000) + runtime.toFile().deleteRecursively() + } + } + @Test fun `process client rejects a shutdown grace period outside the Node timer range`() { assertFailsWith { diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts index 1b012ceb1c..653db26a2d 100644 --- a/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/CoverageProperties.ts @@ -1,4 +1,6 @@ import { classify } from './source-under-test.ts'; +import { classifyNested } from './nested-source-under-test.ts'; +import { nonterminal } from './nonterminal-source-under-test.ts'; export function coversPositive(value: number): boolean { return classify(value) === 'positive'; @@ -12,3 +14,18 @@ export function failsAfterClassifying(value: number): boolean { classify(value); return false; } + +export function alwaysCovers(value: number): boolean { + classify(value); + return true; +} + +export function coversNested(value: number): boolean { + classifyNested(value); + return true; +} + +export function coversNonterminal(value: number): boolean { + nonterminal(value); + return true; +} diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts new file mode 100644 index 0000000000..fa46738aeb --- /dev/null +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/nested-source-under-test.ts @@ -0,0 +1,11 @@ +export function classifyNested(value: number): string { + if (value > 0) { + if (value > 10) { + return 'large'; + } else { + return 'small'; + } + } else { + return 'non-positive'; + } +} diff --git a/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts b/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts new file mode 100644 index 0000000000..b9dfccaaf9 --- /dev/null +++ b/usvm-ts-fast-check/src/test/resources/properties/coverage/nonterminal-source-under-test.ts @@ -0,0 +1,7 @@ +export function nonterminal(value: number): number { + let result = 0; + if (value > 0) { + result += 1; + } + return result; +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt index 1722c94681..8f1a2430a1 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt @@ -14,6 +14,7 @@ import java.nio.file.Path /** Selects relevant report entries and assembles the coverage artifact for one property run. */ internal class IstanbulCoverageDecoder( private val context: IstanbulCoverageContext, + private val checkBudget: () -> Unit = {}, ) { private val sourceRoots = context.sourceRoots.map(::normalizeCoveragePath) private val propertyEntryPoints = context.propertyEntryPointPaths @@ -36,6 +37,7 @@ internal class IstanbulCoverageDecoder( ): List { val decodedFiles = buildList { for ((reportKey, reportEntry) in report) { + checkBudget() val decodedFile = decodeFile(reportKey, reportEntry, diagnostics) if (decodedFile != null) { add(decodedFile) @@ -71,6 +73,7 @@ internal class IstanbulCoverageDecoder( file = fileObject, path = path, reportKey = reportKey, + checkBudget = checkBudget, ) return decoder.decode() diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt index 700602872e..5721d09fcd 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt @@ -42,13 +42,18 @@ class CoverageArtifactException( } } -/** Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run. */ +/** + * Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run. + * [checkBudget] can cancel between I/O, parsing and entry decoding; the bounded JSON parse itself is not preemptible. + */ fun decodeIstanbulCoverageReport( reportPath: Path, context: IstanbulCoverageContext, + checkBudget: () -> Unit = {}, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, ): PropertyCoverageArtifact { - val report = IstanbulCoverageReportReader.read(reportPath) - val decoder = IstanbulCoverageDecoder(context) + val report = IstanbulCoverageReportReader.read(reportPath, checkBudget, maxReportBytes) + val decoder = IstanbulCoverageDecoder(context, checkBudget) return decoder.decode(report) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt index a43b97bb9b..2de6f40ece 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt @@ -10,13 +10,20 @@ import java.nio.file.Path /** Reads one bounded Istanbul report and converts I/O and JSON failures into coverage diagnostics. */ internal object IstanbulCoverageReportReader { - fun read(reportPath: Path): JsonObject { + fun read( + reportPath: Path, + checkBudget: () -> Unit = {}, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, + ): JsonObject { + require(maxReportBytes > 0) + checkBudget() requireRegularFile(reportPath) - requireAllowedSize(reportPath) + requireAllowedSize(reportPath, maxReportBytes) val reportText = readText(reportPath) + checkBudget() - return parse(reportText, reportPath) + return parse(reportText, reportPath).also { checkBudget() } } private fun requireRegularFile(reportPath: Path) { @@ -29,17 +36,17 @@ internal object IstanbulCoverageReportReader { } } - private fun requireAllowedSize(reportPath: Path) { + private fun requireAllowedSize(reportPath: Path, maxReportBytes: Long) { val reportSize = try { Files.size(reportPath) } catch (error: IOException) { throw unreadableReport(reportPath, error) } - if (reportSize > MAX_COVERAGE_REPORT_BYTES) { + if (reportSize > maxReportBytes) { throw CoverageArtifactException.create( code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, - message = "Istanbul coverage report exceeds $MAX_COVERAGE_REPORT_BYTES bytes", + message = "Istanbul coverage report exceeds $maxReportBytes bytes", path = reportPath.toString(), ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt index ec03fc3176..4dab7dd44e 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt @@ -14,6 +14,7 @@ internal class IstanbulSourceFileDecoder( file: JsonObject, private val path: String, reportKey: String, + private val checkBudget: () -> Unit = {}, ) { private val coveragePath = "coverage[$reportKey]" private val fileJson = IstanbulJsonValue(file, coveragePath) @@ -47,6 +48,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { statementId -> + checkBudget() val location = coverageMap.location(statementId).asRange() val hitCount = coverageMap.hitCount(statementId).asHitCount() @@ -65,6 +67,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { functionId -> + checkBudget() decodeFunction( functionId = functionId, coverageMap = coverageMap, @@ -100,6 +103,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { branchId -> + checkBudget() decodeBranch( branchId = branchId, coverageMap = coverageMap, @@ -118,6 +122,7 @@ internal class IstanbulSourceFileDecoder( val type = branchJson.requiredString(name = "type") val location = branchJson.required(name = "loc").asRange() val arms = branchArms.locations.indices.map { armIndex -> + checkBudget() BranchArmCoverage( location = branchArms.location(armIndex).asRange(), hits = branchArms.hitCount(armIndex).asHitCount(), @@ -171,7 +176,10 @@ internal class IstanbulSourceFileDecoder( ) } - val ids = locations.keys.map(::parseCoverageId).sorted() + val ids = locations.keys.map { id -> + checkBudget() + parseCoverageId(id) + }.sorted() return CoverageMap( ids = ids, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt index 7ceef0c0a0..2d2c3e6371 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -23,6 +23,7 @@ fun inspectRawV8SourceMapDiagnostics( sourceRoots: List, maxReportFiles: Int = MAX_RAW_V8_REPORT_FILES, maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, + checkBudget: () -> Unit = {}, ): List { require(maxReportFiles > 0) { "Raw V8 report file limit must be positive" } require(maxReportBytes in 1 until Int.MAX_VALUE.toLong()) { @@ -31,14 +32,20 @@ fun inspectRawV8SourceMapDiagnostics( val reports = listRawReports(rawDirectory, maxReportFiles) val roots = sourceRoots.map { sourceRoot -> Path.of(sourceRoot).toAbsolutePath().normalize() } - val reader = RawV8ReportReader(maxReportBytes = maxReportBytes) - val diagnostics = reports.flatMap { report -> inspectRawReport(report, roots, reader) } + val reader = RawV8ReportReader(maxReportBytes = maxReportBytes, checkBudget = checkBudget) + val diagnostics = reports.flatMap { report -> + checkBudget() + inspectRawReport(report, roots, reader, checkBudget).also { checkBudget() } + } return coalesceSourceMapDiagnostics(diagnostics) } /** Reads several reports under one aggregate byte budget. */ -internal class RawV8ReportReader(maxReportBytes: Long) { +internal class RawV8ReportReader( + maxReportBytes: Long, + private val checkBudget: () -> Unit = {}, +) { private var remainingBytes = maxReportBytes init { @@ -48,6 +55,7 @@ internal class RawV8ReportReader(maxReportBytes: Long) { } fun readText(reportPath: Path): String { + checkBudget() // One byte past the remaining budget detects growth without a separate size preflight or manual read loop. val readLimit = remainingBytes.toInt() + LIMIT_OVERFLOW_SENTINEL_BYTES val bytes = try { @@ -68,8 +76,9 @@ internal class RawV8ReportReader(maxReportBytes: Long) { ) } remainingBytes -= bytes.size + checkBudget() - return decodeUtf8(bytes, reportPath) + return decodeUtf8(bytes, reportPath).also { checkBudget() } } } @@ -132,7 +141,9 @@ private fun inspectRawReport( reportPath: Path, sourceRoots: List, reader: RawV8ReportReader, -): List = readUnappliedSourceMaps(reportPath, reader).mapNotNull { sourceMap -> + checkBudget: () -> Unit, +): List = readUnappliedSourceMaps(reportPath, reader, checkBudget).mapNotNull { sourceMap -> + checkBudget() sourceMap.inspect(sourceRoots) } @@ -140,8 +151,13 @@ private fun inspectRawReport( * Extracts only cache entries for which c8 saw a source-map URL but could not load its data. * Entries with object-valued `data` are already usable by c8 and need no diagnostic. */ -private fun readUnappliedSourceMaps(reportPath: Path, reader: RawV8ReportReader): List { +private fun readUnappliedSourceMaps( + reportPath: Path, + reader: RawV8ReportReader, + checkBudget: () -> Unit, +): List { val report = parseRawReport(reader.readText(reportPath), reportPath) + checkBudget() val sourceMapCache = report["source-map-cache"] ?: return emptyList() if (sourceMapCache !is JsonObject) { failInvalidRawReport( @@ -151,6 +167,7 @@ private fun readUnappliedSourceMaps(reportPath: Path, reader: RawV8ReportReader) } return sourceMapCache.mapNotNull { (scriptUrl, entry) -> + checkBudget() parseUnappliedSourceMap(reportPath, scriptUrl, entry) } } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt index 48e52ecbb2..096a645227 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/manifest/PropertyManifest.kt @@ -2,11 +2,11 @@ package org.usvm.ts.pbt.manifest import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import org.usvm.ts.pbt.model.encodeToUtf8SafeString import org.usvm.ts.pbt.validation.requireValid import org.usvm.ts.pbt.validation.validatePropertyDefinition import org.usvm.ts.pbt.validation.validatePropertyManifest @@ -46,7 +46,7 @@ object PropertyManifestJson { fun encode(manifest: PropertyManifest): String { requireValid(validatePropertyManifest(manifest)) - return json.encodeToString(manifest) + return json.encodeToUtf8SafeString(manifest) } fun decode(value: String): PropertyManifest = json.decodeFromString(value) 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/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt index 9dba12e1f3..d442482b83 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt @@ -46,6 +46,17 @@ data class JsNumber( val value: JsNumberKind, val bits: String? = null, ) { + /** Whether this tag can represent exactly the encoded ECMAScript binary64 value. */ + fun hasValidEncoding(): Boolean = when (value) { + JsNumberKind.FINITE -> { + bits?.takeIf { it.matches(FINITE_NUMBER_BITS_REGEX) } + ?.let { Double.fromBits(it.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } + ?: false + } + + else -> bits == null + } + fun toDouble(): Double = when (value) { JsNumberKind.FINITE -> Double.fromBits( requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } @@ -289,8 +300,7 @@ private fun JsonObject.requiredFiniteBits(): String { throw SerializationException("Finite JsConcreteValue requires sixteen lowercase hexadecimal bits") } - val number = Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()) - if (!number.isFinite()) { + if (!JsNumber(value = JsNumberKind.FINITE, bits = bits).hasValidEncoding()) { throw SerializationException("Finite JsConcreteValue requires finite IEEE-754 bits") } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt new file mode 100644 index 0000000000..d75c5beee5 --- /dev/null +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/Utf16Json.kt @@ -0,0 +1,28 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Encodes JSON for UTF-8 transport without replacing JavaScript's isolated UTF-16 surrogates. */ +inline fun Json.encodeToUtf8SafeString(value: T): String = + escapeJsonSurrogates(encodeToString(value)) + +/** + * Escapes surrogate code units in an already encoded JSON document. All other JSON text is unchanged. + * Escaping paired units too preserves their value and avoids making transport depend on UTF-8 error handling. + * Keep this at the text boundary: JsonElement string contents must retain their original UTF-16 value. + */ +fun escapeJsonSurrogates(json: String): String { + if (json.none { it.isSurrogate() }) return json + + return buildString { + json.forEach { unit -> + if (unit.isSurrogate()) { + append("\\u") + append(unit.code.toString(radix = 16).padStart(length = 4, padChar = '0')) + } else { + append(unit) + } + } + } +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt index 09079ca167..d78ad5613a 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt @@ -261,10 +261,7 @@ private fun validateJsNumber( path: String, diagnostics: MutableList, ): Boolean { - val valid = when (number.value) { - JsNumberKind.FINITE -> number.bits.isFiniteNumberBits() - else -> number.bits == null - } + val valid = number.hasValidEncoding() if (!valid) { diagnostics += diagnostic( code = PbtDiagnosticCode.JS_NUMBER_ENCODING_INVALID, @@ -275,11 +272,6 @@ private fun validateJsNumber( return valid } -private fun String?.isFiniteNumberBits(): Boolean = this - ?.takeIf { bits -> bits.matches(FINITE_NUMBER_BITS_REGEX) } - ?.let { bits -> Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } - ?: false - private fun validateLengths( minLength: Int, maxLength: Int, @@ -368,8 +360,6 @@ private fun diagnostic(code: String, message: String, path: String) = Validation path = path, ) -private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") -private const val JS_NUMBER_HEX_RADIX = 16 // ECMAScript permits these otherwise invisible Unicode characters after the first identifier character. private const val ZERO_WIDTH_NON_JOINER_CODE_POINT = 0x200C diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt index 81e2ade2be..0ccfe14bdc 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt @@ -1,5 +1,6 @@ package org.usvm.ts.pbt.coverage +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.CoverageCollectorIdentity import org.usvm.ts.pbt.backend.CoverageScope @@ -9,6 +10,7 @@ import java.nio.ByteBuffer import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardOpenOption +import java.util.concurrent.TimeUnit import kotlin.io.path.absolute import kotlin.io.path.createTempDirectory import kotlin.io.path.createTempFile @@ -16,6 +18,7 @@ import kotlin.io.path.deleteIfExists import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class IstanbulCoverageReportTest { @Test @@ -138,6 +141,67 @@ class IstanbulCoverageReportTest { } } + @Test + fun `dense legal report near the strict limit observes an expired deadline`() { + val report = createTempFile(suffix = ".json") + val statements = 30_000 + + try { + val statementMap = (0 until statements).joinToString(separator = ",") { id -> + "\"$id\":{\"start\":{\"line\":1,\"column\":0},\"end\":{\"line\":1,\"column\":1}}" + } + val hits = (0 until statements).joinToString(separator = ",") { id -> "\"$id\":1" } + val prefix = "{\"/workspace/src/math.ts\":{\"path\":\"/workspace/src/math.ts\"," + + "\"statementMap\":{$statementMap},\"fnMap\":{},\"branchMap\":{}," + + "\"s\":{$hits},\"f\":{},\"b\":{}}}" + val padding = (STRICT_REPORT_LIMIT_BYTES - prefix.toByteArray().size - 1).toInt() + assertTrue(padding > 0) + report.writeText(prefix + " ".repeat(padding)) + assertEquals(STRICT_REPORT_LIMIT_BYTES - 1, Files.size(report)) + + val startedAt = System.nanoTime() + var deadline = 0L + var checkpoints = 0 + assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + maxReportBytes = STRICT_REPORT_LIMIT_BYTES, + checkBudget = { + if (deadline == 0L) deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(5) + checkpoints++ + if (System.nanoTime() >= deadline) throw TestDeadlineExpired() + }, + ) + } + val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + assertTrue(checkpoints >= 2) + assertTrue(elapsedMillis >= 5) + } finally { + report.deleteIfExists() + } + } + + @Test + fun `Istanbul entry iteration checks the deadline within one retained file`() { + val report = IstanbulCoverageReportReader.read(goldenReport()) + val file = report.getValue("/workspace/src/math.ts").jsonObject + var checkpoints = 0 + val decoder = IstanbulSourceFileDecoder( + file = file, + path = "/workspace/src/math.ts", + reportKey = "/workspace/src/math.ts", + checkBudget = { + checkpoints++ + if (checkpoints == 2) throw TestDeadlineExpired() + }, + ) + + assertFailsWith { decoder.decode() } + assertEquals(2, checkpoints) + } + @Test fun `generated JavaScript with an unusable map reports an invalid source map`() { val directory = createTempDirectory("invalid-source-map-") @@ -202,5 +266,8 @@ class IstanbulCoverageReportTest { private companion object { const val TEST_COVERAGE_REPORT_LIMIT_BYTES = 64L * 1024 * 1024 + const val STRICT_REPORT_LIMIT_BYTES = 4L * 1024 * 1024 } + + private class TestDeadlineExpired : RuntimeException() } 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/kotlin/org/usvm/ts/pbt/model/Utf16JsonTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/Utf16JsonTest.kt new file mode 100644 index 0000000000..0dd1441f92 --- /dev/null +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/model/Utf16JsonTest.kt @@ -0,0 +1,80 @@ +package org.usvm.ts.pbt.model + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class Utf16JsonTest { + @Test + fun `all UTF-16 code units survive JSON UTF-8 transport`() { + val source = (Char.MIN_VALUE..Char.MAX_VALUE).joinToString(separator = "") + val value: JsConcreteValue = JsConcreteValue.Array(listOf(JsConcreteValue.String(source))) + + val encoded = Json.encodeToUtf8SafeString(value) + val transported = encoded.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8) + + assertFalse(encoded.any { it.isSurrogate() }) + assertEquals(value, Json.decodeFromString(transported)) + } + + @Test + fun `plain JSON remains unchanged and escaping is idempotent`() { + val original = Json.encodeToString("quotes: \" slash: \\ literal: \\ud800 Cyrillic: ΠΏΡ€ΠΈΠ²Π΅Ρ‚") + val surrogate = Json.encodeToUtf8SafeString("\ud800\udc00\udfff") + + assertEquals(original, escapeJsonSurrogates(original)) + assertEquals(surrogate, escapeJsonSurrogates(surrogate)) + } + + @Test + fun `different isolated surrogates retain distinct UTF-8 hashes`() { + val digests = listOf("\ud800", "\ud801", "?").map { value -> + val encoded = Json.encodeToUtf8SafeString(JsConcreteValue.String(value)) + MessageDigest.getInstance("SHA-256").digest(encoded.toByteArray(Charsets.UTF_8)).toList() + } + + assertEquals(3, digests.distinct().size) + assertNotEquals(digests[0], digests[1]) + } + + @Test + fun `ordinary JsonElement roundtrip retains string contents`() { + val value: JsConcreteValue = JsConcreteValue.Array(listOf(JsConcreteValue.String("\ud800"))) + + val tree = Json.encodeToJsonElement(JsConcreteValueSerializer, value) + val decoded = Json.decodeFromJsonElement(JsConcreteValueSerializer, tree) + + assertEquals(value, decoded) + } + + @Test + fun `Node receives exact UTF-16 units through the unchanged tagged schema`() { + val source = "\ud800?\udfff\ud83d\ude00\"\\\n" + val encoded = Json.encodeToUtf8SafeString(JsConcreteValue.String(source)) + val script = """ + const input = JSON.parse(require('fs').readFileSync(0, 'utf8')); + if (input.kind !== 'string') process.exit(2); + const units = Array.from({length: input.value.length}, (_, i) => input.value.charCodeAt(i)); + process.stdout.write(JSON.stringify(units)); + """.trimIndent() + val process = ProcessBuilder("node", "-e", script).redirectErrorStream(true).start() + + try { + process.outputStream.use { it.write(encoded.toByteArray(Charsets.UTF_8)) } + assertTrue(process.waitFor(10, TimeUnit.SECONDS)) + val output = process.inputStream.bufferedReader().readText() + + assertEquals(0, process.exitValue(), output) + assertEquals(source.map { it.code }, Json.decodeFromString>(output)) + } finally { + process.destroyForcibly() + } + } +} 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/api/TsMock.kt b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt index 7ab3d97d96..4f1bef93db 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/api/TsMock.kt @@ -1,26 +1,45 @@ package org.usvm.api import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsType import org.jacodb.ets.model.EtsVoidType import org.usvm.UAddressSort +import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.types.mkFakeValue +import org.usvm.solver.USatResult +import org.usvm.solver.UUnknownResult +import org.usvm.solver.UUnsatResult fun mockMethodCall( scope: TsStepScope, method: EtsMethodSignature, resultType: EtsType = method.returnType, -) { - val result = makeFreshUnknownCallResult(scope, resultType) +): Boolean { + val prepared = prepareFreshUnknownCallResult(scope, resultType) + prepared.admissibilityGuard?.let { guard -> + val state = scope.calcOnState { this } + if (scope.assert(guard) == null) { + val constraints = state.pathConstraints.clone() + constraints += guard + + when (state.ctx.solver().check(constraints)) { + is UUnsatResult -> return false + is UUnknownResult -> error("Solver could not decide fresh string type for $method") + is USatResult -> error("Fresh string type was satisfiable after its fork failed for $method") + } + } + } scope.doWithState { - setMockMethodCallResult(method, result) + setMockMethodCallResult(method, prepared.value) } + return true } /** Stores a prepared opaque result on this state without applying callee effects or exceptions. */ @@ -31,23 +50,39 @@ internal fun TsState.setMockMethodCallResult( methodResult = TsMethodResult.Success.MockedCall(result, method) } -/** Creates a fresh opaque result through [scope], keeping solver models consistent with new constraints. */ -internal fun makeFreshUnknownCallResult( +internal data class PreparedFreshUnknownCallResult( + val value: UExpr<*>, + val admissibilityGuard: UBoolExpr? = null, +) + +/** Prepares a fresh opaque result; callers must apply [PreparedFreshUnknownCallResult.admissibilityGuard]. */ +internal fun prepareFreshUnknownCallResult( scope: TsStepScope, resultType: EtsType, -): UExpr<*> = scope.calcOnState { - if (resultType is EtsVoidType) return@calcOnState ctx.mkUndefinedValue() +): PreparedFreshUnknownCallResult { + if (resultType is EtsStringType) { + // The type guard belongs only to the branch using this result. Asserting it before a + // partial model forks could discard satisfiable model successors. + val ref = scope.calcOnState { makeSymbolicRefUntyped() } + val guard = scope.calcOnState { memory.types.evalTypeEquals(ref, EtsStringType) } + return PreparedFreshUnknownCallResult(value = ref, admissibilityGuard = guard) + } + + val value = scope.calcOnState { + if (resultType is EtsVoidType) return@calcOnState ctx.mkUndefinedValue() - when (val sort = ctx.typeToSort(resultType)) { - is UAddressSort -> makeSymbolicRefUntyped() + when (val sort = ctx.typeToSort(resultType)) { + is UAddressSort -> makeSymbolicRefUntyped() - is TsUnresolvedSort -> mkFakeValue( - scope, - boolValue = makeSymbolicPrimitive(ctx.boolSort), - fpValue = makeSymbolicPrimitive(ctx.fp64Sort), - refValue = makeSymbolicRefUntyped(), - ) + is TsUnresolvedSort -> mkFakeValue( + scope, + boolValue = makeSymbolicPrimitive(ctx.boolSort), + fpValue = makeSymbolicPrimitive(ctx.fp64Sort), + refValue = makeSymbolicRefUntyped(), + ) - else -> makeSymbolicPrimitive(sort) + else -> makeSymbolicPrimitive(sort) + } } + return PreparedFreshUnknownCallResult(value = value) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt index 4666162d04..970a67e3aa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt @@ -325,6 +325,7 @@ internal class UnknownCallCensusRunner( ) val generatedIr = try { generateEtsIR( + keepPartialOutputOnFailure = false, projectPath = sourceRoot, isProject = true, loadEntrypoints = false, 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..420992e640 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -10,6 +10,7 @@ import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsClass import org.jacodb.ets.model.EtsEnumValueType import org.jacodb.ets.model.EtsGenericType +import org.jacodb.ets.model.EtsIntersectionType import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod @@ -61,6 +62,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) } @@ -150,6 +152,16 @@ class TsContext( is EtsNullType -> addressSort is EtsUndefinedType -> addressSort is EtsUnionType -> unresolvedSort + is EtsIntersectionType -> { + val memberSorts = type.types.map(::typeToSort) + val commonSort = memberSorts.firstOrNull() + + if (commonSort != null && memberSorts.all { it == commonSort }) { + commonSort + } else { + unresolvedSort + } + } is EtsRefType -> addressSort is EtsLexicalEnvType -> addressSort is EtsAnyType -> unresolvedSort 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..145a66d4c2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -2,8 +2,10 @@ package org.usvm.machine import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallExpr +import org.jacodb.ets.model.EtsCallStmt 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 +23,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, @@ -29,6 +36,15 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } + /** Called only after an assignment writes its value, before execution advances. */ + fun onAssignmentCompleted( + simpleValueResolver: TsSimpleValueResolver, + stmt: EtsAssignStmt, + scope: TsStepScope, + ) { + // default empty implementation + } + // TODO on entry point fun onCallWithUnresolvedArguments( @@ -39,6 +55,15 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } + /** Called before a standalone call is executed, while its arguments still denote the current state. */ + fun onCallStatement( + simpleValueResolver: TsSimpleValueResolver, + stmt: EtsCallStmt, + scope: TsStepScope, + ) { + // default empty implementation + } + // TODO onCallWithResolvedArguments fun onIfStatement( @@ -74,3 +99,27 @@ 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_ELEMENT_DELETE, + CAUGHT_EXCEPTION_VALUE, + REGULAR_EXPRESSION_LITERAL, + STRING_CONCAT_OPERAND_CONVERSION, + ARRAY_STORAGE_TYPE, + CONDITIONAL_FIELD_RECEIVER, + FAKE_FIELD_RECEIVER_TYPE, + 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..11689031d2 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -8,10 +8,14 @@ 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 +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision import org.usvm.machine.call.TsUnknownCallDispatcher +import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallModelCatalog import org.usvm.machine.call.deduplicateEtsFilesBySignature import org.usvm.machine.interpreter.TsInterpreter @@ -51,6 +55,17 @@ data class TsAnalysisResult( val stopReason: TsAnalysisStopReason, ) +/** Analysis-wide observations; collected states alone do not prove that every path completed. */ +data class TsMachineAnalysisResult( + val states: List, + val stopReason: TsAnalysisStopReason, + val timedOut: Boolean, + /** A STOP_PATH residual decision observed from the machine-owned model dispatcher. */ + val unsupportedCall: Boolean, + val engineFailed: Boolean, + val runtimeLimited: Boolean, +) + class TsMachine( scene: EtsScene, override val options: UMachineOptions, @@ -59,6 +74,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,17 +102,19 @@ class TsMachine( scene = analysisScene, components = components, applicationAndSdkClasses = scene.projectAndSdkClasses, + dateNowMilliseconds = tsOptions.dateNowMilliseconds, ) + private val analysisObserver = AnalysisTrackingObserver(observer ?: object : TsInterpreterObserver {}) private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsModelUnknownCallDispatcher( models = requireNotNull(resolvedUnknownCallModels), fallback = tsOptions.unknownCallFallback, - observer = observer, + observer = analysisObserver, ) private val interpreter = TsInterpreter( ctx = ctx, graph = graph, options = tsOptions, - observer = observer, + observer = analysisObserver, unknownCallDispatcher = resolvedUnknownCallDispatcher, throwExceptionOnStepFailure = options.throwExceptionOnStepFailure, ) @@ -106,12 +125,34 @@ class TsMachine( targets: List = emptyList(), ): List = analyzeWithOutcome(methods = methods, targets = targets).states + fun analyze( + methods: List, + targets: List = emptyList(), + configureInitialState: (EtsMethod, TsState) -> Unit, + ): List = analyzeWithMetadata(methods, targets, configureInitialState).states + fun analyzeWithOutcome( methods: List, targets: List = emptyList(), ): TsAnalysisResult { - val initialStates = mutableMapOf() - methods.forEach { initialStates[it] = interpreter.getInitialState(it, targets) } + val result = analyzeWithMetadata(methods = methods, targets = targets) + return TsAnalysisResult(states = result.states, stopReason = result.stopReason) + } + + /** + * The constructor configurator runs before [configureInitialState], both before the initial solver query. + * A caller-supplied dispatcher owns its telemetry; [TsMachineAnalysisResult.unsupportedCall] only tracks + * residual decisions made by this machine's default dispatcher. Runtime limitations are tracked separately. + */ + fun analyzeWithMetadata( + methods: List, + targets: List = emptyList(), + configureInitialState: (EtsMethod, TsState) -> Unit = { _, _ -> }, + ): TsMachineAnalysisResult { + interpreter.resetStepFailure() + analysisObserver.reset() + + val initialStates = createInitialStates(methods, targets, configureInitialState) val methodsToTrackCoverage = when (options.coverageZone) { @@ -164,6 +205,7 @@ class TsMachine( } val stepsStatistics = StepsStatistics() + var timedOut = false val stopStrategy = object : StopStrategy { val strategy = createStopStrategy( options, @@ -175,7 +217,15 @@ class TsMachine( ) override fun shouldStop(): Boolean { + if (options.timeout <= kotlin.time.Duration.ZERO) { + timedOut = true + return true + } + val result = strategy.shouldStop() + if (result && timeStatistics.runningTime >= options.timeout) { + timedOut = true + } if (result) { logger.warn { "Stop strategy finished execution: ${strategy.stopReason()}" } @@ -216,10 +266,60 @@ class TsMachine( TsAnalysisStopReason.STOPPED } - return TsAnalysisResult(states = statesCollector.collectedStates, stopReason = stopReason) + return TsMachineAnalysisResult( + states = statesCollector.collectedStates, + stopReason = stopReason, + timedOut = timedOut, + unsupportedCall = analysisObserver.pathStopped, + engineFailed = interpreter.stepFailed, + runtimeLimited = analysisObserver.runtimeLimited, + ) + } + + private fun createInitialStates( + methods: List, + targets: List, + configureInitialState: (EtsMethod, TsState) -> Unit, + ): Map = methods.associateWith { method -> + interpreter.getInitialState( + method = method, + targets = targets, + configure = { state -> + initialStateConfigurator(state) + configureInitialState(method, state) + }, + parameterSortOverride = { stackSlot -> initialParameterSortOverride(ctx, stackSlot) }, + ) } override fun close() { components.close() } } + +private class AnalysisTrackingObserver( + private val delegate: TsInterpreterObserver, +) : TsInterpreterObserver by delegate { + var pathStopped: Boolean = false + private set + var runtimeLimited: Boolean = false + private set + + override fun onUnknownCall(event: TsUnknownCallEvent) { + val decision = event.decision + if (decision is TsUnknownCallDecision.ResidualFallback && decision.policy == TsResidualCallPolicy.STOP_PATH) { + pathStopped = true + } + delegate.onUnknownCall(event) + } + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + runtimeLimited = true + delegate.onRuntimeFeatureLimitation(event) + } + + fun reset() { + pathStopped = false + runtimeLimited = false + } +} 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..b278d498dc 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,18 +1,39 @@ 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) } + internal fun hasPartialApproximationModel( + methodName: String, + enclosingClassName: String, + allowUnqualifiedTarget: Boolean = false, + ): Boolean = + allModels.hasTarget( + methodName = methodName, + enclosingClassName = enclosingClassName, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + allowUnqualifiedTarget = allowUnqualifiedTarget, + ) + fun catalog(selection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All): TsUnknownCallModelCatalog = if (selection == TsUnknownCallModelSelection.All) allModels else TsUnknownCallModelCatalog(models, selection) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/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..c8214ec174 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 @@ -53,6 +53,7 @@ fun loadEtsIrUnknownCallModelArtifact( entryPointMethodName: String, ): TsEtsIrUnknownCallModelArtifact { val irPath = generateEtsIR( + keepPartialOutputOnFailure = false, projectPath = sourcePath, isProject = false, loadEntrypoints = true, @@ -111,11 +112,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 +161,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..73488da1b5 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 @@ -98,7 +98,9 @@ object TsCompatibilityUnknownCallDispatcher : TsUnknownCallDispatcher { TsUnknownCallFailureReason.INTERPROCEDURAL_ANALYSIS_DISABLED, TsUnknownCallFailureReason.LOGGING_CALL, -> { - mockMethodCall(scope, call.callee) + if (!mockMethodCall(scope, call.callee)) { + return TsUnknownCallOutcome.PATH_STOPPED + } scope.doWithState { newStmt(call.callSite) } return TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN } @@ -141,19 +143,18 @@ 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 unknownCall = 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, ) + + return dispatch(scope, unknownCall) } 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..50c4d5eb5b 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 @@ -2,6 +2,7 @@ package org.usvm.machine.call import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFileSignature +import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily import org.usvm.machine.state.TsState import java.util.Collections import java.util.IdentityHashMap @@ -23,13 +24,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) @@ -59,13 +75,29 @@ class TsUnknownCallModelCatalog( return candidates[call.callee.enclosingClass.name] ?: candidates[null] } + internal fun hasTarget( + methodName: String, + enclosingClassName: String, + failureReason: TsUnknownCallFailureReason, + allowUnqualifiedTarget: Boolean, + ): Boolean { + val candidates = index[methodName]?.get(failureReason) ?: return false + return enclosingClassName in candidates || (allowUnqualifiedTarget && null in candidates) + } + fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication { - val model = select(call) ?: return TsUnknownCallModelApplication.NotApplicable + val directModel = select(call) + val modelCall = if (directModel == null) { + TsDateEtsIrModelFamily.canonicalModelCall(state, call) ?: call + } else { + call + } + val model = directModel ?: select(modelCall) ?: return TsUnknownCallModelApplication.NotApplicable if (state.isUnknownCallModelActive(model.id)) { return TsUnknownCallModelApplication.NotApplicable } - val execution = model.apply(state, call) ?: return TsUnknownCallModelApplication.NotApplicable + val execution = model.apply(state, modelCall) ?: return TsUnknownCallModelApplication.NotApplicable return TsUnknownCallModelApplication.Applied( modelId = model.id, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt index 5ab9ad9772..49623b3319 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelDispatcher.kt @@ -2,8 +2,8 @@ package org.usvm.machine.call import mu.KotlinLogging import org.usvm.UExpr -import org.usvm.api.makeFreshUnknownCallResult import org.usvm.api.mockMethodCall +import org.usvm.api.prepareFreshUnknownCallResult import org.usvm.api.setMockMethodCallResult import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.interpreter.TsStepScope @@ -61,7 +61,9 @@ class TsModelUnknownCallDispatcher( } TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN -> { - mockMethodCall(scope, call.callee, call.resultType) + if (!mockMethodCall(scope, call.callee, call.resultType)) { + return TsUnknownCallOutcome.PATH_STOPPED + } scope.doWithState { newStmt(call.callSite) } } } @@ -76,12 +78,12 @@ class TsModelUnknownCallDispatcher( application: TsUnknownCallModelApplication.Applied, ): TsUnknownCallOutcome { val residualGuard = application.execution.residualGuard - // Creating an unresolved value may add fake-value constraints. Do it before forking so the residual clone - // inherits both the constraints and their solver models. + // Create the value before forking. A string's type guard is applied only to the residual + // branch below, so an infeasible fresh string cannot discard a modeled successor. val freshResidualResult = if ( residualGuard != null && fallback == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN ) { - makeFreshUnknownCallResult(scope, call.resultType) + prepareFreshUnknownCallResult(scope, call.resultType) } else { null } @@ -112,8 +114,12 @@ class TsModelUnknownCallDispatcher( }.toMutableList() if (residualGuard != null && fallback == TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN) { - guardedStateChanges += residualGuard to { - setMockMethodCallResult(call.callee, requireNotNull(freshResidualResult)) + val prepared = requireNotNull(freshResidualResult) + val guardedResidual = prepared.admissibilityGuard?.let { admissibilityGuard -> + scope.calcOnState { ctx.mkAnd(residualGuard, admissibilityGuard) } + } ?: residualGuard + guardedStateChanges += guardedResidual to { + setMockMethodCallResult(call.callee, prepared.value) newStmt(call.callSite) freshResidualApplied = true } 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..cd96f81b75 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -0,0 +1,669 @@ +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.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.UHeapRef +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_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", + ) + } + + // A guard states when the source model agrees with JavaScript. Other states use the residual policy. + private val arrayReceiverGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.arrayReceiver(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + + state.memory.types.evalIsSubtype(array, arrayType) + } + + private val indexSearchGuard = missingSlotSearchGuard(skipUndefinedHoles = true) + private val includesSearchGuard = missingSlotSearchGuard(skipUndefinedHoles = false) + + private fun missingSlotSearchGuard(skipUndefinedHoles: Boolean): TsEtsIrUnknownCallModelDomainGuard { + return TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val (array, arrayType) = state.arrayReceiver(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + + val searchElement = inputs.getOrNull(1) + val searchRef = searchElement?.takeIf { it.sort == addressSort }?.asExpr(addressSort) + if (searchRef?.hasFakeValueBranch() == true) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + + val elementSort = typeToSort(arrayType.elementType) + val hasNoMissingSlots = array is UConcreteHeapRef && + state.isUnmodifiedDenseInputArray(array, arrayType) + + // Sparse slots are absent in JavaScript, but typed storage can read them as zero or false. + // indexOf/lastIndexOf skip holes when searching for undefined; includes sees holes as undefined. + val missingSlotGuard = when { + searchElement == mkUndefinedValue() && + (skipUndefinedHoles || elementSort == fp64Sort || elementSort == boolSort) -> { + mkBool(hasNoMissingSlots) + } + + elementSort == fp64Sort && searchElement?.sort == fp64Sort -> { + val searchNumber = searchElement.asExpr(fp64Sort) + mkOr(mkBool(hasNoMissingSlots), mkNot(mkFpEqualExpr(searchNumber, mkFp64(0.0)))) + } + + elementSort == boolSort && searchElement?.sort == boolSort -> { + mkOr(mkBool(hasNoMissingSlots), searchElement.asExpr(boolSort)) + } + + else -> trueExpr + } + + mkAnd(state.memory.types.evalIsSubtype(array, arrayType), missingSlotGuard) + } + } + } + + private fun TsState.arrayReceiver( + call: TsUnknownCall, + inputs: List>, + ): Pair? = with(ctx) { + val receiver = inputs.firstOrNull() + ?.takeIf { it.sort == addressSort } + ?.asExpr(addressSort) + ?: return null + val staticType = call.receiver?.source?.type ?: return null + val arrayType = arrayStorageType(receiver, staticType) as? EtsArrayType ?: return null + if (receiver.hasFakeValueBranch() || arrayType.dimensions != 1) return null + + receiver to arrayType + } + + private val pushGuard = 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 = SOURCE_ARRAY_MODEL_CAPACITY - arguments.size) + } + + private val fillGuard = 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 + if (!state.argumentsMatchArrayType(arrayType, listOf(value))) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + // A whole-array fill overwrites holes. A range fill must preserve slots outside its range. + if (call.arguments.size != 1 && !state.isUnmodifiedDenseInputArray(array, arrayType)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = SOURCE_ARRAY_MODEL_CAPACITY) + } + + private val denseInputGuard = 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 = SOURCE_ARRAY_MODEL_CAPACITY) + } + + private val unshiftGuard = 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 = SOURCE_ARRAY_MODEL_CAPACITY - arguments.size) + } + + private val concatGuard = 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 = SOURCE_ARRAY_MODEL_CAPACITY, + ) + val secondIsBounded = state.boundedArrayGuard( + other, + otherType, + maximumLength = SOURCE_ARRAY_MODEL_CAPACITY, + ) + val resultLength = mkBvAddExpr(firstLength, secondLength) + val maximumResultLength = mkBv(SOURCE_ARRAY_MODEL_CAPACITY) + 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.join", + methodName = "join", + inputAdapter = TsDenseArrayModelSupport.joinAdapter, + domainGuard = TsDenseArrayModelSupport.joinDomain, + ), + sourceModel( + id = "ts.array.reduce", + methodName = "reduce", + inputAdapter = TsDenseArrayModelSupport.reduceAdapter, + domainGuard = TsDenseArrayModelSupport.denseReceiverDomain, + ), + sourceModel( + id = "ts.array.pop", + methodName = "pop", + ), + sourceModel( + id = "ts.array.indexOf", + methodName = "indexOf", + inputAdapter = optionalFromIndexAdapter, + domainGuard = indexSearchGuard, + ), + sourceModel( + id = "ts.array.includes", + methodName = "includes", + inputAdapter = optionalFromIndexAdapter, + domainGuard = includesSearchGuard, + ), + sourceModel( + id = "ts.array.lastIndexOf", + methodName = "lastIndexOf", + inputAdapter = optionalLastIndexAdapter, + domainGuard = indexSearchGuard, + ), + sourceModel( + id = "ts.array.push", + methodName = "push", + inputAdapter = variadicMutationAdapter, + domainGuard = pushGuard, + ), + sourceModel( + id = "ts.array.fill", + methodName = "fill", + inputAdapter = fillAdapter, + domainGuard = fillGuard, + ), + sourceModel( + id = "ts.array.reverse", + methodName = "reverse", + inputAdapter = noArgumentsAdapter, + domainGuard = denseInputGuard, + ), + sourceModel( + id = "ts.array.unshift", + methodName = "unshift", + inputAdapter = variadicMutationAdapter, + domainGuard = unshiftGuard, + ), + sourceModel( + id = "ts.array.slice", + methodName = "slice", + inputAdapter = sliceAdapter, + domainGuard = denseInputGuard, + ), + sourceModel( + id = "ts.array.concat", + methodName = "concat", + domainGuard = concatGuard, + ), + 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 = arrayReceiverGuard, + ): 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 == "join") add("ts.array.primitive.elementString") + if (methodName == "reduce") { + add("ts.array.primitive.requireDense") + add("ts.typeError.constructor") + } + 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.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 primitiveModel( + methodName: String, + arity: Int, + implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ): TsUnknownCallModel = TsPrimitiveUnknownCallModel( + idPrefix = "ts.array.primitive", + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + 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(SOURCE_ARRAY_MODEL_CAPACITY) + val guard = mkAnd( + receiverMatchesType, + mkFpEqualExpr(roundTrip, fpLength), + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLength), + ) + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { + denseInputArrays.remove(receiver) + 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(SOURCE_ARRAY_MODEL_CAPACITY.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(SOURCE_ARRAY_MODEL_CAPACITY) { 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(SOURCE_ARRAY_MODEL_CAPACITY) { 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(SOURCE_ARRAY_MODEL_CAPACITY) + 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.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 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..5040590247 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -0,0 +1,424 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsStringType +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.typeStreamOf +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 +import org.usvm.types.singleOrNull + +/** 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 builtinDateSignature = EtsClassSignature.UNKNOWN.copy(name = DATE_CLASS) + private val builtinDateType = EtsClassType(signature = builtinDateSignature) + private val getterNames = listOf( + "getDate", + "getDay", + "getFullYear", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getSeconds", + "getTime", + "getTimezoneOffset", + "getUTCDate", + "getUTCDay", + "getUTCFullYear", + "getUTCHours", + "getUTCMilliseconds", + "getUTCMinutes", + "getUTCMonth", + "getUTCSeconds", + "toISOString", + "valueOf", + ) + + /** Normalize only model lookup; residual calls and observation retain the frontend signature. */ + internal fun canonicalModelCall(state: TsState, call: TsUnknownCall): TsUnknownCall? { + if (call.callee.enclosingClass.name == DATE_CLASS) return null + + val receiverValue = call.receiver ?: return null + if (!isDateReceiver(state, receiverValue.source, receiverValue.resolved)) return null + + return call.copy(callee = call.callee.copy(enclosingClass = builtinDateSignature)) + } + + internal fun isDateReceiver(state: TsState, source: EtsValue, receiver: UExpr<*>?): Boolean { + val sourceLooksDate = (source as? EtsLocal)?.name == DATE_CLASS || when (val type = source.type) { + is EtsClassType -> type.signature.name == DATE_CLASS + is EtsUnclearRefType -> type.typeName == DATE_CLASS + else -> false + } + if (sourceLooksDate) return true + if (receiver?.sort != state.ctx.addressSort) return false + + val runtimeType = state.memory.typeStreamOf(receiver.asExpr(state.ctx.addressSort)).singleOrNull() + return (runtimeType as? EtsClassType)?.signature?.name == DATE_CLASS + } + + 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 getterNames) { + 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 providedArgumentCount = mkFp64(call.arguments.size.toDouble()) + val fallbackClock = mkFp64(nowMilliseconds ?: 0.0) + + buildList { + add(receiver) + add(providedArgumentCount) + add(fallbackClock) + addAll(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.hasBuiltinDateStaticOwner() && + 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 != builtinDateSignature || + receiverValue?.sort != addressSort || + receiverValue.asExpr(addressSort).hasFakeValueBranch() + ) { + falseExpr + } else if (!call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state)) { + falseExpr + } else { + val runtimeType = state.memory.typeStreamOf(receiverValue.asExpr(addressSort)).singleOrNull() + if (runtimeType == builtinDateType) { + trueExpr + } else { + falseExpr + } + } + } + } + + private fun numericGuard( + minArgs: Int, + numericArgs: Int, + ) = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + if (call.hasBuiltinDateStaticOwner() && + call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state) + ) { + state.ctx.trueExpr + } else { + state.ctx.falseExpr + } + } + + private fun TsUnknownCall.hasBuiltinDateStaticOwner(): Boolean { + if (callee.enclosingClass != builtinDateSignature) return false + + val owner = receiver?.source as? EtsLocal ?: return false + val ownerType = owner.type as? EtsFunctionType ?: return false + return owner.name == DATE_CLASS && + ownerType.signature.enclosingClass == EtsClassSignature.UNKNOWN && + ownerType.signature.name.isEmpty() && + ownerType.signature.parameters.isEmpty() && + ownerType.signature.returnType == EtsStringType + } + + 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/TsDenseArrayModelSupport.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDenseArrayModelSupport.kt new file mode 100644 index 0000000000..f7686ffa54 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDenseArrayModelSupport.kt @@ -0,0 +1,104 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.api.evalTypeEquals +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.state.TsState +import org.usvm.util.arrayStorageType +import org.usvm.util.hasDenseArrayShape +import org.usvm.util.mkArrayLengthLValue + +internal const val SOURCE_ARRAY_MODEL_CAPACITY = 16 + +internal object TsDenseArrayModelSupport { + val denseReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + if (!state.hasDenseArrayShape(array, arrayType)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + state.boundedArrayGuard(array, arrayType, maximumLength = SOURCE_ARRAY_MODEL_CAPACITY) + } + + val joinAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + if (call.arguments.size > 1) return@TsEtsIrUnknownCallModelInputAdapter null + val separator = inputs.getOrNull(1) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.mkInitializedStringConstant(",") + if (separator !is UConcreteHeapRef || separator in state.associatedFunction || + with(state.ctx) { separator.hasFakeValueBranch() } + ) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + listOf(inputs.first(), separator) + } + + val joinDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiverGuard = denseReceiverDomain.evaluate(state, call, inputs) + val separatorGuard = state.memory.types.evalTypeEquals(inputs[1].asExpr(addressSort), EtsStringType) + mkAnd( + receiverGuard, + separatorGuard, + ) + } + } + + val reduceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + if (call.arguments.size !in 1..2) return@TsEtsIrUnknownCallModelInputAdapter null + val callback = inputs[1] as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelInputAdapter null + if (callback !in state.associatedFunction) return@TsEtsIrUnknownCallModelInputAdapter null + listOf( + inputs.first(), + callback, + inputs.getOrNull(2) ?: state.ctx.mkUndefinedValue(), + state.ctx.mkBool(call.arguments.size == 2), + ) + } +} + +internal 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 +} + +internal 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), + ) +} + +internal fun TsUnknownCall.resolvedInstanceInputs(): List>? { + val resolvedReceiver = receiver?.resolved ?: return null + val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } + + return listOf(resolvedReceiver) + resolvedArguments +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDenseArrayPrimitives.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDenseArrayPrimitives.kt new file mode 100644 index 0000000000..3034510efd --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDenseArrayPrimitives.kt @@ -0,0 +1,158 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.UIteExpr +import org.usvm.api.evalTypeEquals +import org.usvm.api.initializeArray +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.expr.mkFpToUint32AfterValidation +import org.usvm.machine.expr.mkValidArrayLength +import org.usvm.machine.state.TsState +import org.usvm.sizeSort +import org.usvm.util.arrayStorageType +import org.usvm.util.hasDenseArrayShape +import org.usvm.util.markDenseInputArray +import org.usvm.util.mkArrayLengthLValue + +/** Memory and primitive-value operations used by the source split/join/reduce algorithms. */ +internal object TsDenseArrayPrimitives : TsBuiltInUnknownCallModelFamily { + private val stringsType = EtsArrayType(EtsStringType, dimensions = 1) + + override val models: List = listOf( + Primitive("allocateStrings", arity = 1, implementation = ::allocateStrings), + Primitive("truncateDense", arity = 2, implementation = ::truncateDense), + Primitive("requireDense", arity = 1, implementation = ::requireDense), + Primitive("elementString", arity = 1, implementation = ::elementString), + ) + + private fun allocateStrings(state: TsState, inputs: List>): TsUnknownCallModelExecution? = + with(state.ctx) { + // All capacity slots are initialized, so subsequent in-range writes preserve density. + if (inputs.single() != mkFp64(SOURCE_ARRAY_MODEL_CAPACITY.toDouble())) return null + execution(guard = trueExpr) { + val descriptor = ctx.arrayDescriptorOf(stringsType) + val array = memory.allocConcrete(stringsType) + val empty = mkInitializedStringConstant("") + memory.initializeArray( + arrayHeapRef = array, + type = descriptor, + sort = ctx.addressSort, + sizeSort = ctx.sizeSort, + contents = List(SOURCE_ARRAY_MODEL_CAPACITY) { empty }.asSequence(), + ) + markDenseInputArray(array = array, type = stringsType) + array + } + } + + private fun truncateDense(state: TsState, inputs: List>): TsUnknownCallModelExecution? = + with(state.ctx) { + val array = inputs[0] as? UConcreteHeapRef ?: return null + if (!state.hasDenseArrayShape(array, stringsType)) return null + val length = inputs[1].takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val validLength = mkValidArrayLength(length) + val converted = mkFpToUint32AfterValidation(length, validLength).asExpr(sizeSort) + val lengthLocation = mkArrayLengthLValue(array, stringsType) + val oldLength = state.memory.read(lengthLocation) + val guard = mkAnd(validLength, mkBvUnsignedLessOrEqualExpr(converted, oldLength)) + execution(guard = guard) { + memory.write(lengthLocation, converted, guard = ctx.trueExpr) + markDenseInputArray(array = array, type = stringsType) + array + } + } + + private fun requireDense(state: TsState, inputs: List>): TsUnknownCallModelExecution? { + val array = inputs.single() as? UConcreteHeapRef ?: return null + val type = state.arrayStorageType(array, EtsArrayType(EtsUnknownType, dimensions = 1)) as? EtsArrayType + ?: return null + if (!state.hasDenseArrayShape(array, type)) return null + return execution(guard = state.ctx.trueExpr) { ctx.mkUndefinedValue() } + } + + private fun elementString(state: TsState, inputs: List>): TsUnknownCallModelExecution? = + with(state.ctx) { + val value = inputs.single() + if (value.isFakeObject()) { + // Only reference payloads are admitted here; numeric/object coercion stays residual. + val kind = value.getFakeType(state.memory) + val ref = value.extractRef(state.memory) + if (state.hasUnsafeStringReference(ref)) return null + val isString = state.memory.types.evalTypeEquals(ref, EtsStringType) + val nullish = mkOr(mkEq(ref, mkUndefinedValue()), mkEq(ref, nullRef)) + val guard = mkAnd(kind.refTypeExpr, mkOr(isString, nullish)) + return execution(guard = guard) { + ctx.mkIte(nullish, mkInitializedStringConstant(""), ref) + } + } + when { + value.sort == boolSort -> execution(guard = trueExpr) { + ctx.mkIte( + value.asExpr(ctx.boolSort), + mkInitializedStringConstant("true"), + mkInitializedStringConstant("false"), + ) + } + value.sort == addressSort -> { + val ref = value.asExpr(addressSort) + if (state.hasUnsafeStringReference(ref)) return null + val nullish = mkOr(mkEq(ref, mkUndefinedValue()), mkEq(ref, nullRef)) + val guard = mkOr(nullish, state.memory.types.evalTypeEquals(ref, EtsStringType)) + execution(guard = guard) { ctx.mkIte(nullish, mkInitializedStringConstant(""), ref) } + } + else -> null + } + } + + private fun TsState.hasUnsafeStringReference(ref: UHeapRef): Boolean = with(ctx) { + when { + ref.hasFakeValueBranch() -> true + ref is UConcreteHeapRef -> ref in associatedFunction + ref is UIteExpr<*> -> hasUnsafeStringReference(ref.trueBranch.asExpr(addressSort)) || + hasUnsafeStringReference(ref.falseBranch.asExpr(addressSort)) + else -> false + } + } + + private fun execution(guard: UBoolExpr, result: TsState.() -> UExpr<*>): TsUnknownCallModelExecution { + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal(result), + ) + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = with(guard.ctx) { mkNot(guard) }, + ) + } + + private class Primitive( + methodName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ) : TsUnknownCallModel { + override val id = "ts.array.primitive.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = "ArrayModelPrimitives", + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (call.receiver != null || call.arguments.size != arity) return null + return implementation(state, call.arguments.map { it.resolved ?: return null }) + } + } +} 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..8ca3b27e4c --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt @@ -0,0 +1,92 @@ +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(errorClass = ERROR_CLASS, modelId = CONSTRUCTOR_ID, methodName = "construct"), + constructorModel( + errorClass = "TypeError", + modelId = "ts.typeError.constructor", + methodName = "constructTypeError", + ), + ) + } + + private fun constructorModel(errorClass: String, modelId: String, methodName: String): TsUnknownCallModel { + val target = TsUnknownCallTarget( + methodName = CONSTRUCTOR_NAME, + enclosingClassName = errorClass, + failureReason = TsUnknownCallFailureReason.RECEIVER_CLASS_NOT_FOUND, + ) + + val entryPoint = artifact.file.allClasses.single { it.name == "ErrorModels" } + .methods + .single { it.name == methodName } + return TsEtsIrUnknownCallModel( + id = modelId, + target = target, + artifact = artifact.copy(entryPoint = entryPoint), + 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 = errorClass) + 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/TsNumberParseFloatModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumberParseFloatModel.kt new file mode 100644 index 0000000000..1ca5d88d26 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumberParseFloatModel.kt @@ -0,0 +1,65 @@ +package org.usvm.machine.call.intrinsic + +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.api.evalTypeEquals +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.TsUnknownCallTarget +import org.usvm.machine.call.hasBuiltinGlobalOwner +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.util.mkArrayLengthLValue +import org.usvm.util.mkFieldLValue + +/** Decimal prefix parsing in source, with exact binary64 arithmetic on a guarded finite domain. */ +internal object TsNumberParseFloatModel : TsBuiltInUnknownCallModelFamily { + private const val MAX_INPUT_LENGTH = 32 + + override val models by lazy { + val model = TsEtsIrUnknownCallModel( + id = "ts.number.parseFloat", + target = TsUnknownCallTarget( + methodName = "parseFloat", + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ), + artifact = loadBundledEtsIrUnknownCallModelArtifact( + resourceName = "/org/usvm/machine/call/models/NumberParseFloatModels.ts", + sourceFileName = "NumberParseFloatModels.ts", + entryPointClassName = "NumberParseFloatModels", + entryPointMethodName = "parseFloat", + ), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + val owner = call.receiver?.source + if (owner == null || !hasBuiltinGlobalOwner(owner, call.callee, expectedName = "Number")) { + null + } else { + call.arguments.firstOrNull()?.resolved?.let(::listOf) + } + }, + domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val input = inputs.single() as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + if (input.hasFakeValueBranch() || input in state.associatedFunction) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + val characters = state.memory.read(mkFieldLValue(addressSort, input, "value")) + val lengthLocation = mkArrayLengthLValue( + characters, + EtsArrayType(EtsNumberType, dimensions = 1), + ) + val length = state.memory.read(lengthLocation) + val isString = state.memory.types.evalTypeEquals(input, EtsStringType) + val isBounded = mkBvSignedLessOrEqualExpr(length, mkBv(MAX_INPUT_LENGTH)) + mkAnd(isString, isBounded) + } + }, + requiredModelIds = setOf("ts.string.primitive.length", "ts.string.primitive.codeUnitAt"), + ) + listOf(model) + } +} 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/TsPrimitiveUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsPrimitiveUnknownCallModel.kt new file mode 100644 index 0000000000..02d61c91e7 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsPrimitiveUnknownCallModel.kt @@ -0,0 +1,34 @@ +package org.usvm.machine.call.intrinsic + +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.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.state.TsState + +/** Dispatches a helper method used by an EtsIR source model. */ +internal class TsPrimitiveUnknownCallModel( + idPrefix: String, + methodName: String, + enclosingClassName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, +) : TsUnknownCallModel { + override val id: String = "$idPrefix.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = enclosingClassName, + 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) + } +} 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..58e708feba --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -0,0 +1,598 @@ +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.expr.mkEcmaScriptToUint32 +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 const val DEFAULT_SPLIT_LIMIT = 4_294_967_295.0 + private const val MAX_SPLIT_INPUT_LENGTH = 15 + + 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 splitAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + if (call.arguments.size > 2) return@TsEtsIrUnknownCallModelInputAdapter null + val separator = inputs.getOrNull(1) + val omitted = separator == null || separator == mkUndefinedValue() + val limit = inputs.getOrNull(2) + val numericLimit = limit?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) + val convertedLimit = when { + limit == null || limit == mkUndefinedValue() -> mkFp64(DEFAULT_SPLIT_LIMIT) + numericLimit != null -> mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = mkEcmaScriptToUint32(numericLimit), + signed = false, + ) + else -> return@TsEtsIrUnknownCallModelInputAdapter null + } + val search = if (omitted) state.mkInitializedStringConstant("") else requireNotNull(separator) + listOf(inputs.first(), search, convertedLimit, mkBool(omitted)) + } + } + + private val splitDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val stringsGuard = receiverAndSearchDomain.evaluate(state, call, inputs) + val receiver = inputs.first() as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + val length = state.memory.read(mkArrayLengthLValue(characters, characterArrayType)) + // At most length + 1 fields fit into the shared bounded array capacity. + mkAnd(stringsGuard, mkBvSignedLessOrEqualExpr(length, mkBv(MAX_SPLIT_INPUT_LENGTH))) + } + } + + 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.split", + methodName = "split", + inputAdapter = splitAdapter, + domainGuard = splitDomain, + ), + 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 { + if (methodName == "split") { + add("ts.array.primitive.allocateStrings") + add("ts.array.primitive.truncateDense") + } + 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", "split")) { + 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 = TsPrimitiveUnknownCallModel( + idPrefix = "ts.string.primitive", + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + 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 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..9c4b0c97e9 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 @@ -22,14 +23,18 @@ import org.usvm.getIntValue import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsSizeSort import org.usvm.machine.TsVirtualMethodCallStmt +import org.usvm.machine.call.TsBuiltInUnknownCallModels 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.intrinsic.TsDateEtsIrModelFamily 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 @@ -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", "parseFloat" -> { + 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,16 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `.valueOf()` method calls if (expr.callee.name == "valueOf") { - return from(handleValueOf(expr, instance)) + val receiverIsDate = scope.calcOnState { + TsDateEtsIrModelFamily.isDateReceiver( + state = this, + source = expr.instance, + receiver = instance, + ) + } + if (!receiverIsDate) { + return from(handleValueOf(expr, instance)) + } } if (instance.sort != addressSort) return TsExprApproximationResult.NoApproximation @@ -116,6 +171,19 @@ internal fun TsExprResolver.tryApproximateInstanceCall( .takeIf { it !is TsUnresolvedSort } ?: addressSort + val usesModelDispatcher = unknownCallDispatcher is TsUnknownCallModelDispatcher + val modeledArrayCall = usesModelDispatcher && TsBuiltInUnknownCallModels.hasPartialApproximationModel( + methodName = expr.callee.name, + enclosingClassName = "Array", + allowUnqualifiedTarget = true, + ) + + // join has no source model yet; the model run intentionally sends it to the residual policy. + if (modeledArrayCall || (usesModelDispatcher && expr.callee.name == "join")) { + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure + } + // Handle 'Array.push()' method calls if (expr.callee.name == "push") { return from(handleArrayPush(expr, instanceType, elementSort, array)) @@ -156,14 +224,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 +240,86 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } } + val dispatcher = unknownCallDispatcher + if (instanceType is EtsStringType && dispatcher is TsUnknownCallModelDispatcher && + TsBuiltInUnknownCallModels.hasPartialApproximationModel( + methodName = expr.callee.name, + enclosingClassName = "String", + ) + ) { + 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..e84393d9b1 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt @@ -0,0 +1,91 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsField +import org.jacodb.ets.model.EtsFieldSignature +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsType +import org.usvm.UConcreteHeapRef +import org.usvm.UHeapRef +import org.usvm.api.typeStreamOf +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.types.singleOrNull +import org.usvm.util.EtsHierarchy +import org.usvm.util.TsResolutionResult +import org.usvm.util.resolveEtsField + +internal val builtInErrorSignature = EtsClassSignature.UNKNOWN.copy(name = "Error") +private val builtInErrorSignatures = setOf(builtInErrorSignature, EtsClassSignature.UNKNOWN.copy(name = "TypeError")) + +internal fun EtsType?.isModeledErrorType(): Boolean = + this is EtsClassType && signature in builtInErrorSignatures + +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 in builtInErrorSignatures && (name == "name" || name == "message") + +internal fun EtsFieldSignature.errorModelStorageField(): String? { + if (enclosingClass !in builtInErrorSignatures && enclosingClass != EtsClassSignature.UNKNOWN) { + return null + } + + return when (name) { + "name" -> ERROR_NAME_STORAGE_FIELD + "message" -> ERROR_MESSAGE_STORAGE_FIELD + else -> null + } +} + +internal data class ModelStorageFieldResolution( + val storageField: String?, + val etsField: TsResolutionResult, + val isModelStorageField: Boolean, +) + +internal fun TsContext.resolveModelStorageField( + scope: TsStepScope, + instanceLocal: EtsLocal?, + instance: UHeapRef, + field: EtsFieldSignature, + hierarchy: EtsHierarchy, +): ModelStorageFieldResolution { + val candidateStorageField = field.errorModelStorageField() + val runtimeType = (instance as? UConcreteHeapRef) + ?.takeIf { candidateStorageField != null } + ?.let { concreteInstance -> + scope.calcOnState { memory.typeStreamOf(concreteInstance).singleOrNull() } + } + val storageField = candidateStorageField.takeIf { + runtimeType.isModeledErrorType() + } + val etsField = when { + storageField != null -> TsResolutionResult.Empty + field.isUnresolvedErrorField() -> resolveEtsField( + instance = instanceLocal, + field = field.copy(enclosingClass = EtsClassSignature.UNKNOWN), + hierarchy = hierarchy, + ) + else -> resolveEtsField(instanceLocal, field, hierarchy) + } + + return ModelStorageFieldResolution( + storageField = storageField, + etsField = etsField, + isModelStorageField = field.isModelStorageDefinitionField() || storageField != null, + ) +} + +private fun EtsFieldSignature.isModelStorageDefinitionField(): Boolean = when (enclosingClass.name) { + "DateValue" -> name == "timestamp" + "ErrorValue" -> isErrorModelStorageDefinitionField() + else -> false +} 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..caee4f5036 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 @@ -5,20 +5,30 @@ import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsStringType import org.usvm.UBoolExpr import org.usvm.UBoolSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.USort +import org.usvm.USymbolicHeapRef import org.usvm.api.makeSymbolicPrimitive +import org.usvm.api.typeStreamOf import org.usvm.isFalse 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.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.ExprWithTypeConstraint +import org.usvm.types.TypesResult 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()) { @@ -26,6 +36,20 @@ fun TsContext.checkNotFake(expr: UExpr<*>) { } } +internal fun TsContext.fieldReceiverLimitation( + scope: TsStepScope, + receiver: UHeapRef, +): TsRuntimeFeatureLimitationReason? { + if (receiver !is UConcreteHeapRef && receiver !is USymbolicHeapRef) { + return TsRuntimeFeatureLimitationReason.CONDITIONAL_FIELD_RECEIVER + } + + val types = scope.calcOnState { memory.typeStreamOf(receiver).take(2) } + return TsRuntimeFeatureLimitationReason.FAKE_FIELD_RECEIVER_TYPE.takeIf { + types is TypesResult.SuccessfulTypesResult && types.types.any { type -> type is EtsFakeType } + } +} + fun TsContext.mkTruthyExpr( expr: UExpr, scope: TsStepScope, @@ -63,10 +87,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 +110,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..a524176d06 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,44 @@ 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) { - "Expected EtsArrayType, got: ${value.array.type}" + if (storageType !is EtsArrayType) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE, + detail = "indexed read requires supported array storage: static=${value.array.type}, storage=$storageType", + ) + return null } - // 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 +97,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..ee44b0c9b9 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 @@ -17,7 +17,6 @@ import org.usvm.util.EtsHierarchy import org.usvm.util.TsResolutionResult import org.usvm.util.createFakeField import org.usvm.util.mkFieldLValue -import org.usvm.util.resolveEtsField private val logger = KotlinLogging.logger {} @@ -47,9 +46,25 @@ internal fun TsExprResolver.handleInstanceFieldRef( // Check for undefined or null property access. checkUndefinedOrNullPropertyRead(scope, instance, propertyName = value.field.name) ?: return null + val receiverLimitation = fieldReceiverLimitation(scope, instance) + if (receiverLimitation != null) { + reportRuntimeFeatureLimitation( + reason = receiverLimitation, + detail = "Field read requires an unsupported receiver: ${value.field.name}", + ) + scope.assert(falseExpr) + return null + } + // Handle reading "length" property. if (value.field.name == "length") { - return readLengthProperty(scope, instanceLocal, instance, options.maxArraySize) + return readLengthProperty( + scope = scope, + instanceLocal = instanceLocal, + instance = instance, + maxArraySize = options.maxArraySize, + onFeatureLimitation = ::reportRuntimeFeatureLimitation, + ) } // Read the field. @@ -62,19 +77,28 @@ fun TsContext.readField( instance: UHeapRef, field: EtsFieldSignature, hierarchy: EtsHierarchy, -): UExpr<*> { +): UExpr<*>? { checkNotFake(instance) - val sort = when (val etsField = resolveEtsField(instanceLocal, field, hierarchy)) { + val (errorStorageField, etsField, isModelStorageField) = resolveModelStorageField( + scope = scope, + instanceLocal = instanceLocal, + instance = instance, + field = field, + hierarchy = hierarchy, + ) + 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 +107,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) } } 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..96756abe9f 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 @@ -10,10 +10,12 @@ import org.jacodb.ets.model.EtsUnknownType import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason 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( @@ -21,9 +23,22 @@ fun TsContext.readLengthProperty( instanceLocal: EtsLocal, instance: UHeapRef, maxArraySize: Int, + onFeatureLimitation: (TsRuntimeFeatureLimitationReason, String) -> Unit, ): 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,12 +48,13 @@ fun TsContext.readLengthProperty( EtsArrayType(EtsUnknownType, dimensions = 1) } - is EtsStringType -> { - // Strings are treated as arrays of characters (represented as strings). - EtsArrayType(EtsStringType, dimensions = 1) + else -> { + onFeatureLimitation( + TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE, + "length read requires supported array storage: static=${instanceLocal.type}, storage=$type", + ) + return null } - - else -> error("Expected EtsArrayType, EtsAnyType or EtsUnknownType, but got: $type") } // Read the length of the array. 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..b04b1f46a8 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 @@ -3,6 +3,7 @@ package org.usvm.machine.expr import io.ksmt.expr.KFp64Value import io.ksmt.utils.asExpr import io.ksmt.utils.cast +import kotlinx.serialization.json.JsonPrimitive import mu.KotlinLogging import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsAndExpr @@ -57,6 +58,7 @@ import org.jacodb.ets.model.EtsPostIncExpr import org.jacodb.ets.model.EtsPreDecExpr import org.jacodb.ets.model.EtsPreIncExpr import org.jacodb.ets.model.EtsPtrCallExpr +import org.jacodb.ets.model.EtsRawEntity import org.jacodb.ets.model.EtsRefType import org.jacodb.ets.model.EtsRemExpr import org.jacodb.ets.model.EtsRightShiftExpr @@ -89,12 +91,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 +120,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 +146,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 +159,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 +169,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 +331,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 +343,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 @@ -424,6 +405,19 @@ class TsExprResolver( // The delete operator removes a property from an object and returns true/false // For property access like "delete obj.prop", we need to handle EtsInstanceFieldRef when (val operand = expr.arg) { + is EtsArrayAccess -> { + // Do not execute a callback past deletion with a stale payload, including on + // exceptional exits that would bypass a model's post-callback density guard. + resolve(operand.array) ?: return null + resolve(operand.index) ?: return null + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_DELETE, + detail = "array deletion requires slot-presence storage", + ) + scope.assert(falseExpr) + null + } + is EtsInstanceFieldRef -> { val instance = resolve(operand.instance)?.asExpr(addressSort) ?: return null @@ -580,15 +574,58 @@ 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) + ?: return@resolveAfterResolved stopUnsupportedStringConcatOperand(side = "left", value = lhs) + val rhsRef = stringStorageRef(rhs) + ?: return@resolveAfterResolved stopUnsupportedStringConcatOperand(side = "right", value = rhs) + scope.calcOnState { concatStrings(lhsRef, rhsRef) } } } return resolveBinaryOperator(TsBinaryOperator.Add, expr) } + private fun stopUnsupportedStringConcatOperand( + side: String, + value: UExpr<*>, + ): UExpr? = with(ctx) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.STRING_CONCAT_OPERAND_CONVERSION, + detail = "Cannot convert $side operand to string: $value", + ) + scope.assert(falseExpr) + + null + } + + private fun stringStorageRef(value: UExpr<*>): UHeapRef? = with(ctx) { + if (value is UIteExpr<*>) { + val trueBranch = stringStorageRef(value.trueBranch) ?: return null + val falseBranch = stringStorageRef(value.falseBranch) ?: return null + + return mkIte(value.condition, trueBranch, falseBranch) + } + + val concrete = concreteStringValue(value) + if (concrete != null && (value == mkTsNullValue() || value == mkUndefinedValue())) { + return mkStringConstant(concrete, scope) + } + + if (value.sort == addressSort) { + val ref = value.asExpr(addressSort) + val type = scope.calcOnState { memory.typeStreamOf(ref).singleOrNull() } + if (type is EtsStringType) { + return ref + } + } + + concrete?.let { mkStringConstant(it, scope) } + } + private fun concreteStringValue(value: UExpr<*>): String? = with(ctx) { when { value == trueExpr -> "true" @@ -638,18 +675,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 +690,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 +705,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 +720,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 +741,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 +767,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 +934,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 +978,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 && @@ -1023,8 +1027,32 @@ class TsExprResolver( override fun visit(value: EtsStaticFieldRef): UExpr<*>? = handleStaticFieldRef(value) override fun visit(value: EtsCaughtExceptionRef): UExpr? { - logger.warn { "visit(${value::class.simpleName}) is not implemented yet" } - error("Not supported $value") + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.CAUGHT_EXCEPTION_VALUE, + detail = "Caught exception value cannot be modeled: $value", + ) + scope.assert(ctx.falseExpr) + + return null + } + + override fun visit(value: EtsRawEntity): UExpr? { + val kindName = when (val raw = value.extra["kindName"]) { + is JsonPrimitive -> raw.content + is String -> raw + else -> null + } + if (value.kind != "UnsupportedValue" || kindName != "RegularExpressionLiteral") { + error("Cannot handle EtsRawEntity: $value") + } + + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.REGULAR_EXPRESSION_LITERAL, + detail = "Regular expression literal is not modeled: $value", + ) + scope.assert(ctx.falseExpr) + + return null } override fun visit(value: EtsGlobalRef): UExpr? { @@ -1071,8 +1099,10 @@ class TsExprResolver( if (expr.type.typeName == "Number") { val clazz = scene.sdkClasses.filter { it.name == "Number" }.maxByOrNull { it.methods.size } - ?: error("No Number class found in SDK") - return@with scope.calcOnState { memory.allocConcrete(clazz.type) } + // The allocation itself does not execute the Number constructor. If the SDK class is + // absent, retain the IR type and let the following constructor call use the residual + // call policy instead of treating the missing SDK declaration as a tool failure. + return@with scope.calcOnState { memory.allocConcrete(clazz?.type ?: resolvedType) } } scope.calcOnState { memory.allocConcrete(resolvedType) } @@ -1092,33 +1122,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 +1148,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..ddf35e55fc 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,41 +15,87 @@ 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<*>, ): Unit? = with(ctx) { // Resolve the array. - val resolvedArray = resolve(lhv.array) ?: return null - check(resolvedArray.sort == addressSort) { - "Expected address sort for array, got: ${resolvedArray.sort}" + val array = run { + val resolved = resolve(lhv.array) ?: return null + if (resolved.isFakeObject()) { + scope.assert(resolved.getFakeType(scope).refTypeExpr) ?: return null + resolved.extractRef(scope) + } else { + check(resolved.sort == addressSort) { + "Expected address sort for array, got: ${resolved.sort}" + } + resolved.asExpr(addressSort) + } } - 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}" + if (arrayType !is EtsArrayType) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE, + detail = "indexed write requires supported array storage: static=${lhv.array.type}, storage=$arrayType", + ) + return null } - 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 +104,8 @@ fun TsContext.assignToArrayIndex( index: UExpr, expr: UExpr<*>, arrayType: EtsArrayType, + onUnsupportedGrowth: (() -> Unit)? = null, + onUnsupportedElementKind: (() -> Unit)? = null, ): Unit? { checkNotFake(array) @@ -69,14 +119,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 +155,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..788724b857 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 @@ -3,15 +3,14 @@ package org.usvm.machine.expr 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.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.UExpr import org.usvm.UHeapRef 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 @@ -22,7 +21,6 @@ import org.usvm.util.TsResolutionResult import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkFieldLValue -import org.usvm.util.resolveEtsField private val logger = KotlinLogging.logger {} @@ -53,6 +51,16 @@ internal fun TsExprResolver.handleAssignToInstanceField( // Check for undefined or null field access. checkUndefinedOrNullPropertyRead(scope, instance, field.name) ?: return null + val receiverLimitation = fieldReceiverLimitation(scope, instance) + if (receiverLimitation != null) { + reportRuntimeFeatureLimitation( + reason = receiverLimitation, + detail = "Field write requires an unsupported receiver: ${field.name}", + ) + scope.assert(falseExpr) + return null + } + val arrayType = scope.calcOnState { arrayStorageType(instance, instanceLocal.type) } as? EtsArrayType if (field.name == "length" && arrayType != null) { return assignToArrayLength( @@ -61,6 +69,7 @@ internal fun TsExprResolver.handleAssignToInstanceField( arrayType = arrayType, value = expr, maxArraySize = options.maxArraySize, + onFeatureLimitation = ::reportRuntimeFeatureLimitation, ) } @@ -74,6 +83,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,42 +99,47 @@ 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 } return scope.doWithState { + if (length != currentLength) denseInputArrays.remove(array) memory.write(lengthLValue, length, guard = trueExpr) } } @@ -136,63 +151,65 @@ 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 (errorStorageField, etsField, isModelStorageField) = resolveModelStorageField( + scope = scope, + instanceLocal = instanceLocal, + instance = unwrappedInstance, + field = field, + hierarchy = 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 { - if (sort is TsUnresolvedSort) { + if (sort is TsUnresolvedSort) { + return scope.doWithState { 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) - if (lValue.sort != expr.sort) { - if (expr.isFakeObject()) { - val lhvType = instanceLocal.type - val value = when (lhvType) { - is EtsBooleanType -> { - pathConstraints += expr.getFakeType(scope).boolTypeExpr - expr.extractBool(scope) - } - - is EtsNumberType -> { - pathConstraints += expr.getFakeType(scope).fpTypeExpr - expr.extractFp(scope) - } - - else -> { - pathConstraints += expr.getFakeType(scope).refTypeExpr - expr.extractRef(scope) - } - } - memory.write(lValue, value.asExpr(lValue.sort), guard = trueExpr) - } else { - TODO("Support enums fields") - } - } else { - memory.write(lValue, expr.asExpr(lValue.sort), guard = trueExpr) + } + } + + val lValue = mkFieldLValue(sort, unwrappedInstance, errorStorageField ?: field.name) + val payload = if (lValue.sort != expr.sort) { + if (expr.isFakeObject()) { + val type = expr.getFakeType(scope) + val (fakePayload, kindGuard) = when (lValue.sort) { + boolSort -> expr.extractBool(scope) to type.boolTypeExpr + fp64Sort -> expr.extractFp(scope) to type.fpTypeExpr + addressSort -> expr.extractRef(scope) to type.refTypeExpr + else -> error("Unsupported field sort: ${lValue.sort}") } + scope.assert(kindGuard) ?: return null + + fakePayload + } else { + TODO("Support enums fields") } + } else { + expr + } + + return scope.doWithState { + memory.write(lValue, payload.asExpr(lValue.sort), guard = trueExpr) } } 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..323723628f 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 @@ -33,8 +33,10 @@ import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME import org.jacodb.ets.utils.callExpr import org.usvm.StepResult import org.usvm.StepScope +import org.usvm.UConcreteHeapRef 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 @@ -59,6 +61,7 @@ import org.usvm.machine.expr.handleAssignToArrayIndex import org.usvm.machine.expr.handleAssignToInstanceField import org.usvm.machine.expr.handleAssignToLocal import org.usvm.machine.expr.handleAssignToStaticField +import org.usvm.machine.expr.isModeledErrorType import org.usvm.machine.expr.mkTruthyExpr import org.usvm.machine.expr.readGlobal import org.usvm.machine.expr.tryApproximateInstanceCall @@ -70,6 +73,7 @@ import org.usvm.machine.state.localsCount import org.usvm.machine.state.newStmt import org.usvm.machine.state.parametersWithThisCount import org.usvm.machine.state.returnValue +import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.toAuxiliaryType import org.usvm.sizeSort @@ -77,8 +81,8 @@ import org.usvm.targets.UTargetsSet import org.usvm.types.TypesResult import org.usvm.types.first import org.usvm.types.single +import org.usvm.types.singleOrNull 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 @@ -102,6 +106,12 @@ class TsInterpreter( ) : UInterpreter() { private val forkBlackList: UForkBlackList = UForkBlackList.createDefault() + internal var stepFailed: Boolean = false + private set + + internal fun resetStepFailure() { + stepFailed = false + } override fun step(state: TsState): StepResult { val stmt = state.lastStmt @@ -151,6 +161,7 @@ class TsInterpreter( } } } catch (e: Exception) { + stepFailed = true if (throwExceptionOnStepFailure) { throw e } @@ -252,6 +263,11 @@ class TsInterpreter( return } + if (possibleTypesSet.any { it is EtsFakeType }) { + unknownCallDispatcher.dispatch(scope, stmt, Reason.UNSUPPORTED_RECEIVER_TYPE, receiver) + return + } + val filteredPossibleTypesSet = possibleTypesSet - EtsAnyType val methodsDeclaringClasses = concreteMethods.mapNotNull { it.enclosingClass } // is it right? @@ -527,32 +543,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) } } @@ -631,6 +623,8 @@ class TsInterpreter( assignTo(scope, stmt.lhv, expr) ?: return } + observer?.onAssignmentCompleted(exprResolver.simpleValueResolver, stmt, scope) + val nextStmt = stmt.nextStmt ?: return scope.doWithState { newStmt(nextStmt) } } @@ -643,8 +637,10 @@ class TsInterpreter( return } + val exprResolver = exprResolverWithScope(scope) + observer?.onCallStatement(exprResolver.simpleValueResolver, stmt, scope) + if (options.interproceduralAnalysis) { - val exprResolver = exprResolverWithScope(scope) exprResolver.resolve(stmt.expr) ?: return val nextStmt = stmt.nextStmt ?: return scope.doWithState { newStmt(nextStmt) } @@ -670,10 +666,10 @@ class TsInterpreter( if (exception != null) { val exceptionType: EtsType = when (exception.sort) { ctx.addressSort -> { - // If it's an object reference, try to determine its type - val ref = exception.asExpr(ctx.addressSort) - // For now, assume it's a generic error type - EtsStringType // TODO: improve type detection + val ref = exception as? UConcreteHeapRef + val runtimeType = ref?.let { scope.calcOnState { memory.typeStreamOf(it).singleOrNull() } } + runtimeType?.takeIf { it.isModeledErrorType() } + ?: EtsStringType // TODO: improve type detection for other object exceptions } ctx.fp64Sort -> EtsNumberType @@ -710,9 +706,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 +741,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 +809,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..100b1c3d11 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,11 @@ 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 length: UExpr, + val lengthRegion: UReadOnlyMemoryRegion<*, *>, + val elementRegion: UReadOnlyMemoryRegion<*, *>, +) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt index cefcc35885..f126ae1423 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt @@ -70,6 +70,12 @@ class TsTypeSystem( // "never" is the universal subtype: never can be assigned to any type. if (unwrappedType is EtsNeverType) return true + // A fake type only matches itself. Distinct wrappers do not establish a + // TypeScript subtype relation; callers handle those branches explicitly. + if (unwrappedType is EtsFakeType || unwrappedSupertype is EtsFakeType) { + return unwrappedSupertype === unwrappedType + } + // When "never" is in supertype position, only never <: never. if (unwrappedSupertype is EtsNeverType) return type is EtsNeverType @@ -164,10 +170,6 @@ class TsTypeSystem( // Class and structural types - require(unwrappedType !is EtsFakeType && unwrappedSupertype !is EtsFakeType) { - "Fake types should not occur in type constraints" - } - if (unwrappedSupertype is EtsAuxiliaryType && unwrappedType is EtsAuxiliaryType) { return unwrappedType.properties.all { it in unwrappedSupertype.properties } } 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..21e4fe3594 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,51 @@ 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) + val length = memory.read(mkArrayLengthLValue(array, type)) + denseInputArrays[array] = TsDenseInputArray( + type = type, + length = length, + 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 +} + +/** In-range value writes preserve density; length changes and deletions do not. */ +internal fun TsState.hasDenseArrayShape(array: UConcreteHeapRef, type: EtsArrayType): Boolean = with(ctx) { + val snapshot = denseInputArrays[array] ?: return false + if (snapshot.type != type) return false + + // Other arrays share the region but cannot change this receiver's density. + memory.read(mkArrayLengthLValue(array, type)) == snapshot.length +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/EtsFieldResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/util/EtsFieldResolver.kt index 607a702e42..2fbbe2fa8a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/EtsFieldResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/EtsFieldResolver.kt @@ -24,7 +24,8 @@ fun TsContext.resolveEtsField( if (field.enclosingClass.name != UNKNOWN_CLASS_NAME) { val classes = hierarchy.classesForType(EtsClassType(field.enclosingClass)) if (classes.isEmpty()) { - error("Cannot resolve class ${field.enclosingClass.name}") + logger.warn { "Cannot resolve class ${field.enclosingClass.name} for field ${field.name}" } + return TsResolutionResult.Empty } if (classes.size > 1) { error("Multiple classes with name ${field.enclosingClass.name}") 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..8847e81c30 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -0,0 +1,298 @@ +package org.usvm.util + +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.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType +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.expr.extractInt +import org.usvm.machine.state.TsState +import org.usvm.model.UModelBase +import org.usvm.sizeSort + +internal val STRING_CHARACTER_ARRAY_TYPE = EtsArrayType(EtsNumberType, dimensions = 1) +private const val UTF16_CODE_UNIT_MASK = 0xffff + +/** Builds a bounded string in the engine's UTF-16 layout, constraining its length to the supplied capacity. */ +fun TsState.mkStringFromCodeUnits( + length: UExpr, + codeUnits: List>, +): UConcreteHeapRef = with(ctx) { + pathConstraints += mkBvSignedGreaterOrEqualExpr(length, mkBv(0)) + pathConstraints += mkBvSignedLessOrEqualExpr(length, mkBv(codeUnits.size)) + val (string, characters) = allocateString(length = length, maxLength = codeUnits.size) + codeUnits.forEachIndexed { index, unit -> + val position = mkBv(index) + memory.write( + mkArrayIndexLValue(bv16Sort, characters, position, STRING_CHARACTER_ARRAY_TYPE), + unit, + guard = mkBvSignedLessExpr(position, length), + ) + } + + string +} + +/** Reads a bounded string from current memory under [model]; returns null for untracked, nonconstant refs. */ +fun TsState.resolveStringFromModel(model: UModelBase, ref: UConcreteHeapRef): String? = with(ctx) { + val maxLength = stringMaxLengths[ref] ?: return@with getStringConstantValue(ref) + val characters = stringCharacters(ref) + val length = model.eval(stringLength(ref)).extractInt() + require(length in 0..maxLength) { "Resolved string length $length exceeds its stored bounds" } + + buildString(length) { + repeat(length) { index -> + val slot = mkArrayIndexLValue(bv16Sort, characters, mkBv(index), STRING_CHARACTER_ARRAY_TYPE) + val unit = memory.read(slot) + val resolved = model.eval(unit) as KBitVec16Value + append((resolved.shortValue.toInt() and UTF16_CODE_UNIT_MASK).toChar()) + } + } +} + +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/kotlin/org/usvm/util/TsImports.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt index 51bc7f56ab..70fc7ce3c0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TsImports.kt @@ -103,15 +103,20 @@ private fun EtsScene.resolveRelativePath(currentFile: EtsFile, importPath: Strin normalizeRelativePath("$currentDir/$importPath") } - val foundFile = (projectFiles + sdkFiles).find { file -> - val fileName = file.signature.fileName - fileName == targetPath || + val files = projectFiles + sdkFiles + val exactFile = files.find { it.signature.fileName == targetPath } + val foundFile = exactFile ?: if (targetPath.endsWith(".js")) { + files.find { it.signature.fileName == targetPath.removeSuffix(".js") + ".ts" } + } else { + files.find { file -> + val fileName = file.signature.fileName fileName == "$targetPath.ts" || - fileName == "$targetPath.ets" || - fileName == "$targetPath.d.ts" || - fileName == "$targetPath/index.ts" || - fileName == "$targetPath/index.ets" || - fileName == "$targetPath/index.d.ts" + fileName == "$targetPath.ets" || + fileName == "$targetPath.d.ts" || + fileName == "$targetPath/index.ts" || + fileName == "$targetPath/index.ets" || + fileName == "$targetPath/index.d.ts" + } } return if (foundFile != null) { 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..7df2c6545e 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,49 @@ +declare class ArrayModelPrimitives { + static grow(receiver: any[], length: number): void; + static allocate(length: number): any[]; + static allocateLike(receiver: any[], length: number): any[]; + static elementString(value: any): string; + static requireDense(receiver: any[]): void; +} + export class ArrayModels { + static join(receiver: any[], separator: string): string { + const length = receiver.length; + let result = ""; + let index = 0; + while (index < length) { + if (index !== 0) result += separator; + result += ArrayModelPrimitives.elementString(receiver[index]); + index++; + } + return result; + } + + static reduce(receiver: any[], callback: (acc: any, current: any, index: number, array: any[]) => any, + initial: any, hasInitial: boolean): any { + const length = receiver.length; + if (length === 0 && !hasInitial) throw new TypeError("Reduce of empty array with no initial value"); + + let accumulator = initial; + let index = 0; + if (!hasInitial) { + accumulator = receiver[0]; + index = 1; + } + while (index < length) { + accumulator = callback(accumulator, receiver[index], index, receiver); + // Element mutation is supported. Structural mutation/deletion remains residual + // until the engine can represent HasProperty for holes after arbitrary callbacks. + ArrayModelPrimitives.requireDense(receiver); + index++; + } + return accumulator; + } + + static fromLength(length: number): any[] { + return ArrayModelPrimitives.allocate(length); + } + static pop(receiver: any[]): any { const length = receiver.length; if (length === 0) { @@ -9,4 +54,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..bdd2433458 --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -0,0 +1,564 @@ +// 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 { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + return DateModels.replaceDate(receiver, current.year, current.month, date, current); + } + + 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 { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextMinutes = argumentCount >= 2 ? minutes : current.minutes; + const nextSeconds = argumentCount >= 3 ? seconds : current.seconds; + const nextMilliseconds = argumentCount >= 4 ? milliseconds : current.milliseconds; + return DateModels.replaceTime(receiver, current, hours, nextMinutes, nextSeconds, nextMilliseconds); + } + + static setMilliseconds(receiver: DateValue, milliseconds: number): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + return DateModels.replaceTime( + receiver, current, current.hours, current.minutes, current.seconds, milliseconds, + ); + } + + static setMinutes( + receiver: DateValue, + argumentCount: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextSeconds = argumentCount >= 2 ? seconds : current.seconds; + const nextMilliseconds = argumentCount >= 3 ? milliseconds : current.milliseconds; + return DateModels.replaceTime(receiver, current, current.hours, minutes, nextSeconds, nextMilliseconds); + } + + static setMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextDate = argumentCount >= 2 ? date : current.date; + return DateModels.replaceDate(receiver, current.year, month, nextDate, current); + } + + static setSeconds(receiver: DateValue, argumentCount: number, seconds: number, milliseconds: number): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextMilliseconds = argumentCount >= 2 ? milliseconds : current.milliseconds; + return DateModels.replaceTime( + receiver, current, current.hours, current.minutes, seconds, nextMilliseconds, + ); + } + + 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 replaceTime( + receiver: DateValue, + current: DateParts, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + receiver.timestamp = DateModels.makeDate( + current.year, + current.month, + current.date, + hours, + minutes, + seconds, + milliseconds, + ); + 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..fc34c648da --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts @@ -0,0 +1,19 @@ +export class ErrorValue { + // Keep model storage out of unresolved user-property lookup. + __usvmErrorName: string; + __usvmErrorMessage: string; +} + +export class ErrorModels { + static constructTypeError(receiver: ErrorValue, message: string): ErrorValue { + receiver.__usvmErrorName = "TypeError"; + receiver.__usvmErrorMessage = message; + return receiver; + } + + 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/NumberParseFloatModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/NumberParseFloatModels.ts new file mode 100644 index 0000000000..eeeb79affb --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/NumberParseFloatModels.ts @@ -0,0 +1,109 @@ +declare class StringModelPrimitives { + static length(receiver: string): number; + static codeUnitAt(receiver: string, index: number): number; +} + +declare class NumberParseFloatPrimitives { + // Deliberately unmodeled: the residual policy handles decimals outside the exact domain. + static unsupportedDecimal(): number; +} + +export class NumberParseFloatModels { + static parseFloat(input: string): number { + const length = StringModelPrimitives.length(input); + let index = 0; + while (index < length && NumberParseFloatModels.isWhitespace( + StringModelPrimitives.codeUnitAt(input, index))) index++; + + let negative = false; + if (index < length) { + const sign = StringModelPrimitives.codeUnitAt(input, index); + if (sign === 43 || sign === 45) { + negative = sign === 45; + index++; + } + } + if (NumberParseFloatModels.isInfinity(input, index, length)) return negative ? -Infinity : Infinity; + + let coefficient = 0; + let significantDigits = 0; + let digits = 0; + let fractionalDigits = 0; + let point = false; + while (index < length) { + const code = StringModelPrimitives.codeUnitAt(input, index); + if (code >= 48 && code <= 57) { + const digit = code - 48; + if (coefficient !== 0 || digit !== 0) significantDigits++; + // Keep all arithmetic exact; rejected long mantissas never produce an approximation. + if (significantDigits > 15) return NumberParseFloatPrimitives.unsupportedDecimal(); + coefficient = coefficient * 10 + digit; + digits++; + if (point) fractionalDigits++; + index++; + } else if (code === 46 && !point) { + point = true; + index++; + } else { + break; + } + } + if (digits === 0) return NaN; + + let exponent = 0; + if (index < length) { + const code = StringModelPrimitives.codeUnitAt(input, index); + if (code === 69 || code === 101) { + index++; + let exponentNegative = false; + if (index < length) { + const sign = StringModelPrimitives.codeUnitAt(input, index); + if (sign === 43 || sign === 45) { + exponentNegative = sign === 45; + index++; + } + } + // With no exponent digits this remains zero: parseFloat accepts the preceding prefix. + while (index < length) { + const digit = StringModelPrimitives.codeUnitAt(input, index) - 48; + if (digit < 0 || digit > 9) break; + if (exponent < 1000) exponent = exponent * 10 + digit; + index++; + } + if (exponentNegative) exponent = -exponent; + } + } + if (coefficient === 0) return negative ? -0 : 0; + const scale = exponent - fractionalDigits; + if (scale < -22 || scale > 22) return NumberParseFloatPrimitives.unsupportedDecimal(); + + // Every integer coefficient and every power 10^k, k <= 22, is exactly representable. + // A single final multiply/divide therefore supplies the required binary64 rounding. + let power = 1; + let remaining = scale < 0 ? -scale : scale; + while (remaining > 0) { + power *= 10; + remaining--; + } + const result = scale < 0 ? coefficient / power : coefficient * power; + return negative ? -result : result; + } + + private static isInfinity(input: string, start: number, length: number): boolean { + if (start + 8 > length) return false; + return StringModelPrimitives.codeUnitAt(input, start) === 73 && + StringModelPrimitives.codeUnitAt(input, start + 1) === 110 && + StringModelPrimitives.codeUnitAt(input, start + 2) === 102 && + StringModelPrimitives.codeUnitAt(input, start + 3) === 105 && + StringModelPrimitives.codeUnitAt(input, start + 4) === 110 && + StringModelPrimitives.codeUnitAt(input, start + 5) === 105 && + StringModelPrimitives.codeUnitAt(input, start + 6) === 116 && + StringModelPrimitives.codeUnitAt(input, start + 7) === 121; + } + + private static isWhitespace(code: number): boolean { + return (code >= 9 && code <= 13) || code === 32 || code === 160 || code === 5760 || + (code >= 8192 && code <= 8202) || code === 8232 || code === 8233 || code === 8239 || + code === 8287 || code === 12288 || code === 65279; + } +} 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..1f641eb35f --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -0,0 +1,392 @@ +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; +} + +declare class ArrayModelPrimitives { + static allocateStrings(length: number): string[]; + static truncateDense(receiver: string[], length: number): string[]; +} + +export class StringModels { + static split(receiver: string, separator: string, limit: number, omitted: boolean): string[] { + const result = ArrayModelPrimitives.allocateStrings(16); + // The adapter supplies an exact uint32 limit, including the omitted default. + if (limit === 0) return ArrayModelPrimitives.truncateDense(result, 0); + if (omitted) { + result[0] = receiver; + return ArrayModelPrimitives.truncateDense(result, 1); + } + + const length = StringModelPrimitives.length(receiver); + const separatorLength = StringModelPrimitives.length(separator); + let count = 0; + if (separatorLength === 0) { + while (count < length && count < limit) { + result[count] = StringModelPrimitives.copyRange(receiver, count, count + 1); + count++; + } + return ArrayModelPrimitives.truncateDense(result, count); + } + + let start = 0; + let position = 0; + while (position + separatorLength <= length) { + let offset = 0; + while (offset < separatorLength && + StringModelPrimitives.codeUnitAt(receiver, position + offset) === + StringModelPrimitives.codeUnitAt(separator, offset)) { + offset++; + } + if (offset === separatorLength) { + result[count] = StringModelPrimitives.copyRange(receiver, start, position); + count++; + if (count === limit) return ArrayModelPrimitives.truncateDense(result, count); + position += separatorLength; + start = position; + } else { + position++; + } + } + result[count] = StringModelPrimitives.copyRange(receiver, start, length); + return ArrayModelPrimitives.truncateDense(result, count + 1); + } + + 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/ImportExportResolution.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt index 660b670a17..ef86deb4f5 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/ImportExportResolution.kt @@ -138,6 +138,53 @@ class ImportExportResolutionTest { } // Test symbol resolution for default imports + @Test + fun `JavaScript specifier resolves the TypeScript source when emitted JavaScript is absent`() { + val source = createMockFile( + signature = EtsFileSignature("TestProject", "src/utils/value.ts"), + exports = emptyList(), + ) + val project = EtsScene(projectFiles = listOf(currentFile, source)) + + val result = project.resolveImport(currentFile, "../utils/value.js") + + assertEquals(source, assertIs(result).file) + } + + @Test + fun `JavaScript specifier never resolves a doubly suffixed TypeScript path`() { + val source = createMockFile( + signature = EtsFileSignature("TestProject", "src/utils/value.ts"), + exports = emptyList(), + ) + val unrelated = createMockFile( + signature = EtsFileSignature("TestProject", "src/utils/value.js.ts"), + exports = emptyList(), + ) + val project = EtsScene(projectFiles = listOf(currentFile, unrelated, source)) + + val result = project.resolveImport(currentFile, "../utils/value.js") + + assertEquals(source, assertIs(result).file) + } + + @Test + fun `JavaScript specifier prefers exact source over a TypeScript alternative`() { + val typescript = createMockFile( + signature = EtsFileSignature("TestProject", "src/utils/value.ts"), + exports = emptyList(), + ) + val javascript = createMockFile( + signature = EtsFileSignature("TestProject", "src/utils/value.js"), + exports = emptyList(), + ) + val project = EtsScene(projectFiles = listOf(currentFile, typescript, javascript)) + + val result = project.resolveImport(currentFile, "../utils/value.js") + + assertEquals(javascript, assertIs(result).file) + } + @Test @DisplayName("Test default import symbol resolution") fun testDefaultImportResolution() { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt new file mode 100644 index 0000000000..ae88adf7ac --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/StringEngineTest.kt @@ -0,0 +1,111 @@ +package org.usvm.machine + +import io.ksmt.sort.KBv16Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.api.makeSymbolicPrimitive +import org.usvm.sizeSort +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import org.usvm.util.mkRegisterStackLValue +import org.usvm.util.mkStringFromCodeUnits +import org.usvm.util.resolveStringFromModel +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration + +class StringEngineTest { + private val file = loadEtsFileAutoConvert( + getResourcePath("/models/SymbolicStringEngine.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(file)) + + @Test + fun `length uses UTF-16 units through a widened alias`() { + val direct = execute("length") + val alias = execute("anyAliasLength") + + assertEquals(2.0, assertIs(direct).number) + assertEquals(1.0, assertIs(alias).number) + } + + @Test + fun `character operations preserve isolated surrogate units`() { + val code = execute("charCodeAt") + val charEquals = execute("charAtEquals") + val indexEquals = execute("indexedReadEquals") + val outOfRange = execute("outOfRangeCharAt") + + assertEquals(0xd800.toDouble(), assertIs(code).number) + assertEquals(true, assertIs(charEquals).value) + assertEquals(true, assertIs(indexEquals).value) + assertEquals("", assertIs(outOfRange).value) + } + + @Test + fun `shared allocation and decoding preserve a solver selected surrogate`() { + val method = scene.projectClasses.single { it.name == "SymbolicStringEngine" } + .methods + .single { it.name == "inputRoundtrip" } + lateinit var input: UConcreteHeapRef + val options = UMachineOptions(stateCollectionStrategy = StateCollectionStrategy.ALL) + + TsMachine(scene = scene, options = options, tsOptions = TsOptions()).use { machine -> + val states = machine.analyze( + methods = listOf(method), + configureInitialState = { _, state -> + with(state.ctx) { + val length: UExpr = state.makeSymbolicPrimitive(sizeSort) + val unit: UExpr = state.makeSymbolicPrimitive(bv16Sort) + state.pathConstraints += mkEq(length, mkBv(1)) + state.pathConstraints += mkEq(unit, mkBv(0xd800, bv16Sort)) + input = state.mkStringFromCodeUnits(length = length, codeUnits = listOf(unit)) + state.memory.write( + mkRegisterStackLValue(addressSort, 1), + input.asExpr(addressSort), + guard = trueExpr, + ) + } + }, + ) + + val state = states.single() + assertEquals("\ud800", state.resolveStringFromModel(model = state.models.single(), ref = input)) + val decoded = TsTestResolver().resolve(method, state).returnValue + assertEquals("\ud800", assertIs(decoded).value) + } + } + + private fun execute(name: String): TsTestValue { + val method = scene.projectClasses.single { it.name == "SymbolicStringEngine" } + .methods + .single { it.name == name } + val options = 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, + ) + + return TsMachine(scene = scene, options = options, tsOptions = TsOptions()).use { machine -> + val state = machine.analyze(listOf(method)).single() + TsTestResolver().resolve(method, state).returnValue + } + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTypeToSortTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTypeToSortTest.kt new file mode 100644 index 0000000000..65b829f429 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsContextTypeToSortTest.kt @@ -0,0 +1,51 @@ +package org.usvm.machine + +import io.mockk.mockk +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsIntersectionType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsUnclearRefType +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class TsContextTypeToSortTest { + private val ctx = TsContext( + scene = EtsScene(projectFiles = emptyList()), + components = mockk(), + ) + + @Test + fun `intersection of reference types has reference sort`() { + val window = EtsClassType( + signature = EtsClassSignature( + name = "Window", + file = EtsFileSignature.UNKNOWN, + ), + ) + val globalThis = EtsUnclearRefType( + name = "globalThis", + typeParameters = emptyList(), + ) + val type = EtsIntersectionType(types = listOf(window, globalThis)) + + val sort = ctx.typeToSort(type) + + assertEquals(ctx.addressSort, sort) + } + + @Test + fun `intersection with incompatible sorts remains unresolved`() { + val globalThis = EtsUnclearRefType( + name = "globalThis", + typeParameters = emptyList(), + ) + val type = EtsIntersectionType(types = listOf(EtsNumberType, globalThis)) + + val sort = ctx.typeToSort(type) + + assertEquals(ctx.unresolvedSort, sort) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt new file mode 100644 index 0000000000..a302c530cd --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/TsSharedAnalysisTest.kt @@ -0,0 +1,201 @@ +package org.usvm.machine + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Test +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallModelSelection +import org.usvm.machine.expr.TsSimpleValueResolver +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.state.TsMethodResult +import org.usvm.util.getResourcePath +import org.usvm.util.mkRegisterStackLValue +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsSharedAnalysisTest { + private val file = loadEtsFileAutoConvert( + getResourcePath("/models/SharedAnalysis.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(file)) + private val options = UMachineOptions( + stateCollectionStrategy = StateCollectionStrategy.ALL, + stopOnCoverage = 0, + timeout = Duration.INFINITE, + solverType = SolverType.Z3, + ) + private val tsOptions = TsOptions(unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet())) + + @Test + fun `both initial configurators run in order before the first model and preserve sort overrides`() { + val callbacks = mutableListOf() + val method = method("identity") + val machine = TsMachine( + scene = scene, + options = options, + tsOptions = tsOptions, + initialParameterSortOverride = { ctx, slot -> ctx.fp64Sort.takeIf { slot == 1 } }, + initialStateConfigurator = { callbacks += "constructor" }, + ) + + machine.use { + val result = it.analyzeWithMetadata( + methods = listOf(method), + configureInitialState = { configuredMethod, state -> + assertEquals(method, configuredMethod) + callbacks += "analysis" + with(state.ctx) { + val input = state.memory.read(mkRegisterStackLValue(fp64Sort, 1)).asExpr(fp64Sort) + state.pathConstraints += mkFpEqualExpr(input, mkFp(7.0, fp64Sort)) + } + }, + ) + + assertEquals(listOf("constructor", "analysis"), callbacks) + assertFalse(result.engineFailed) + val state = result.states.single() + val value = (state.methodResult as TsMethodResult.Success).value + assertEquals(state.ctx.mkFp(7.0, state.ctx.fp64Sort), state.models.single().eval(value)) + } + } + + @Test + fun `step limit and zero time budget have distinct metadata`() { + val limited = analyze(options = options.copy(stepLimit = 1uL)) + val timedOut = analyze(options = options.copy(timeout = Duration.ZERO)) + + assertEquals(TsAnalysisStopReason.STOPPED, limited.stopReason) + assertFalse(limited.timedOut) + assertEquals(TsAnalysisStopReason.STOPPED, timedOut.stopReason) + assertTrue(timedOut.timedOut) + assertTrue(timedOut.states.isEmpty()) + } + + @Test + fun `stopped residual and runtime limitation are distinct and reset per analysis`() { + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { events += "call" } + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { events += "runtime" } + } + + TsMachine(scene = scene, options = options, tsOptions = tsOptions, observer = observer).use { machine -> + val unknown = machine.analyzeWithMetadata(methods = listOf(method("unknown"))) + val limited = machine.analyzeWithMetadata(methods = listOf(method("limited"))) + val success = machine.analyzeWithMetadata(methods = listOf(method("success"))) + + assertTrue(unknown.unsupportedCall) + assertFalse(unknown.runtimeLimited) + assertFalse(unknown.engineFailed) + assertFalse(limited.unsupportedCall) + assertTrue(limited.runtimeLimited) + assertFalse(limited.engineFailed) + assertFalse(success.unsupportedCall) + assertFalse(success.runtimeLimited) + assertFalse(success.engineFailed) + assertEquals(listOf("call", "runtime"), events) + } + } + + @Test + fun `fresh residual return is not reported as a stopped call`() { + val result = TsMachine( + scene = scene, + options = options, + tsOptions = tsOptions.copy(unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN), + ).use { it.analyzeWithMetadata(methods = listOf(method("unknown"))) } + + assertFalse(result.unsupportedCall) + assertFalse(result.engineFailed) + assertTrue(result.states.isNotEmpty()) + } + + @Test + fun `suppressed step failure is reported and reset for a later analysis`() { + var shouldFail = true + val observer = object : TsInterpreterObserver { + override fun onReturnStatement( + simpleValueResolver: TsSimpleValueResolver, + stmt: EtsReturnStmt, + scope: TsStepScope, + ) { + check(!shouldFail) { "Injected interpreter callback failure" } + } + } + + TsMachine(scene = scene, options = options, tsOptions = tsOptions, observer = observer).use { machine -> + val failed = machine.analyzeWithMetadata(methods = listOf(method("success"))) + shouldFail = false + val recovered = machine.analyzeWithMetadata(methods = listOf(method("success"))) + + assertTrue(failed.engineFailed) + assertTrue(failed.states.isEmpty()) + assertFalse(recovered.engineFailed) + assertEquals(1, recovered.states.size) + } + } + + @Test + fun `observers see standalone calls once and assignments after their value is stored`() { + val calls = mutableListOf() + var assignments = 0 + val observer = object : TsInterpreterObserver { + override fun onCallStatement( + simpleValueResolver: TsSimpleValueResolver, + stmt: EtsCallStmt, + scope: TsStepScope, + ) { + calls += stmt.expr.callee.name + } + + override fun onAssignmentCompleted( + simpleValueResolver: TsSimpleValueResolver, + stmt: EtsAssignStmt, + scope: TsStepScope, + ) { + if ((stmt.lhv as? EtsLocal)?.name != "result") return + + assignments++ + val value = stmt.lhv.accept(simpleValueResolver) + val expected = scope.calcOnState { ctx.mkFp(7.0, ctx.fp64Sort) } + assertEquals(expected, value) + } + } + + TsMachine( + scene = scene, + options = options.copy(throwExceptionOnStepFailure = true), + tsOptions = tsOptions, + observer = observer, + ).use { machine -> + machine.analyze(methods = listOf(method("observed"))) + machine.analyze(methods = listOf(method("unknown"))) + } + + assertEquals(listOf("callee"), calls) + assertEquals(1, assignments) + } + + private fun method(name: String) = scene.projectClasses.single { it.name == "SharedAnalysis" } + .methods + .single { it.name == name } + + private fun analyze(options: UMachineOptions) = TsMachine( + scene = scene, + options = options, + tsOptions = tsOptions, + ).use { it.analyzeWithMetadata(methods = listOf(method("success"))) } +} 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..67802a48a3 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -0,0 +1,233 @@ +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.callExpr +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.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 `Date alias model observation keeps the frontend callee`() { + val method = method("anyAliasGetTime") + val sourceCall = assertNotNull( + method.cfg.stmts.single { stmt -> stmt.callExpr?.callee?.name == "getTime" }.callExpr + ) + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + + assertTrue(states.isNotEmpty()) + val dateEvents = events.filter { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.date.getTime" + } + assertTrue(dateEvents.isNotEmpty()) + assertTrue(dateEvents.all { event -> event.callee == sourceCall.callee }) + } + + @Test + fun `Date model does not accept a foreign receiver cast to Date`() { + assertTrue(analyze(method("castForeignReceiver")).isEmpty()) + } + + @Test + fun `Date model does not replace an unavailable user Date static method`() { + val shadowFile = loadEtsFileAutoConvert( + getResourcePath("/models/DateShadowEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val shadowScene = EtsScene(listOf(shadowFile)) + val method = shadowScene.projectClasses + .single { it.name == "DateShadowEtsIr" } + .methods + .single { it.name == "call" } + + val states = TsMachine( + scene = shadowScene, + options = machineOptions, + tsOptions = TsOptions(), + ).use { machine -> + machine.analyze(listOf(method)) + } + + assertTrue(states.isEmpty()) + } + + @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..e06c301de4 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt @@ -0,0 +1,166 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +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) + val exception = assertIs(result.states.single().methodResult) + assertEquals(EtsClassType(EtsClassSignature.UNKNOWN.copy(name = "Error")), exception.type) + assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) + assertTrue(result.values.single() is TsTestValue.TsException) + } + + @Test + fun `modeled TypeError retains its type when thrown`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "throwTypeError") + + val exception = assertIs(result.states.single().methodResult) + assertEquals(EtsClassType(EtsClassSignature.UNKNOWN.copy(name = "TypeError")), exception.type) + assertEquals(listOf("ts.typeError.constructor"), result.modelIds) + } + + @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/TsGapModelsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsTest.kt new file mode 100644 index 0000000000..7613f2f2ff --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsGapModelsTest.kt @@ -0,0 +1,290 @@ +package org.usvm.machine.call + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStringType +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 java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsGapModelsTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/GapModels.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `split reverse join compose on UTF16 strings`() { + val result = analyze(methodName = "reverseString") + + assertEquals("cba", assertIs(result.values.single()).value) + assertTrue(setOf("ts.string.split", "ts.array.reverse", "ts.array.join").all(result.modelIds::contains)) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `split separator limit and empty field semantics`() { + val result = analyze(methodName = "splitEdges") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.split" in result.modelIds) + } + + @Test + fun `reduce invokes callback with captured variables and array aliases`() { + val result = analyze(methodName = "reduceMutation", denseInputs = listOf(listOf(1.0, 2.0, 3.0))) + + assertEquals(75.0, result.singleNumber()) + assertTrue("ts.array.reduce" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `reduce handles omitted initial value`() { + val result = analyze(methodName = "reduceNoInitial", denseInputs = listOf(listOf(1.0, 2.0, 3.0))) + + assertEquals(6.0, result.singleNumber()) + assertTrue("ts.array.reduce" in result.modelIds) + } + + @Test + fun `reduce handles explicit undefined initial value`() { + val result = analyze(methodName = "reduceUndefined", denseInputs = listOf(listOf(1.0))) + + assertEquals(8.0, result.singleNumber()) + assertTrue("ts.array.reduce" in result.modelIds) + } + + @Test + fun `reduce structural mutation remains residual`() { + val result = analyze(methodName = "reduceShrink", denseInputs = listOf(listOf(1.0, 2.0, 3.0))) + + assertTrue(result.values.isEmpty()) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + @Test + fun `parseFloat accepts longest decimal prefix and exact rounding`() { + val result = analyze(methodName = "parseFloatEdges") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.number.parseFloat" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `parseFloat large decimal remains residual`() { + val result = analyze(methodName = "parseFloatUnsupported") + + assertTrue(result.values.isEmpty()) + assertTrue("ts.number.parseFloat" in result.modelIds) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + @Test + fun `source algorithms agree with native JavaScript`() { + val script = getResourcePath("/models/GapModelsDifferential.mjs") + val sourceDirectory = getResourcePath("/org/usvm/machine/call/models/StringModels.ts").parent + val process = ProcessBuilder( + "node", + "--experimental-strip-types", + script.toString(), + sourceDirectory.toString(), + ) + .redirectErrorStream(true) + .start() + + val completed = process.waitFor(30, TimeUnit.SECONDS) + if (!completed) process.destroyForcibly() + assertTrue(completed, "Node differential cases timed out") + val output = process.inputStream.bufferedReader().readText() + assertEquals(0, process.exitValue(), output) + } + + @Test + fun `join preserves nullish elements separators and booleans`() { + val strings = analyze(methodName = "joinDefault", denseInputs = listOf(listOf("a", null, "b"))) + val booleans = analyze(methodName = "joinBooleans", denseInputs = listOf(listOf(true, false))) + + assertEquals("a,,b", assertIs(strings.values.single()).value) + assertEquals("true-false", assertIs(booleans.values.single()).value) + assertTrue(listOf(strings, booleans).all { "ts.array.join" in it.modelIds }) + } + + @Test + fun `callable coercions go residual without querying absent heap types`() { + for (name in listOf("parseFloatCallable", "joinCallable", "splitCallable")) { + val result = analyze(methodName = name) + + assertTrue(result.values.isEmpty(), name) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }, name) + } + } + + @Test + fun `empty reduce without initial throws TypeError and callback exceptions propagate`() { + val empty = analyze(methodName = "reduceNoInitial", denseInputs = listOf(emptyList())) + val throwing = analyze(methodName = "reduceThrows", denseInputs = listOf(listOf(1.0))) + + assertIs(empty.values.single()) + assertIs(throwing.values.single()) + assertTrue("ts.typeError.constructor" in empty.modelIds) + assertTrue(listOf(empty, throwing).all { "ts.array.reduce" in it.modelIds }) + } + + @Test + fun `empty reduce with initial does not invoke callback`() { + val result = analyze(methodName = "reduceInitialEmpty", denseInputs = listOf(emptyList())) + + assertEquals(12.0, result.singleNumber()) + } + + @Test + fun `callback deletion cannot escape as a stale exception payload`() { + val result = analyze(methodName = "reduceDeleteThrows", denseInputs = listOf(listOf(1.0))) + + assertTrue(result.values.isEmpty()) + } + + @Test + fun `array allocations do not invalidate another dense receiver`() { + val result = analyze(methodName = "twoSplits") + + assertEquals("abcd", assertIs(result.values.single()).value) + } + + @Test + fun `callable alternatives after symbolic writes remain residual`() { + val result = analyze(methodName = "joinCallableBranch") + + assertTrue(result.values.all { assertIs(it).value == "" }) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + 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 arrays = inputs.mapIndexed { index, values -> + val elementType = when (values.firstOrNull { it != null }) { + is String -> EtsStringType + is Boolean -> EtsBooleanType + else -> EtsNumberType + } + val arrayType = EtsArrayType(elementType, dimensions = 1) + val descriptor = arrayDescriptorOf(arrayType) + val array = state.memory.allocConcrete(arrayType) + state.memory.initializeArray( + arrayHeapRef = array, + type = descriptor, + sort = typeToSort(elementType), + sizeSort = sizeSort, + contents = values.asSequence().map { value -> + when (value) { + is String -> state.mkInitializedStringConstant(value) + is Boolean -> mkBool(value) + is Number -> mkFp64(value.toDouble()) + else -> nullRef + }.asExpr(typeToSort(elementType)) + }, + ) + state.memory.write( + mkRegisterStackLValue(addressSort, index + 1), + array.asExpr(addressSort), + guard = trueExpr, + ) + state.saveSortForLocal(index + 1, addressSort) + array to arrayType + } + arrays.forEach { (array, arrayType) -> + state.markDenseInputArray(array = array, type = arrayType) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "GapModels" } + .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/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/TsUnknownCallDispatcherTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt index 4abb82bb0b..485ad9884e 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallDispatcherTest.kt @@ -25,6 +25,7 @@ import org.usvm.UBoolSort import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UMachineOptions +import org.usvm.api.evalTypeEquals import org.usvm.api.targets.ReachabilityObserver import org.usvm.api.targets.TsReachabilityTarget import org.usvm.isTrue @@ -209,6 +210,30 @@ class TsUnknownCallDispatcherTest { ) } + @Test + fun `partial string model keeps modeled and typed fresh successors`() { + val observer = RecordingUnknownCallObserver() + + val states = analyzeAllStates( + methodName = "modeledStringCallForks", + fallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + models = catalog(PartialStringModel), + observer = observer, + ) + + assertEquals(2, states.size) + assertEquals( + listOf(TsUnknownCallOutcome.MODEL_APPLIED, TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), + observer.events.map { it.outcome }, + ) + states.forEach { state -> + val result = assertIs(state.methodResult).value + val isString = state.memory.types.evalTypeEquals(result.asExpr(state.ctx.addressSort), EtsStringType) + assertTrue(state.models.isNotEmpty()) + assertTrue(state.models.all { model -> model.eval(isString).isTrue }) + } + } + @Test fun `partial model sends residual domain to stop fallback`() { val observer = RecordingUnknownCallObserver() @@ -717,6 +742,25 @@ class TsUnknownCallDispatcherTest { } } + private object PartialStringModel : TestModel( + id = "partial-string-model", + methodName = "convert", + enclosingClassName = "ExternalString", + ) { + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { + val condition = requireNotNull(call.arguments.single().resolved).asExpr(state.ctx.boolSort) + val successor = TsUnknownCallModelSuccessor( + guard = condition, + completion = TsUnknownCallModelCompletion.Normal { mkInitializedStringConstant("modeled") }, + ) + + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = state.ctx.mkNot(condition), + ) + } + } + private object ExceptionalModel : TestModel(id = "exceptional-model", methodName = "fail") { override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution { val successor = TsUnknownCallModelSuccessor( @@ -760,8 +804,9 @@ class TsUnknownCallDispatcherTest { private abstract class TestModel( override val id: String, methodName: String, + enclosingClassName: String? = null, ) : TsUnknownCallModel { - override val target = TsUnknownCallTarget(methodName = methodName) + override val target = TsUnknownCallTarget(methodName = methodName, enclosingClassName = enclosingClassName) } private class RecordingUnknownCallObserver : TsInterpreterObserver { 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/ArrayStorageTypeBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ArrayStorageTypeBoundaryTest.kt new file mode 100644 index 0000000000..e6e44c602f --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/ArrayStorageTypeBoundaryTest.kt @@ -0,0 +1,87 @@ +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.machine.state.TsMethodResult +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration + +class ArrayStorageTypeBoundaryTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayStorageTypeBoundary.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(sourceFile)) + + @Test + fun `unsupported indexed access and typed array length report limitations`() { + val methods = mapOf( + "readUnknown" to "unknown", + "writeUnknown" to "unknown", + "readUint16" to "Uint16Array", + "lengthUint8" to "Uint8Array", + ) + + methods.forEach { (methodName, expectedType) -> + val observer = RecordingObserver() + val method = scene.projectClasses + .single { it.name == "ArrayStorageTypeBoundary" } + .methods + .single { it.name == methodName } + + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + machine.analyze(methods = listOf(method)) + } + + assertTrue(states.none { it.methodResult is TsMethodResult.Success }, methodName) + assertTrue(observer.limitations.isNotEmpty(), methodName) + assertTrue( + observer.limitations.all { it.reason == TsRuntimeFeatureLimitationReason.ARRAY_STORAGE_TYPE }, + "$methodName: ${observer.limitations.map { it.reason }}", + ) + assertTrue( + observer.limitations.any { expectedType in it.detail }, + "$methodName: ${observer.limitations.map { it.detail }}", + ) + } + } + + 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/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/NumberWithoutSdkTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumberWithoutSdkTest.kt new file mode 100644 index 0000000000..9b7a0871f8 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumberWithoutSdkTest.kt @@ -0,0 +1,76 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.CONSTRUCTOR_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.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallModelSelection +import org.usvm.machine.call.TsUnknownCallOutcome +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration + +class NumberWithoutSdkTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/NumberWithoutSdk.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `missing Number SDK class leaves constructor call residual`() { + val clazz = scene.projectClasses.single { it.name == "NumberWithoutSdk" } + val method = clazz.methods.single { it.name == "construct" } + val observer = RecordingObserver() + + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + + val freshReturnObserved = observer.events.any { event -> + event.callee.name == CONSTRUCTOR_NAME && + event.outcome == TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN + } + + assertTrue(states.isNotEmpty()) + assertTrue(freshReturnObserved) + } + + private class RecordingObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += 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.Z3, + 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/machine/expr/UnknownStringConcatTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/UnknownStringConcatTest.kt new file mode 100644 index 0000000000..772679041b --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/UnknownStringConcatTest.kt @@ -0,0 +1,98 @@ +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.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallModelSelection +import org.usvm.machine.call.TsUnknownCallOutcome +import org.usvm.machine.state.TsState +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +class UnknownStringConcatTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/UnknownStringConcat.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `fresh string return can be concatenated`() { + val observer = RecordingObserver() + + val states = analyze(methodName = "concatOpaqueString", observer = observer) + + assertTrue(states.isNotEmpty()) + assertTrue(observer.events.any { it.outcome == TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN }) + assertTrue(observer.limitations.isEmpty()) + } + + @Test + fun `symbolic number to string is an explicit limitation`() { + val observer = RecordingObserver() + + val states = analyze(methodName = "concatOpaqueNumber", observer = observer) + + assertTrue(states.isEmpty()) + assertEquals( + TsRuntimeFeatureLimitationReason.STRING_CONCAT_OPERAND_CONVERSION, + observer.limitations.single().reason, + ) + } + + private fun analyze(methodName: String, observer: RecordingObserver): List { + val clazz = scene.projectClasses.single { it.name == "UnknownStringConcat" } + val method = clazz.methods.single { it.name == methodName } + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + } + + private class RecordingObserver : TsInterpreterObserver { + val events = mutableListOf() + val limitations = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + + 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.Z3, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/UnsupportedExpressionBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/UnsupportedExpressionBoundaryTest.kt new file mode 100644 index 0000000000..41beec14b1 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/UnsupportedExpressionBoundaryTest.kt @@ -0,0 +1,78 @@ +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.time.Duration + +class UnsupportedExpressionBoundaryTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/UnsupportedExpressionBoundary.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `regular expression literal stops with an observed limitation`() { + assertLimitation( + methodName = "regularExpressionLiteral", + reason = TsRuntimeFeatureLimitationReason.REGULAR_EXPRESSION_LITERAL, + ) + } + + @Test + fun `used catch value stops with an observed limitation`() { + assertLimitation( + methodName = "caughtExceptionValue", + reason = TsRuntimeFeatureLimitationReason.CAUGHT_EXCEPTION_VALUE, + ) + } + + private fun assertLimitation(methodName: String, reason: TsRuntimeFeatureLimitationReason) { + val method = scene.projectClasses.flatMap { it.methods }.single { it.name == methodName } + val observer = RecordingObserver() + + TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + + assertEquals(listOf(reason), observer.limitations.map { it.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.Z3, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt index 2e699edffc..2bed4645b6 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsTypeSystemTest.kt @@ -1,5 +1,6 @@ package org.usvm.machine.types +import io.mockk.mockk import org.jacodb.ets.model.EtsClassImpl import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsFieldImpl @@ -9,12 +10,52 @@ import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsScene import org.junit.jupiter.api.Test +import org.usvm.machine.TsContext +import org.usvm.types.USingleTypeStream import org.usvm.util.EtsHierarchy import org.usvm.util.type +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds class TsTypeSystemTest { + @Test + fun `synthetic wrapper does not satisfy structural type constraints`() { + val scene = EtsScene(projectFiles = emptyList()) + val context = TsContext(scene = scene, components = mockk()) + val typeSystem = TsTypeSystem( + scene = scene, + typeOperationsTimeout = 1.seconds, + hierarchy = EtsHierarchy(scene), + ) + val fakeType = EtsFakeType.mkRef(context) + val structuralType = EtsAuxiliaryType(properties = setOf("style")) + + val fakeHasProperty = typeSystem.isSupertype(structuralType, fakeType) + val propertyHasFakeType = typeSystem.isSupertype(fakeType, structuralType) + + assertFalse(fakeHasProperty) + assertFalse(propertyHasFakeType) + } + + @Test + fun `fake type remains in a singleton stream after filtering by itself`() { + val scene = EtsScene(projectFiles = emptyList()) + val context = TsContext(scene = scene, components = mockk()) + val typeSystem = TsTypeSystem( + scene = scene, + typeOperationsTimeout = 1.seconds, + hierarchy = EtsHierarchy(scene), + ) + val fakeType = EtsFakeType.mkRef(context) + val stream = USingleTypeStream(typeSystem = typeSystem, singleType = fakeType) + + val filtered = stream.filterBySupertype(fakeType).filterBySubtype(fakeType) + + assertTrue(typeSystem.isSupertype(fakeType, fakeType)) + assertFalse(filtered.isEmpty ?: true) + } + @Test fun `auxiliary type is a subtype of a class containing its properties`() { val fileSignature = EtsFileSignature(projectName = "test", fileName = "types.ts") 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/EtsFieldResolverTest.kt b/usvm-ts/src/test/kotlin/org/usvm/util/EtsFieldResolverTest.kt new file mode 100644 index 0000000000..300ec9c550 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/util/EtsFieldResolverTest.kt @@ -0,0 +1,40 @@ +package org.usvm.util + +import io.mockk.mockk +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFieldSignature +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStringType +import org.junit.jupiter.api.Test +import org.usvm.machine.TsContext +import kotlin.test.assertEquals + +class EtsFieldResolverTest { + @Test + fun `missing built-in class leaves its field unresolved`() { + val scene = EtsScene(projectFiles = emptyList()) + val ctx = TsContext(scene = scene, components = mockk()) + val hierarchy = EtsHierarchy(scene) + + for ((className, fieldName) in listOf("RangeError" to "stack", "URL" to "searchParams")) { + val signature = EtsClassSignature( + name = className, + file = EtsFileSignature.UNKNOWN, + ) + val field = EtsFieldSignature( + enclosingClass = signature, + name = fieldName, + type = EtsStringType, + ) + + val result = ctx.resolveEtsField( + instance = null, + field = field, + hierarchy = hierarchy, + ) + + assertEquals(TsResolutionResult.Empty, result) + } + } +} 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/baseline/CallFallbackBaseline.ts b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts index e37b101722..dd04bbe03a 100644 --- a/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts +++ b/usvm-ts/src/test/resources/baseline/CallFallbackBaseline.ts @@ -18,6 +18,10 @@ declare class ExternalBoolean { static convert(value: boolean): boolean; } +declare class ExternalString { + static convert(value: boolean): string; +} + declare class ExternalAny { static value(): any; } @@ -77,6 +81,10 @@ class CallFallbackBaseline { return ExternalBoolean.convert(value); } + modeledStringCallForks(value: boolean): string { + return ExternalString.convert(value); + } + modeledUnknownCallReturnsAlias(receiver: ExternalReceiver): number { if (ExternalModeledCall.identity(receiver) === receiver) { return 122; 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/ArrayStorageTypeBoundary.ts b/usvm-ts/src/test/resources/models/ArrayStorageTypeBoundary.ts new file mode 100644 index 0000000000..89ba876a4a --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayStorageTypeBoundary.ts @@ -0,0 +1,20 @@ +// @ts-nocheck + +export class ArrayStorageTypeBoundary { + readUnknown(values: unknown): unknown { + return values[0]; + } + + writeUnknown(values: unknown): number { + values[0] = 7; + return 1; + } + + readUint16(values: Uint16Array): number { + return values[0]; + } + + lengthUint8(values: Uint8Array): number { + return values.length; + } +} 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..cc6b6a6533 --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -0,0 +1,94 @@ +// @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(); + } + + castForeignReceiver(): number { + return (new DateImpostor() as unknown as Date).getTime(); + } +} + +class DateImpostor {} 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/DateShadowEtsIr.ts b/usvm-ts/src/test/resources/models/DateShadowEtsIr.ts new file mode 100644 index 0000000000..88e028a97e --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateShadowEtsIr.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +declare class Date { + static UTC(year: number): number; +} + +export class DateShadowEtsIr { + call(): number { + return Date.UTC(2020); + } +} 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..343cb319d1 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -0,0 +1,53 @@ +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 throwTypeError(): number { + throw new TypeError("expected type error"); + } + + 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/GapModels.ts b/usvm-ts/src/test/resources/models/GapModels.ts new file mode 100644 index 0000000000..cf3f348294 --- /dev/null +++ b/usvm-ts/src/test/resources/models/GapModels.ts @@ -0,0 +1,77 @@ +// @ts-nocheck +export class GapModels { + joinDefault(values: string[]): string { return values.join(undefined); } + joinBooleans(values: boolean[]): string { return values.join("-"); } + parseFloatCallable(): number { const f = () => 1; return Number.parseFloat(f as any); } + joinCallable(): string { const f = () => 1; return "ab".split("").join(f as any); } + splitCallable(): string[] { const f = () => 1; return "ab".split(f as any); } + reduceThrows(values: number[]): number { return values.reduce((a, b) => { throw b; }, 0); } + reduceInitialEmpty(values: number[]): number { return values.reduce((a, b) => { throw b; }, 12); } + reduceDeleteThrows(values: number[]): number { + return values.reduce((a, b, i, array) => { delete array[0]; throw array[0]; }, 0); + } + + joinCallableBranch(index: number): string { + if (index !== 0 && index !== 1) return ""; + const values = "a,b".split(","); + values[index] = (() => 1) as any; + return values.join(","); + } + twoSplits(): string { + const first = "ab".split(""); + const second = "cd".split(""); + return first.join("") + second.join(""); + } + + reverseString(): string { + return "abc".split("").reverse().join(""); + } + + splitEdges(): boolean { + const fields = ",a,,".split(","); + return fields.length === 4 && fields[0] === "" && fields[1] === "a" && fields[3] === "" && + "abc".split().join() === "abc" && "abc".split(undefined).join() === "abc" && + "".split("").length === 0 && "".split(",").length === 1 && + "abc".split("", 0).length === 0 && "abc".split("", 2).join("-") === "a-b" && + "abc".split("", 4294967297).join("") === "a" && + "πŸ˜€".split("").length === 2 && "πŸ˜€".split("").join("") === "πŸ˜€"; + } + + reduceMutation(values: number[]): number { + const multiplier = 2; + const result = values.reduce((acc, current, index, array) => { + if (index === 0) array[1] = 10; + return acc + current * multiplier + index; + }, 4); + return result + values[1] * 4; + } + + reduceNoInitial(values: number[]): number { + return values.reduce((acc, value) => acc + value); + } + + reduceUndefined(values: number[]): number { + return values.reduce((acc, value) => acc === undefined ? 8 : value, undefined); + } + + reduceShrink(values: number[]): number { + return values.reduce((acc, value, index, array) => { + array.length = 0; + return acc + value; + }, 0); + } + + parseFloatEdges(): boolean { + return Number.parseFloat(" -1.25rem") === -1.25 && + Number.parseFloat(".5e+2px") === 50 && Number.parseFloat("12e-foo") === 12 && + Number.parseFloat("0x10") === 0 && Number.parseFloat("1.2.3") === 1.2 && + Number.parseFloat("\uFEFFInfinity!") === Infinity && + 1 / Number.parseFloat("-0.0") === -Infinity && + Number.isNaN(Number.parseFloat("nope")) && Number.isNaN(Number.parseFloat("+.")) && + Number.parseFloat("0.100000000000001") === 0.100000000000001; + } + + parseFloatUnsupported(): number { + return Number.parseFloat("12345678901234567"); + } +} diff --git a/usvm-ts/src/test/resources/models/GapModelsDifferential.mjs b/usvm-ts/src/test/resources/models/GapModelsDifferential.mjs new file mode 100644 index 0000000000..c20c5236fa --- /dev/null +++ b/usvm-ts/src/test/resources/models/GapModelsDifferential.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const root = process.argv[2]; +globalThis.StringModelPrimitives = { + length: value => value.length, + codeUnitAt: (value, index) => value.charCodeAt(index), + fromCodeUnit: value => String.fromCharCode(value), + copyRange: (value, start, end) => value.slice(start, end), +}; +globalThis.ArrayModelPrimitives = { + allocateStrings: length => new Array(length).fill(''), + truncateDense: (array, length) => { array.length = length; return array; }, + elementString: value => value == null ? '' : String(value), + requireDense: () => {}, +}; +const unsupported = new Error('outside guarded decimal domain'); +globalThis.NumberParseFloatPrimitives = { unsupportedDecimal: () => { throw unsupported; } }; +const { StringModels } = await import(pathToFileURL(`${root}/StringModels.ts`)); +const { ArrayModels } = await import(pathToFileURL(`${root}/ArrayModels.ts`)); +const { NumberParseFloatModels } = await import(pathToFileURL(`${root}/NumberParseFloatModels.ts`)); + +let splitCases = 0; +for (const input of ['', 'abc', ',a,,', 'aaaa', 'a\0b', 'πŸ˜€x\ud800', 'ab--cd--']) { + for (const separator of [undefined, '', ',', 'a', '--', 'πŸ˜€', '\ud83d']) { + for (const limit of [undefined, 0, 1, 2, 8, -1, -2.7, 2.7, NaN, Infinity, 4294967297]) { + const actual = StringModels.split(input, separator ?? '', limit === undefined ? 4294967295 : limit >>> 0, + separator === undefined); + assert.deepEqual(actual, input.split(separator, limit), JSON.stringify([input, separator, limit])); + splitCases++; + } + } +} +let joinCases = 0; +for (const array of [[], [''], ['a', 'b'], [null, undefined, 'x'], [true, false], ['πŸ˜€', '\ud800']]) { + for (const separator of [undefined, '', ',', '--', 'πŸ˜€']) { + assert.equal(ArrayModels.join(array, separator ?? ','), array.join(separator)); + joinCases++; + } +} +let reduceCases = 0; +for (const values of [[], [1], [1, 2, 3]]) { + for (const hasInitial of [false, true]) { + for (const initial of [undefined, 0, 7]) { + const left = values.slice(); + const right = values.slice(); + const callback = (acc, value, index, array) => { + if (index === 0 && array.length > 1) array[1] = 10; + return (acc ?? 9) + value + index + array.length; + }; + if (!values.length && !hasInitial) { + assert.throws(() => ArrayModels.reduce(left, callback, initial, hasInitial), TypeError); + } else { + assert.equal(ArrayModels.reduce(left, callback, initial, hasInitial), + hasInitial ? right.reduce(callback, initial) : right.reduce(callback)); + assert.deepEqual(left, right); + } + reduceCases++; + } + } +} +let parseCases = 0; +const checkParse = input => { + const actual = NumberParseFloatModels.parseFloat(input); + assert.ok(Object.is(actual, Number.parseFloat(input)), `${JSON.stringify(input)}: ${actual}`); + parseCases++; +}; +for (const input of ['', ' ', '+.', '-', '.5', '1.', '12e', '12e-', '12e+foo', '0x10', '1.2.3', + '\ufeff-1.25rem', 'Infinity!', '-Infinity', '+Infinityx', 'infinity', '-0', '-0e-99999', + '000.00001', '0.100000000000001', '999999999999999e-22', '999999999999999e22']) checkParse(input); +for (const whitespace of [9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8202, 8232, 8233, 8239, 8287, 12288, 65279]) { + checkParse(String.fromCharCode(whitespace) + '-12.5px'); +} +for (let coefficient = 1; coefficient <= 999; coefficient += 7) { + for (let scale = -22; scale <= 22; scale++) { + checkParse(`${coefficient}e${scale}x`); + checkParse(`-${coefficient}e${scale}x`); + } +} +for (const input of ['12345678901234567', '1e23', '1e-23']) { + assert.throws(() => NumberParseFloatModels.parseFloat(input), error => error === unsupported); +} +console.log(JSON.stringify({ splitCases, joinCases, reduceCases, parseCases })); 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/NumberWithoutSdk.ts b/usvm-ts/src/test/resources/models/NumberWithoutSdk.ts new file mode 100644 index 0000000000..a80ebdef44 --- /dev/null +++ b/usvm-ts/src/test/resources/models/NumberWithoutSdk.ts @@ -0,0 +1,9 @@ +// @ts-nocheck + +class NumberWithoutSdk { + construct(value: number): number { + const wrapper = new Number(value); + + return wrapper.valueOf(); + } +} 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/SharedAnalysis.ts b/usvm-ts/src/test/resources/models/SharedAnalysis.ts new file mode 100644 index 0000000000..8a4bc9fa34 --- /dev/null +++ b/usvm-ts/src/test/resources/models/SharedAnalysis.ts @@ -0,0 +1,35 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +declare class External { + static value(): number; +} + +export class SharedAnalysis { + identity(value: any): any { + return value; + } + + success(): number { + return 7; + } + + unknown(): number { + const result = External.value(); + return result; + } + + limited(): boolean { + return /x/.test("x"); + } + + observed(): number { + this.callee(); + const result = 7; + return result; + } + + callee(): void { + return; + } +} 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/SymbolicStringEngine.ts b/usvm-ts/src/test/resources/models/SymbolicStringEngine.ts new file mode 100644 index 0000000000..7533fa9cfa --- /dev/null +++ b/usvm-ts/src/test/resources/models/SymbolicStringEngine.ts @@ -0,0 +1,33 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class SymbolicStringEngine { + inputRoundtrip(value: string): string { + return value; + } + + length(): number { + return "\ud800\udc00".length; + } + + charCodeAt(): number { + return "\ud800".charCodeAt(0); + } + + charAtEquals(): boolean { + return "\ud800".charAt(0) === "\ud800"; + } + + indexedReadEquals(): boolean { + return "\udc00"[0] === "\udc00"; + } + + outOfRangeCharAt(): string { + return "x".charAt(1); + } + + anyAliasLength(): number { + const value: any = "\ud800"; + return value.length; + } +} diff --git a/usvm-ts/src/test/resources/models/UnknownStringConcat.ts b/usvm-ts/src/test/resources/models/UnknownStringConcat.ts new file mode 100644 index 0000000000..391c292bd7 --- /dev/null +++ b/usvm-ts/src/test/resources/models/UnknownStringConcat.ts @@ -0,0 +1,16 @@ +// @ts-nocheck + +class UnknownStringConcat { + concatOpaqueString(): number { + const upper = "a".toUpperCase(); + const result = upper + "!"; + + return result.length; + } + + concatOpaqueNumber(value: number): number { + const result = `${value}`; + + return result.length; + } +} diff --git a/usvm-ts/src/test/resources/models/UnsupportedExpressionBoundary.ts b/usvm-ts/src/test/resources/models/UnsupportedExpressionBoundary.ts new file mode 100644 index 0000000000..f0685c379e --- /dev/null +++ b/usvm-ts/src/test/resources/models/UnsupportedExpressionBoundary.ts @@ -0,0 +1,12 @@ +export function regularExpressionLiteral(value: string): string { + const whitespace = /\s+/g; + return value.replaceAll(whitespace, "-"); +} + +export function caughtExceptionValue(): unknown { + try { + return 1; + } catch (error) { + return error; + } +} 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]; + } +}