Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "23199c1843"
const val jacodb = "37327563ab"
const val juliet = "1.3.2"
const val junit = "5.9.3"
const val kotlin = "2.1.0"
Expand Down
481 changes: 481 additions & 0 deletions usvm-ts-calls/scripts/run-class-experiment.py

Large diffs are not rendered by default.

447 changes: 447 additions & 0 deletions usvm-ts-calls/scripts/test_run_class_experiment.py

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

415 changes: 415 additions & 0 deletions usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsClassHarness.kt

Large diffs are not rendered by default.

683 changes: 683 additions & 0 deletions usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsClassOracle.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package org.usvm.ts.calls

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import org.usvm.ts.pbt.fastcheck.TypeScriptClassStatementDescriptor
import org.usvm.ts.pbt.fastcheck.TypeScriptClassSurfaceInventory
import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector
import java.nio.file.Files
import java.nio.file.Path

/** Instrument only a source overlay; the original source and frozen AST offsets remain untouched. */
@Serializable
internal data class CallsClassSourceInstrumentation(
val universe: CallsClassSourceUniverse,
val instrumentedSource: String,
val instrumentedSha256: String,
val hitGlobalName: String,
val inventory: TypeScriptClassSurfaceInventory,
) {
init {
require(callsClassSourceSha256(instrumentedSource.toByteArray(Charsets.UTF_8)) == instrumentedSha256) {
"Class instrumentation source hash mismatch"
}
}

fun validateSurface(surface: CallsClassSurface) {
require(surface.sourceFile == universe.sourceFile && surface.exportName == universe.className) {
"Class harness surface selects a different source class"
}
require(surface.constructorInputs.map { input -> input.name } == inventory.constructorParameterNames) {
"Class harness must represent every constructor parameter in source order"
}
val expectedMembers = inventory.publicMembers.map { member ->
Triple(member.name, member.kind, member.isStatic)
}.toSet()
val actualMembers = surface.members.map { member ->
Triple(member.name, member.kind.name, member.isStatic)
}.toSet()
require(actualMembers == expectedMembers && actualMembers.size == surface.members.size) {
"Class harness must represent the complete original public callable surface"
}
for (member in surface.members) {
val original = inventory.publicMembers.single { original ->
original.name == member.name && original.kind == member.kind.name &&
original.isStatic == member.isStatic
}
require(member.inputs.map { input -> input.name } == original.parameterNames) {
"Class harness must represent every parameter of ${member.name} in source order"
}
}
val hasUnobservablePrivateState = inventory.instanceDataFields.any { field -> field.isEcmaPrivate }
require(!hasUnobservablePrivateState) { "ECMAScript private receiver state cannot be observed by this harness" }
val expectedFields = inventory.instanceDataFields.map { field -> field.name }.toSet()
require(surface.stateFields.keys == expectedFields) {
"Class harness must observe every original receiver data field: $expectedFields"
}
validateDataClasses(surface)
}

private fun validateDataClasses(surface: CallsClassSurface) {
val duplicateExports = surface.dataClasses.map { dataClass -> dataClass.exportName }.distinct().size !=
surface.dataClasses.size
require(!duplicateExports) { "Companion data-class exports must be unique" }
for (dataClass in surface.dataClasses) {
require(dataClass.exportName != universe.className) { "The target class has its own observation identity" }
val original = inventory.dataClasses.singleOrNull { original ->
original.exportName == dataClass.exportName
}
requireNotNull(original) {
"Unverified companion data class ${dataClass.exportName} in ${universe.sourceFile}"
}
require(dataClass.fields.keys == original.fields.toSet()) {
"Companion data class ${dataClass.exportName} must preserve every original own data field"
}
}
}

companion object {
fun create(sourceRoot: Path, sourceFile: String, className: String): CallsClassSourceInstrumentation {
val root = sourceRoot.toRealPath()
val source = root.resolve(sourceFile).normalize().toRealPath()
require(source.startsWith(root)) { "Class source is outside the frozen source root" }
val sourceBytes = Files.readAllBytes(source)
val sourceSha256 = callsClassSourceSha256(sourceBytes)
val inspected = TypeScriptSourceInspector.instrumentClassCoverage(source = source, className = className)
val instrumentedSource = requireNotNull(inspected.source) {
inspected.reason ?: "Unsupported class statement instrumentation"
}
require(inspected.sourceSha256 == sourceSha256) { "Class source changed during AST inspection" }

val universeIdentity = CallsExperimentJson.json.encodeToString(
CallsClassUniverseHashInput(
sourceFile = sourceFile,
className = className,
sourceSha256 = sourceSha256,
instrumenterVersion = inspected.instrumenterVersion,
statements = inspected.statements,
inventory = requireNotNull(inspected.inventory),
),
)
val universe = CallsClassSourceUniverse(
sourceFile = sourceFile,
className = className,
sourceSha256 = sourceSha256,
universeSha256 = callsClassSourceSha256(universeIdentity.toByteArray(Charsets.UTF_8)),
instrumenterVersion = inspected.instrumenterVersion,
statements = inspected.statements,
)

return CallsClassSourceInstrumentation(
universe = universe,
instrumentedSource = instrumentedSource,
instrumentedSha256 = requireNotNull(inspected.instrumentedSha256),
hitGlobalName = inspected.counterGlobalName,
inventory = requireNotNull(inspected.inventory),
)
}
}
}

