aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt')
-rw-r--r--src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt252
1 files changed, 252 insertions, 0 deletions
diff --git a/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
new file mode 100644
index 00000000..56fb5725
--- /dev/null
+++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/CoverageRecorder.kt
@@ -0,0 +1,252 @@
+// Copyright 2021 Code Intelligence GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.code_intelligence.jazzer.instrumentor
+
+import com.code_intelligence.jazzer.runtime.CoverageMap
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.analysis.CoverageBuilder
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionData
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionDataStore
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.ExecutionDataWriter
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.data.SessionInfo
+import com.code_intelligence.jazzer.third_party.org.jacoco.core.internal.data.CRC64
+import com.code_intelligence.jazzer.utils.ClassNameGlobber
+import io.github.classgraph.ClassGraph
+import java.io.File
+import java.io.FileOutputStream
+import java.io.OutputStream
+import java.time.Instant
+import java.util.UUID
+
+private data class InstrumentedClassInfo(
+ val classId: Long,
+ val initialEdgeId: Int,
+ val nextEdgeId: Int,
+ val bytecode: ByteArray,
+)
+
+object CoverageRecorder {
+ var classNameGlobber = ClassNameGlobber(emptyList(), emptyList())
+ private val instrumentedClassInfo = mutableMapOf<String, InstrumentedClassInfo>()
+ private var startTimestamp: Instant? = null
+ private val additionalCoverage = mutableSetOf<Int>()
+
+ fun recordInstrumentedClass(internalClassName: String, bytecode: ByteArray, firstId: Int, numIds: Int) {
+ if (startTimestamp == null) {
+ startTimestamp = Instant.now()
+ }
+ instrumentedClassInfo[internalClassName] = InstrumentedClassInfo(
+ CRC64.classId(bytecode),
+ firstId,
+ firstId + numIds,
+ bytecode,
+ )
+ }
+
+ /**
+ * Manually records coverage IDs based on the current state of [CoverageMap].
+ * Should be called after static initializers have run.
+ */
+ @JvmStatic
+ fun updateCoveredIdsWithCoverageMap() {
+ additionalCoverage.addAll(CoverageMap.getCoveredIds())
+ }
+
+ /**
+ * [dumpCoverageReport] dumps a human-readable coverage report of files using any [coveredIds] to [dumpFileName].
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun dumpCoverageReport(dumpFileName: String, coveredIds: IntArray = CoverageMap.getEverCoveredIds()) {
+ File(dumpFileName).bufferedWriter().use { writer ->
+ writer.write(computeFileCoverage(coveredIds))
+ }
+ }
+
+ private fun computeFileCoverage(coveredIds: IntArray): String {
+ fun Double.format(digits: Int) = "%.${digits}f".format(this)
+ val coverage = analyzeCoverage(coveredIds.toSet()) ?: return "No classes were instrumented"
+ return coverage.sourceFiles.joinToString(
+ "\n",
+ prefix = "Branch coverage:\n",
+ postfix = "\n\n",
+ ) { fileCoverage ->
+ val counter = fileCoverage.branchCounter
+ val percentage = 100 * counter.coveredRatio
+ "${fileCoverage.name}: ${counter.coveredCount}/${counter.totalCount} (${percentage.format(2)}%)"
+ } + coverage.sourceFiles.joinToString(
+ "\n",
+ prefix = "Line coverage:\n",
+ postfix = "\n\n",
+ ) { fileCoverage ->
+ val counter = fileCoverage.lineCounter
+ val percentage = 100 * counter.coveredRatio
+ "${fileCoverage.name}: ${counter.coveredCount}/${counter.totalCount} (${percentage.format(2)}%)"
+ } + coverage.sourceFiles.joinToString(
+ "\n",
+ prefix = "Incompletely covered lines:\n",
+ postfix = "\n\n",
+ ) { fileCoverage ->
+ "${fileCoverage.name}: " + (fileCoverage.firstLine..fileCoverage.lastLine).filter {
+ val instructions = fileCoverage.getLine(it).instructionCounter
+ instructions.coveredCount in 1 until instructions.totalCount
+ }.toString()
+ } + coverage.sourceFiles.joinToString(
+ "\n",
+ prefix = "Missed lines:\n",
+ ) { fileCoverage ->
+ "${fileCoverage.name}: " + (fileCoverage.firstLine..fileCoverage.lastLine).filter {
+ val instructions = fileCoverage.getLine(it).instructionCounter
+ instructions.coveredCount == 0 && instructions.totalCount > 0
+ }.toString()
+ }
+ }
+
+ /**
+ * [dumpJacocoCoverage] dumps the JaCoCo coverage of files using any [coveredIds] to [dumpFileName].
+ * JaCoCo only exports coverage for files containing at least one coverage data point. The dump
+ * can be used by the JaCoCo report command to create reports also including not covered files.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun dumpJacocoCoverage(dumpFileName: String, coveredIds: IntArray = CoverageMap.getEverCoveredIds()) {
+ FileOutputStream(dumpFileName).use { outStream ->
+ dumpJacocoCoverage(outStream, coveredIds)
+ }
+ }
+
+ /**
+ * [dumpJacocoCoverage] dumps the JaCoCo coverage of files using any [coveredIds] to [outStream].
+ */
+ @JvmStatic
+ fun dumpJacocoCoverage(outStream: OutputStream, coveredIds: IntArray) {
+ // Return if no class has been instrumented.
+ val startTimestamp = startTimestamp ?: return
+
+ // Update the list of covered IDs with the coverage information for the current run.
+ updateCoveredIdsWithCoverageMap()
+
+ val dumpTimestamp = Instant.now()
+ val outWriter = ExecutionDataWriter(outStream)
+ outWriter.visitSessionInfo(
+ SessionInfo(UUID.randomUUID().toString(), startTimestamp.epochSecond, dumpTimestamp.epochSecond),
+ )
+ analyzeJacocoCoverage(coveredIds.toSet()).accept(outWriter)
+ }
+
+ /**
+ * Build up a JaCoCo [ExecutionDataStore] based on [coveredIds] containing the internally gathered coverage information.
+ */
+ private fun analyzeJacocoCoverage(coveredIds: Set<Int>): ExecutionDataStore {
+ val executionDataStore = ExecutionDataStore()
+ val sortedCoveredIds = (additionalCoverage + coveredIds).sorted().toIntArray()
+ for ((internalClassName, info) in instrumentedClassInfo) {
+ // Determine the subarray of coverage IDs in sortedCoveredIds that contains the IDs generated while
+ // instrumenting the current class. Since the ID array is sorted, use binary search.
+ var coveredIdsStart = sortedCoveredIds.binarySearch(info.initialEdgeId)
+ if (coveredIdsStart < 0) {
+ coveredIdsStart = -(coveredIdsStart + 1)
+ }
+ var coveredIdsEnd = sortedCoveredIds.binarySearch(info.nextEdgeId)
+ if (coveredIdsEnd < 0) {
+ coveredIdsEnd = -(coveredIdsEnd + 1)
+ }
+ if (coveredIdsStart == coveredIdsEnd) {
+ // No coverage data for the class.
+ continue
+ }
+ check(coveredIdsStart in 0 until coveredIdsEnd && coveredIdsEnd <= sortedCoveredIds.size) {
+ "Invalid range [$coveredIdsStart, $coveredIdsEnd) with coveredIds.size=${sortedCoveredIds.size}"
+ }
+ // Generate a probes array for the current class only, i.e., mapping info.initialEdgeId to 0.
+ val probes = BooleanArray(info.nextEdgeId - info.initialEdgeId)
+ (coveredIdsStart until coveredIdsEnd).asSequence()
+ .map {
+ val globalEdgeId = sortedCoveredIds[it]
+ globalEdgeId - info.initialEdgeId
+ }
+ .forEach { classLocalEdgeId ->
+ probes[classLocalEdgeId] = true
+ }
+ executionDataStore.visitClassExecution(ExecutionData(info.classId, internalClassName, probes))
+ }
+ return executionDataStore
+ }
+
+ /**
+ * Create a [CoverageBuilder] containing all classes matching the include/exclude pattern and their coverage statistics.
+ */
+ fun analyzeCoverage(coveredIds: Set<Int>): CoverageBuilder? {
+ return try {
+ val coverage = CoverageBuilder()
+ analyzeAllUncoveredClasses(coverage)
+ val executionDataStore = analyzeJacocoCoverage(coveredIds)
+ for ((internalClassName, info) in instrumentedClassInfo) {
+ EdgeCoverageInstrumentor(ClassInstrumentor.defaultEdgeCoverageStrategy, ClassInstrumentor.defaultCoverageMap, 0)
+ .analyze(
+ executionDataStore,
+ coverage,
+ info.bytecode,
+ internalClassName,
+ )
+ }
+ coverage
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+
+ /**
+ * Traverses the entire classpath and analyzes all uncovered classes that match the include/exclude pattern.
+ * The returned [CoverageBuilder] will report coverage information for *all* classes on the classpath, not just
+ * those that were loaded while the fuzzer ran.
+ */
+ private fun analyzeAllUncoveredClasses(coverage: CoverageBuilder): CoverageBuilder {
+ val coveredClassNames = instrumentedClassInfo
+ .keys
+ .asSequence()
+ .map { it.replace('/', '.') }
+ .toSet()
+ ClassGraph()
+ .enableClassInfo()
+ .ignoreClassVisibility()
+ .rejectPackages(
+ // Always exclude Jazzer-internal packages (including ClassGraph itself) from coverage reports. Classes
+ // from the Java standard library are never traversed.
+ "com.code_intelligence.jazzer.*",
+ "jaz",
+ )
+ .scan().use { result ->
+ // ExecutionDataStore is used to look up existing coverage during analysis of the class files,
+ // no entries are added during that. Passing in an empty store is fine for uncovered files.
+ val emptyExecutionDataStore = ExecutionDataStore()
+ result.allClasses
+ .asSequence()
+ .filter { classInfo -> classNameGlobber.includes(classInfo.name) }
+ .filterNot { classInfo -> classInfo.name in coveredClassNames }
+ .forEach { classInfo ->
+ classInfo.resource.use { resource ->
+ EdgeCoverageInstrumentor(ClassInstrumentor.defaultEdgeCoverageStrategy, ClassInstrumentor.defaultCoverageMap, 0).analyze(
+ emptyExecutionDataStore,
+ coverage,
+ resource.load(),
+ classInfo.name.replace('.', '/'),
+ )
+ }
+ }
+ }
+ return coverage
+ }
+}