diff options
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.kt | 252 |
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 + } +} |