@Serializable
private data class CallsClassUniverseHashInput(
val sourceFile: String,
val className: String,
val sourceSha256: String,
val instrumenterVersion: String,
val statements: List<TypeScriptClassStatementDescriptor>,
val inventory: TypeScriptClassSurfaceInventory,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.usvm.ts.calls

import kotlinx.serialization.Serializable
import org.usvm.ts.pbt.fastcheck.TypeScriptClassStatementDescriptor
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest

/** Frozen original-source executable statements, independent of policy, seed and replay hits. */
@Serializable
internal data class CallsClassSourceUniverse(
val sourceFile: String,
val className: String,
val sourceSha256: String,
val universeSha256: String,
val instrumenterVersion: String,
val statements: List<TypeScriptClassStatementDescriptor>,
) {
init {
require(sourceFile.isNotBlank() && className.isNotBlank())
require(statements.isNotEmpty()) { "Class coverage universe has no executable statements" }
require(statements.map { statement -> statement.id }.toSet().size == statements.size) {
"Class statement IDs must be unique"
}
val hasExactOriginalRanges = statements.all { statement ->
statement.startOffset >= 0 && statement.endOffset > statement.startOffset &&
statement.id.startsWith("$sourceSha256:")
}
require(hasExactOriginalRanges) { "Class statements must identify exact original-source AST ranges" }
}

val statementIds: Set<String>
get() = statements.mapTo(linkedSetOf()) { statement -> statement.id }

fun verifyUnchanged(sourceRoot: Path) {
val root = sourceRoot.toRealPath()
val source = root.resolve(sourceFile).normalize().toRealPath()
require(source.startsWith(root)) { "Class source is outside the frozen source root" }
require(callsClassSourceSha256(Files.readAllBytes(source)) == sourceSha256) {
"Class source changed after freezing its statement universe"
}
}

/** Counter IDs are native hits of these exact AST statements, never inferred from c8 line records. */
fun checkedCoveredIds(nativeHits: Collection<String>): Set<String> {
val covered = nativeHits.toSet()
val unknownIds = covered - statementIds
require(unknownIds.isEmpty()) { "Native class coverage reported unknown statement IDs: $unknownIds" }

return covered
}
}

internal fun callsClassSourceSha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
.digest(bytes).joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and UNSIGNED_BYTE_MASK) }

private const val UNSIGNED_BYTE_MASK = 0xff
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ internal fun runCoverageUniverse(
val universe = probeFunction(source)
source.verifyUnchanged()
val record = CallsCoverageUniverseRecord(identity = identity, universe = universe)
atomicWrite(output.resolve("universe.json"), encode(record))
System.out.appendLine(encode(record))
writeCallsArtifactAtomically(output.resolve("universe.json"), encodeCallsArtifact(record))
System.out.appendLine(encodeCallsArtifact(record))
}

internal fun runCoverageCell(
Expand All @@ -147,12 +147,12 @@ internal fun runCoverageCell(

Files.createDirectories(output)
val identityPath = output.resolve("identity.json")
val expectedIdentity = encode(identity)
val expectedIdentity = encodeCallsArtifact(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)
writeCallsArtifactAtomically(identityPath, expectedIdentity)
}
require(!Files.exists(output.resolve("result.json"))) { "Cell already completed" }

Expand Down Expand Up @@ -182,8 +182,8 @@ internal fun runCoverageCell(
budgetMillis = identity.budgetMillis,
),
)
atomicWrite(output.resolve("result.json"), encode(result))
System.out.appendLine(encode(result))
writeCallsArtifactAtomically(output.resolve("result.json"), encodeCallsArtifact(result))
System.out.appendLine(encodeCallsArtifact(result))
}

private data class CoverageCellRequest(
Expand Down Expand Up @@ -254,7 +254,7 @@ private fun parseCoverageCellRequest(
budgetMillis = budgetMillis,
solverLimitMillis = solverLimitMillis,
candidateCap = candidateCap,
coverageUniverseSha256 = coverageUniverseSha256(universeBytes),
coverageUniverseSha256 = callsArtifactSha256(universeBytes),
)

return CoverageCellRequest(source = source, identity = identity, output = output, universe = universe)
Expand Down Expand Up @@ -286,13 +286,13 @@ private fun loadOrRunCoverageSearch(
solverQueryLimit = identity.solverLimitMillis.milliseconds,
solverType = SolverType.valueOf(identity.solver),
candidateCap = identity.candidateCap,
onCandidate = { candidate -> appendDurably(candidatePath, encode(candidate)) },
onCandidate = { candidate -> appendCallsJournalDurably(candidatePath, encodeCallsArtifact(candidate)) },
)
val result = searchFunction(request)
source.verifyUnchanged()
val persistedCandidates = readLines<CallsCoverageCandidate>(candidatePath)
val persistedCandidates = readCallsJournal<CallsCoverageCandidate>(candidatePath)
require(result.candidates == persistedCandidates) { "Persisted candidate journal differs from search result" }
atomicWrite(searchPath, encode(result))
writeCallsArtifactAtomically(searchPath, encodeCallsArtifact(result))
result
}
}
Expand All @@ -303,7 +303,7 @@ private fun replayCoverageCandidates(
output: Path,
): List<CallsCoverageReplayEvent> {
val replayPath = output.resolve("replays.jsonl")
val events = readLines<CallsCoverageReplayEvent>(replayPath).toMutableList()
val events = readCallsJournal<CallsCoverageReplayEvent>(replayPath).toMutableList()
require(events.map(CallsCoverageReplayEvent::index) == events.indices.toList()) {
"Replay journal has missing or duplicate indices"
}
Expand All @@ -325,7 +325,7 @@ private fun replayCoverageCandidates(
replay = replay.getOrNull(),
error = replay.exceptionOrNull()?.let { error -> error.message ?: error::class.java.name },
)
appendDurably(replayPath, encode(event))
appendCallsJournalDurably(replayPath, encodeCallsArtifact(event))
events += event
}
}
Expand Down Expand Up @@ -379,17 +379,18 @@ private fun validateCoverageManifestBuild(
}
}

private inline fun <reified T> readLines(path: Path): List<T> = if (Files.exists(path)) {
internal inline fun <reified T> readCallsJournal(path: Path): List<T> = if (Files.exists(path)) {
Files.readAllLines(path).filter(String::isNotBlank).map { line ->
CallsExperimentJson.json.decodeFromString<T>(line)
}
} else {
emptyList()
}

private inline fun <reified T> encode(value: T): String = CallsExperimentJson.json.encodeToUtf8SafeString(value)
internal inline fun <reified T> encodeCallsArtifact(value: T): String =
CallsExperimentJson.json.encodeToUtf8SafeString(value)

private fun appendDurably(path: Path, line: String) {
internal fun appendCallsJournalDurably(path: Path, line: String) {
val bytes = ByteBuffer.wrap((line + "\n").toByteArray(Charsets.UTF_8))
FileChannel.open(
path,
Expand All @@ -402,7 +403,7 @@ private fun appendDurably(path: Path, line: String) {
}
}

private fun atomicWrite(path: Path, contents: String) {
internal fun writeCallsArtifactAtomically(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) }
Expand All @@ -420,7 +421,7 @@ private val COVERAGE_CHECKPOINT_MILLIS = listOf(
THIRD_CHECKPOINT_MILLIS,
)

private fun coverageUniverseSha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes)
internal fun callsArtifactSha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and UNSIGNED_BYTE_MASK) }

private const val COVERAGE_UNIVERSE_ARGUMENT_COUNT = 4
Expand Down
Loading
Loading