diff --git a/.gitignore b/.gitignore index 4064ce7..b013501 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ project/.sbtboot/ .bsp/ .bloop/ .cursor/ +.scala-build/ diff --git a/README.md b/README.md index 122af3b..4de7f0f 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,15 @@ sbt cli/assembly # Patch all classfiles in a directory (in-place) java -jar cli/target/scala-3.8.1/sloth.jar + +# Patch a JAR that references types living in the rest of the classpath +java -jar cli/target/scala-3.8.1/sloth.jar --hierarchy-classpath ``` The CLI recursively finds all `.class` files in the given directory, detects Scala 3.0-3.7.x lazy val implementations, and rewrites them to the 3.8+ VarHandle-based format. Use this for producing patched artifacts in build pipelines (assembly JARs, Docker images, etc.). +Rewriting a method makes ASM recompute its stack map frames, which needs the supertypes of every reference type the method merges. Types defined in a *different* classpath entry are invisible to Sloth unless you say where they are, and an unresolvable merge widens to `java/lang/Object` — producing frames the JVM rejects at load time with `VerifyError: Bad return type`. Pass the rest of the application's classpath via `--hierarchy-classpath` (or `JarProcessor.process(input, output, hierarchyClasspath)` when using the library directly) to avoid this. + ### Java Agent (Runtime Patching) ```bash diff --git a/build.sbt b/build.sbt index f02519d..45ef85d 100644 --- a/build.sbt +++ b/build.sbt @@ -152,6 +152,9 @@ lazy val agent = project val depJars = deps.files.filter(_.getName.endsWith(".jar")) // Build full classpath: CLI jar + all dependency JARs so ASM can resolve class hierarchies val fullCp = (cliJar +: depJars).map(_.getAbsolutePath).mkString(java.io.File.pathSeparator) + // Passed explicitly too: relying on the CLI's own classloader to happen to see a dependency's + // types is exactly the silent-Object-merge trap this flag exists to avoid. + val hierarchyCp = depJars.map(_.getAbsolutePath).mkString(java.io.File.pathSeparator) val debugAssembly = sys.env.contains("DEBUG_AGENT_ASSEMBLY") val processLogger: scala.sys.process.ProcessLogger = @@ -165,7 +168,7 @@ lazy val agent = project if (debugAssembly) log.info(s"Processing ${depJar.getName}...") val exitCode = scala.sys.process .Process( - Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath) + Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath, "--hierarchy-classpath", hierarchyCp) ) .!(processLogger) if (exitCode != 0) { diff --git a/cli/src/main/scala/sloth/cli/Main.scala b/cli/src/main/scala/sloth/cli/Main.scala index 21f495e..30a820d 100644 --- a/cli/src/main/scala/sloth/cli/Main.scala +++ b/cli/src/main/scala/sloth/cli/Main.scala @@ -1,7 +1,7 @@ package sloth.cli import sloth.analysis.{LazyValAnalyzer, ClassfileGroup} -import sloth.patching.BytecodePatcher +import sloth.patching.{BytecodePatcher, ClassHierarchySource} import sloth.jar.JarProcessor import scala.util.{Try, Success, Failure} @@ -33,14 +33,23 @@ object Main { def successful: Boolean = failed == 0 } + private val usage = "Usage: sloth [--hierarchy-classpath ]" + + /** Splits a platform-separated classpath string into paths. */ + private def parseClasspath(cp: String): Seq[java.nio.file.Path] = + cp.split(java.io.File.pathSeparatorChar).filter(_.nonEmpty).map(os.Path(_, os.pwd).toNIO).toSeq + def main(args: Array[String]): Unit = { - if (args.length != 1) { - Console.err.println(fansi.Color.Red("Error: Expected exactly one argument (directory or JAR path)")) - Console.err.println("Usage: sloth ") - sys.exit(1) + val (target, hierarchyClasspath) = args.toSeq match { + case Seq(t) => (t, Seq.empty[java.nio.file.Path]) + case Seq(t, "--hierarchy-classpath", cp) => (t, parseClasspath(cp)) + case _ => + Console.err.println(fansi.Color.Red("Error: Expected a directory or JAR path")) + Console.err.println(usage) + sys.exit(1) } - val targetPath = os.Path(args(0), os.pwd) + val targetPath = os.Path(target, os.pwd) if (!os.exists(targetPath)) { Console.err.println(fansi.Color.Red(s"Error: Path does not exist: $targetPath")) @@ -49,7 +58,7 @@ object Main { // Dispatch to JAR mode or directory mode if (targetPath.ext == "jar") { - processJar(targetPath) + processJar(targetPath, hierarchyClasspath) return } @@ -92,14 +101,17 @@ object Main { println(fansi.Color.Cyan(s"Grouped into ${groups.size} classfile group(s)")) println() - // Build a classloader that can resolve classes from the target directory - val classLoader = new java.net.URLClassLoader( - Array(targetDir.toNIO.toUri.toURL), - getClass.getClassLoader + // Class hierarchy context for frame computation: the directory being patched wins, then any + // caller-supplied classpath, then this process's own classpath and synthesized JDK stubs. + val hierarchy = ClassHierarchySource.chain( + ClassHierarchySource.forClasspath(targetDir.toNIO +: hierarchyClasspath), + ClassHierarchySource.forRuntimeClassLoader(getClass.getClassLoader) ) // Process each group - val results = groups.map(processGroup(_, targetDir, classLoader)) + val results = + try groups.map(processGroup(_, targetDir, hierarchy)) + finally hierarchy.close() // Print summary println() @@ -114,7 +126,7 @@ object Main { } /** Processes a JAR file in-place: reads, patches, writes back */ - private def processJar(jarPath: os.Path): Unit = { + private def processJar(jarPath: os.Path, hierarchyClasspath: Seq[java.nio.file.Path]): Unit = { println(fansi.Bold.On("Sloth - Scala 3.x Lazy Val Bytecode Patcher")) println(fansi.Color.Cyan(s"Processing JAR: $jarPath")) println() @@ -122,7 +134,7 @@ object Main { val input = jarPath.toNIO val tempOutput = os.temp(suffix = ".jar", deleteOnExit = true) - val result = JarProcessor.process(input, tempOutput.toNIO) + val result = JarProcessor.process(input, tempOutput.toNIO, hierarchyClasspath) println(fansi.Bold.On("=" * 80)) println(fansi.Bold.On("Summary:")) @@ -163,13 +175,13 @@ object Main { private def processGroup( group: ClassfileGroup, targetDir: os.Path, - classLoader: ClassLoader + hierarchy: ClassHierarchySource ): (String, PatchGroupResult) = { val groupName = group.primaryName print(fansi.Color.Cyan(s"Processing: $groupName ... ")) Try { - BytecodePatcher.patch(group, classLoader = Some(classLoader)) match { + BytecodePatcher.patch(group, hierarchySource = Some(hierarchy)) match { case BytecodePatcher.PatchResult.PatchedSingle(name, bytes) => // Write back single file val filePath = targetDir / s"${name.replace('.', '/')}.class" diff --git a/core/src/main/scala/sloth/jar/JarProcessor.scala b/core/src/main/scala/sloth/jar/JarProcessor.scala index 351fe8c..ab918b4 100644 --- a/core/src/main/scala/sloth/jar/JarProcessor.scala +++ b/core/src/main/scala/sloth/jar/JarProcessor.scala @@ -1,7 +1,7 @@ package sloth.jar import sloth.analysis.LazyValAnalyzer -import sloth.patching.BytecodePatcher +import sloth.patching.{BytecodePatcher, ClassHierarchySource} import java.io.{ByteArrayOutputStream, InputStream} import java.nio.file.Path @@ -17,8 +17,26 @@ object JarProcessor: errors: Seq[String] ) - /** Process a JAR: patch all .class entries, write to output path. Non-class entries pass through unchanged. */ - def process(inputJar: Path, outputJar: Path): JarResult = + /** Process a JAR: patch all .class entries, write to output path. Non-class entries pass through unchanged. + * + * Patching recomputes stack map frames, which requires resolving the supertypes of every reference type a rewritten + * method merges. Types defined outside the input JAR are only resolvable if the caller says where they live, so + * downstream tools should pass the rest of the application's classpath as `hierarchyClasspath` (jars and/or class + * directories). Without it an unresolvable merge widens to `java/lang/Object` and the patched class fails + * verification at load time — see https://github.com/VirtusLab/sloth/issues/1. + * + * @param hierarchyClasspath + * Jars and class directories defining types the input JAR references. The input JAR itself is always consulted + * first, so its own classes win. + * @param strictHierarchy + * Fail the group with a diagnostic instead of emitting frames widened to `java/lang/Object`. + */ + def process( + inputJar: Path, + outputJar: Path, + hierarchyClasspath: Seq[Path] = Nil, + strictHierarchy: Boolean = false + ): JarResult = val classEntries = mutable.LinkedHashMap[String, Array[Byte]]() val nonClassEntries = mutable.LinkedHashMap[String, Array[Byte]]() var manifest: Option[Manifest] = None @@ -49,11 +67,15 @@ object JarProcessor: className -> entryPath }.toMap - // Build a classloader that can resolve classes from the JAR - val jarClassLoader = new java.net.URLClassLoader( - Array(inputJar.toUri.toURL), - getClass.getClassLoader + // Hierarchy context for frame computation: the JAR's own (in-memory) classes first, then the + // caller-supplied classpath, then the host classpath and synthesized JDK stubs as fallbacks. + val baseHierarchy = ClassHierarchySource.chain( + ClassHierarchySource.fromClassBytes(classEntries.map((path, bytes) => path.stripSuffix(".class") -> bytes).toMap), + ClassHierarchySource.forClasspath(hierarchyClasspath), + ClassHierarchySource.forClassLoader(getClass.getClassLoader), + ClassHierarchySource.jdkStubs ) + val hierarchy = if strictHierarchy then ClassHierarchySource.strict(baseHierarchy) else baseHierarchy // Group and patch val errors = mutable.ArrayBuffer[String]() @@ -66,7 +88,7 @@ object JarProcessor: case Right(groups) => for group <- groups do try - BytecodePatcher.patch(group, classLoader = Some(jarClassLoader)) match + BytecodePatcher.patch(group, hierarchySource = Some(hierarchy)) match case BytecodePatcher.PatchResult.PatchedSingle(name, bytes) => nameToEntryPath.get(name).foreach(ep => patchedBytes(ep) = bytes) @@ -81,6 +103,8 @@ object JarProcessor: case e: Exception => errors += s"Exception patching group ${group.primaryName}: ${e.getMessage}" + hierarchy.close() + // Write output JAR val jos = manifest match case Some(m) => new JarOutputStream(java.nio.file.Files.newOutputStream(outputJar), m) diff --git a/core/src/main/scala/sloth/patching/BytecodePatcher.scala b/core/src/main/scala/sloth/patching/BytecodePatcher.scala index 7699c90..0f87c6b 100644 --- a/core/src/main/scala/sloth/patching/BytecodePatcher.scala +++ b/core/src/main/scala/sloth/patching/BytecodePatcher.scala @@ -69,261 +69,270 @@ object BytecodePatcher { val EvaluatingDesc: String = "L" + Evaluating + ";" val LazyValsObjDesc: String = "L" + LazyValsObj + ";" - /** Creates a ClassWriter, using the provided ClassLoader for class hierarchy resolution if available. */ - private def makeClassWriter(classLoader: Option[ClassLoader]): ClassWriter = - classLoader match - case Some(cl) => new ClassLoaderClassWriter(cl) - case None => new ClassWriter(ClassWriter.COMPUTE_FRAMES) + /** Creates a ClassWriter, using the provided hierarchy source for frame computation if available. */ + private def makeClassWriter(hierarchy: Option[ClassHierarchySource]): ClassWriter = + hierarchy match + case Some(source) => new ClassLoaderClassWriter(source) + case None => new ClassWriter(ClassWriter.COMPUTE_FRAMES) /** Patches a classfile group to 3.8+ format, handling both single files and companion pairs. * * @param group * The classfile group (single or companion pair) * @param classLoader - * Optional ClassLoader for resolving class hierarchies during frame computation + * Optional ClassLoader to resolve class hierarchies from; ignored when `hierarchySource` is given + * @param hierarchySource + * Optional explicit class hierarchy context for frame computation * @return * PatchResult indicating success, failure, or not applicable */ - def patch(group: ClassfileGroup, classLoader: Option[ClassLoader] = None): PatchResult = group match { - case ClassfileGroup.Single(name, classInfo, bytes) => - // Detect lazy vals in single class (no companion) - val detectionResult = LazyValDetector.detect(classInfo, None) - - val (lazyVals, version) = detectionResult match { - case LazyValDetectionResult.NoLazyVals => return PatchResult.NotApplicable - case LazyValDetectionResult.LazyValsFound(lvs, ver) => (lvs, ver) - case LazyValDetectionResult.MixedVersions(lvs) => - return PatchResult.Failed(buildDiagnostic("Mixed Scala versions detected", name, classInfo, lvs)) - } + def patch( + group: ClassfileGroup, + classLoader: Option[ClassLoader] = None, + hierarchySource: Option[ClassHierarchySource] = None + ): PatchResult = { + val hierarchy = hierarchySource.orElse(classLoader.map(ClassHierarchySource.forRuntimeClassLoader)) + group match { + case ClassfileGroup.Single(name, classInfo, bytes) => + // Detect lazy vals in single class (no companion) + val detectionResult = LazyValDetector.detect(classInfo, None) + + val (lazyVals, version) = detectionResult match { + case LazyValDetectionResult.NoLazyVals => return PatchResult.NotApplicable + case LazyValDetectionResult.LazyValsFound(lvs, ver) => (lvs, ver) + case LazyValDetectionResult.MixedVersions(lvs) => + return PatchResult.Failed(buildDiagnostic("Mixed Scala versions detected", name, classInfo, lvs)) + } - // Dispatch to version-specific patching - version match { - case ScalaVersion.Scala30x_31x => patchScala30x_31x(bytes, classInfo, lazyVals, name, classLoader = classLoader) - case ScalaVersion.Scala32x => patchScala32x(bytes, classInfo, lazyVals, name, classLoader = classLoader) - case ScalaVersion.Scala33x_37x => - patchScala33x_37x(bytes, classInfo, lazyVals, name, None, None, classLoader = classLoader) - case ScalaVersion.Scala38Plus => PatchResult.NotApplicable - case ScalaVersion.Unknown(reason) => - PatchResult.Failed(buildDiagnostic(s"Unknown Scala version detected: $reason", name, classInfo, lazyVals)) - } + // Dispatch to version-specific patching + version match { + case ScalaVersion.Scala30x_31x => patchScala30x_31x(bytes, classInfo, lazyVals, name, hierarchy = hierarchy) + case ScalaVersion.Scala32x => patchScala32x(bytes, classInfo, lazyVals, name, hierarchy = hierarchy) + case ScalaVersion.Scala33x_37x => + patchScala33x_37x(bytes, classInfo, lazyVals, name, None, None, hierarchy = hierarchy) + case ScalaVersion.Scala38Plus => PatchResult.NotApplicable + case ScalaVersion.Unknown(reason) => + PatchResult.Failed(buildDiagnostic(s"Unknown Scala version detected: $reason", name, classInfo, lazyVals)) + } - case ClassfileGroup.CompanionPair( - companionObjectName, - className, - companionObjectInfo, - classInfo, - companionObjectBytes, - classBytes - ) => - // Detect lazy vals in companion object with companion class context - val objectDetectionResult = LazyValDetector.detect(companionObjectInfo, Some(classInfo)) - - // Also detect lazy vals in companion class (standalone detection) - val classDetectionResult = LazyValDetector.detect(classInfo, None) - - // Determine if we need to patch. - // - // A companion pair from a single Scala compiler can only have: - // 1. One side with no lazy vals, the other with some version - // 2. Both sides with the exact same version - // Anything else is a bug in detection. After validating this invariant, - // skip if the version is already Scala38Plus (genuine or already-patched bytes - // fed back by a chained ClassFileTransformer). - val (objectLazyVals, classLazyVals, version) = (objectDetectionResult, classDetectionResult) match { - case (LazyValDetectionResult.NoLazyVals, LazyValDetectionResult.NoLazyVals) => - return PatchResult.NotApplicable - case (LazyValDetectionResult.LazyValsFound(objLvs, objVer), LazyValDetectionResult.NoLazyVals) => - (objLvs, Seq.empty, objVer) - case (LazyValDetectionResult.NoLazyVals, LazyValDetectionResult.LazyValsFound(clsLvs, clsVer)) => - (Seq.empty, clsLvs, clsVer) - case ( - LazyValDetectionResult.LazyValsFound(objLvs, objVer), - LazyValDetectionResult.LazyValsFound(clsLvs, clsVer) - ) => - // Both have lazy vals — versions MUST match. A mismatch is always a bug. - if (objVer != clsVer) { - val diag = new StringBuilder - diag.append(s"BUG: Companion class and object have different Scala versions: $clsVer vs $objVer\n") - diag.append(s"\n--- Companion object: $companionObjectName (detected as $objVer) ---\n") - diag.append(s" Fields:\n") - companionObjectInfo.fields.foreach { f => - diag.append(s" ${f.name}:${f.descriptor} (access=0x${f.access.toHexString})\n") - } - diag.append(s" Lazy vals (${objLvs.size}):\n") - objLvs.foreach { lv => - diag.append( - s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField - .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" - ) - } - diag.append(s"\n--- Companion class: $className (detected as $clsVer) ---\n") - diag.append(s" Fields:\n") - classInfo.fields.foreach { f => - diag.append(s" ${f.name}:${f.descriptor} (access=0x${f.access.toHexString})\n") - } - diag.append(s" Lazy vals (${clsLvs.size}):\n") - clsLvs.foreach { lv => - diag.append( - s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField - .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" - ) + case ClassfileGroup.CompanionPair( + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes + ) => + // Detect lazy vals in companion object with companion class context + val objectDetectionResult = LazyValDetector.detect(companionObjectInfo, Some(classInfo)) + + // Also detect lazy vals in companion class (standalone detection) + val classDetectionResult = LazyValDetector.detect(classInfo, None) + + // Determine if we need to patch. + // + // A companion pair from a single Scala compiler can only have: + // 1. One side with no lazy vals, the other with some version + // 2. Both sides with the exact same version + // Anything else is a bug in detection. After validating this invariant, + // skip if the version is already Scala38Plus (genuine or already-patched bytes + // fed back by a chained ClassFileTransformer). + val (objectLazyVals, classLazyVals, version) = (objectDetectionResult, classDetectionResult) match { + case (LazyValDetectionResult.NoLazyVals, LazyValDetectionResult.NoLazyVals) => + return PatchResult.NotApplicable + case (LazyValDetectionResult.LazyValsFound(objLvs, objVer), LazyValDetectionResult.NoLazyVals) => + (objLvs, Seq.empty, objVer) + case (LazyValDetectionResult.NoLazyVals, LazyValDetectionResult.LazyValsFound(clsLvs, clsVer)) => + (Seq.empty, clsLvs, clsVer) + case ( + LazyValDetectionResult.LazyValsFound(objLvs, objVer), + LazyValDetectionResult.LazyValsFound(clsLvs, clsVer) + ) => + // Both have lazy vals — versions MUST match. A mismatch is always a bug. + if (objVer != clsVer) { + val diag = new StringBuilder + diag.append(s"BUG: Companion class and object have different Scala versions: $clsVer vs $objVer\n") + diag.append(s"\n--- Companion object: $companionObjectName (detected as $objVer) ---\n") + diag.append(s" Fields:\n") + companionObjectInfo.fields.foreach { f => + diag.append(s" ${f.name}:${f.descriptor} (access=0x${f.access.toHexString})\n") + } + diag.append(s" Lazy vals (${objLvs.size}):\n") + objLvs.foreach { lv => + diag.append( + s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField + .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" + ) + } + diag.append(s"\n--- Companion class: $className (detected as $clsVer) ---\n") + diag.append(s" Fields:\n") + classInfo.fields.foreach { f => + diag.append(s" ${f.name}:${f.descriptor} (access=0x${f.access.toHexString})\n") + } + diag.append(s" Lazy vals (${clsLvs.size}):\n") + clsLvs.foreach { lv => + diag.append( + s" ${lv.name} (index=${lv.index}, version=${lv.version}, offset=${lv.offsetField.map(_.name)}, varHandle=${lv.varHandleField + .map(_.name)}, bitmap=${lv.bitmapField.map(_.name)}, init=${lv.initMethod.map(_.name)})\n" + ) + } + return PatchResult.Failed(diag.toString()) } - return PatchResult.Failed(diag.toString()) - } - (objLvs, clsLvs, objVer) - case (LazyValDetectionResult.MixedVersions(lvs), _) => - val allLvs = lvs ++ (classDetectionResult match { - case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty - }) - return PatchResult.Failed( - buildDiagnostic( - "Mixed Scala versions detected in companion object", - companionObjectName, - companionObjectInfo, - allLvs - ) - ) - case (_, LazyValDetectionResult.MixedVersions(lvs)) => - val allLvs = (objectDetectionResult match { - case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty - }) ++ lvs - return PatchResult.Failed( - buildDiagnostic("Mixed Scala versions detected in companion class", className, classInfo, allLvs) - ) - } - - // Already in target format — nothing to patch - if (version == ScalaVersion.Scala38Plus) { - return PatchResult.NotApplicable - } - - // Dispatch to version-specific patching based on what we found - version match { - case ScalaVersion.Scala30x_31x => - // Handle different companion pair scenarios for 3.0-3.1 - (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { - case (true, false) => - // Only object has lazy vals - companion class has OFFSET field - patchScala30x_31x( - companionObjectBytes, - companionObjectInfo, - objectLazyVals, + (objLvs, clsLvs, objVer) + case (LazyValDetectionResult.MixedVersions(lvs), _) => + val allLvs = lvs ++ (classDetectionResult match { + case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty + }) + return PatchResult.Failed( + buildDiagnostic( + "Mixed Scala versions detected in companion object", companionObjectName, - Some((className, classInfo, classBytes)), - classLoader = classLoader - ) - case (false, true) => - // Only class has lazy vals - patch as standalone - patchScala30x_31x(classBytes, classInfo, classLazyVals, className, classLoader = classLoader) - case (true, true) => - // BOTH have lazy vals - need to patch both independently - patchCompanionPairBothHaveLazyVals30x_31x( - companionObjectName, - className, companionObjectInfo, - classInfo, - companionObjectBytes, - classBytes, - objectLazyVals, - classLazyVals, - classLoader = classLoader + allLvs ) - case (false, false) => - PatchResult.NotApplicable - } + ) + case (_, LazyValDetectionResult.MixedVersions(lvs)) => + val allLvs = (objectDetectionResult match { + case LazyValDetectionResult.LazyValsFound(l, _) => l; case _ => Seq.empty + }) ++ lvs + return PatchResult.Failed( + buildDiagnostic("Mixed Scala versions detected in companion class", className, classInfo, allLvs) + ) + } - case ScalaVersion.Scala32x => - // Handle different companion pair scenarios for 3.2 (same logic as 3.0-3.1) - (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { - case (true, false) => - // Only object has lazy vals - companion class has OFFSET field - patchScala30x_31x( - companionObjectBytes, - companionObjectInfo, - objectLazyVals, - companionObjectName, - Some((className, classInfo, classBytes)), - classLoader = classLoader - ) - case (false, true) => - // Only class has lazy vals - patch as standalone - patchScala30x_31x(classBytes, classInfo, classLazyVals, className, classLoader = classLoader) - case (true, true) => - // BOTH have lazy vals - need to patch both independently - patchCompanionPairBothHaveLazyVals30x_31x( - companionObjectName, - className, - companionObjectInfo, - classInfo, - companionObjectBytes, - classBytes, - objectLazyVals, - classLazyVals, - classLoader = classLoader - ) - case (false, false) => - PatchResult.NotApplicable - } + // Already in target format — nothing to patch + if (version == ScalaVersion.Scala38Plus) { + return PatchResult.NotApplicable + } - case ScalaVersion.Scala33x_37x => - // Handle different companion pair scenarios - (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { - case (true, false) => - // Only object has lazy vals - val hasCompanionOffset = - objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) - if (hasCompanionOffset) { - patchScala33x_37x( + // Dispatch to version-specific patching based on what we found + version match { + case ScalaVersion.Scala30x_31x => + // Handle different companion pair scenarios for 3.0-3.1 + (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { + case (true, false) => + // Only object has lazy vals - companion class has OFFSET field + patchScala30x_31x( companionObjectBytes, companionObjectInfo, objectLazyVals, companionObjectName, Some((className, classInfo, classBytes)), - classLoader = classLoader + hierarchy = hierarchy ) - } else { - patchScala33x_37x( + case (false, true) => + // Only class has lazy vals - patch as standalone + patchScala30x_31x(classBytes, classInfo, classLazyVals, className, hierarchy = hierarchy) + case (true, true) => + // BOTH have lazy vals - need to patch both independently + patchCompanionPairBothHaveLazyVals30x_31x( + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, + hierarchy = hierarchy + ) + case (false, false) => + PatchResult.NotApplicable + } + + case ScalaVersion.Scala32x => + // Handle different companion pair scenarios for 3.2 (same logic as 3.0-3.1) + (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { + case (true, false) => + // Only object has lazy vals - companion class has OFFSET field + patchScala30x_31x( companionObjectBytes, companionObjectInfo, objectLazyVals, companionObjectName, - None, - None, - classLoader = classLoader + Some((className, classInfo, classBytes)), + hierarchy = hierarchy ) - } - case (false, true) => - // Only class has lazy vals - patch as standalone - patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, classLoader = classLoader) - case (true, true) => - // BOTH have lazy vals - need to patch both independently - patchCompanionPairBothHaveLazyVals33x_37x( + case (false, true) => + // Only class has lazy vals - patch as standalone + patchScala30x_31x(classBytes, classInfo, classLazyVals, className, hierarchy = hierarchy) + case (true, true) => + // BOTH have lazy vals - need to patch both independently + patchCompanionPairBothHaveLazyVals30x_31x( + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, + hierarchy = hierarchy + ) + case (false, false) => + PatchResult.NotApplicable + } + + case ScalaVersion.Scala33x_37x => + // Handle different companion pair scenarios + (objectLazyVals.nonEmpty, classLazyVals.nonEmpty) match { + case (true, false) => + // Only object has lazy vals + val hasCompanionOffset = + objectLazyVals.exists(_.offsetFieldLocation == OffsetFieldLocation.InCompanionClass) + if (hasCompanionOffset) { + patchScala33x_37x( + companionObjectBytes, + companionObjectInfo, + objectLazyVals, + companionObjectName, + Some((className, classInfo, classBytes)), + hierarchy = hierarchy + ) + } else { + patchScala33x_37x( + companionObjectBytes, + companionObjectInfo, + objectLazyVals, + companionObjectName, + None, + None, + hierarchy = hierarchy + ) + } + case (false, true) => + // Only class has lazy vals - patch as standalone + patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, hierarchy = hierarchy) + case (true, true) => + // BOTH have lazy vals - need to patch both independently + patchCompanionPairBothHaveLazyVals33x_37x( + companionObjectName, + className, + companionObjectInfo, + classInfo, + companionObjectBytes, + classBytes, + objectLazyVals, + classLazyVals, + hierarchy = hierarchy + ) + case (false, false) => + PatchResult.NotApplicable + } + + case ScalaVersion.Scala38Plus => PatchResult.NotApplicable + case ScalaVersion.Unknown(reason) => + val allLvs = objectLazyVals ++ classLazyVals + PatchResult.Failed( + buildDiagnostic( + s"Unknown Scala version detected: $reason", companionObjectName, - className, companionObjectInfo, - classInfo, - companionObjectBytes, - classBytes, - objectLazyVals, - classLazyVals, - classLoader = classLoader + allLvs ) - case (false, false) => - PatchResult.NotApplicable - } - - case ScalaVersion.Scala38Plus => PatchResult.NotApplicable - case ScalaVersion.Unknown(reason) => - val allLvs = objectLazyVals ++ classLazyVals - PatchResult.Failed( - buildDiagnostic( - s"Unknown Scala version detected: $reason", - companionObjectName, - companionObjectInfo, - allLvs ) - ) - } + } + } } /** Builds a multi-line diagnostic message for Failed results. */ @@ -373,7 +382,7 @@ object BytecodePatcher { lazyVals: Seq[LazyValInfo], name: String, companionInfo: Option[(String, sloth.classfile.ClassInfo, Array[Byte])] = None, - classLoader: Option[ClassLoader] = None + hierarchy: Option[ClassHierarchySource] = None ): PatchResult = { try { companionInfo match { @@ -386,7 +395,7 @@ object BytecodePatcher { patchCompanionClass30x_31x(companionClassNode) - val companionWriter = makeClassWriter(classLoader) + val companionWriter = makeClassWriter(hierarchy) companionClassNode.accept(companionWriter) val patchedClassBytes = companionWriter.toByteArray @@ -397,7 +406,7 @@ object BytecodePatcher { patchClassNode30x_31x(objectNode, classInfo.name, lazyVals) - val objectWriter = makeClassWriter(classLoader) + val objectWriter = makeClassWriter(hierarchy) objectNode.accept(objectWriter) val patchedObjectBytes = objectWriter.toByteArray @@ -411,7 +420,7 @@ object BytecodePatcher { patchClassNode30x_31x(classNode, classInfo.name, lazyVals) - val writer = makeClassWriter(classLoader) + val writer = makeClassWriter(hierarchy) classNode.accept(writer) val patchedBytes = writer.toByteArray @@ -434,7 +443,7 @@ object BytecodePatcher { classBytes: Array[Byte], objectLazyVals: Seq[LazyValInfo], classLazyVals: Seq[LazyValInfo], - classLoader: Option[ClassLoader] = None + hierarchy: Option[ClassHierarchySource] = None ): PatchResult = { try { // Step 1: Patch object (which also patches class to remove OFFSET fields) @@ -444,7 +453,7 @@ object BytecodePatcher { objectLazyVals, companionObjectName, Some((className, classInfo, classBytes)), - classLoader = classLoader + hierarchy = hierarchy ) objectPatchResult match { @@ -457,7 +466,7 @@ object BytecodePatcher { patchClassNode30x_31x(classNode, className, classLazyVals) - val writer = makeClassWriter(classLoader) + val writer = makeClassWriter(hierarchy) classNode.accept(writer) val finalPatchedClassBytes = writer.toByteArray @@ -1310,9 +1319,9 @@ object BytecodePatcher { classInfo: sloth.classfile.ClassInfo, lazyVals: Seq[LazyValInfo], name: String, - classLoader: Option[ClassLoader] = None + hierarchy: Option[ClassHierarchySource] = None ): PatchResult = { - patchScala30x_31x(bytes, classInfo, lazyVals, name, classLoader = classLoader) + patchScala30x_31x(bytes, classInfo, lazyVals, name, hierarchy = hierarchy) } // ============================================================================ @@ -1332,7 +1341,7 @@ object BytecodePatcher { classBytes: Array[Byte], objectLazyVals: Seq[LazyValInfo], classLazyVals: Seq[LazyValInfo], - classLoader: Option[ClassLoader] = None + hierarchy: Option[ClassHierarchySource] = None ): PatchResult = { try { // Check if object lazy vals have OFFSET in companion class @@ -1348,7 +1357,7 @@ object BytecodePatcher { objectLazyVals, companionObjectName, Some((className, classInfo, classBytes)), - classLoader = classLoader + hierarchy = hierarchy ) objectPatchResult match { @@ -1363,7 +1372,7 @@ object BytecodePatcher { patchClassNode33x_37x(classNode, className, classLazyVals) // Write back to bytes - val writer = makeClassWriter(classLoader) + val writer = makeClassWriter(hierarchy) classNode.accept(writer) val finalPatchedClassBytes = writer.toByteArray @@ -1384,10 +1393,10 @@ object BytecodePatcher { companionObjectName, None, None, - classLoader = classLoader + hierarchy = hierarchy ) val classPatchResult = - patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, classLoader = classLoader) + patchScala33x_37x(classBytes, classInfo, classLazyVals, className, None, None, hierarchy = hierarchy) (objectPatchResult, classPatchResult) match { case (PatchResult.PatchedSingle(_, objBytes), PatchResult.PatchedSingle(_, clsBytes)) => @@ -1422,7 +1431,7 @@ object BytecodePatcher { name: String, companionInfo: Option[(String, sloth.classfile.ClassInfo, Array[Byte])], unused: Option[Any] = None, // For compatibility with old signature - classLoader: Option[ClassLoader] = None + hierarchy: Option[ClassHierarchySource] = None ): PatchResult = { try { companionInfo match { @@ -1443,7 +1452,7 @@ object BytecodePatcher { // Patch companion class: just remove OFFSET fields and patchCompanionClass33x_37x(companionClassNode, lazyVals) - val companionWriter = makeClassWriter(classLoader) + val companionWriter = makeClassWriter(hierarchy) companionClassNode.accept(companionWriter) val patchedClassBytes = companionWriter.toByteArray @@ -1490,7 +1499,7 @@ object BytecodePatcher { ) } - val objectWriter = makeClassWriter(classLoader) + val objectWriter = makeClassWriter(hierarchy) objectNode.accept(objectWriter) val patchedObjectBytes = objectWriter.toByteArray @@ -1506,7 +1515,7 @@ object BytecodePatcher { patchClassNode33x_37x(classNode, classInfo.name, lazyVals) // Write back to bytes - val writer = makeClassWriter(classLoader) + val writer = makeClassWriter(hierarchy) classNode.accept(writer) val patchedBytes = writer.toByteArray diff --git a/core/src/main/scala/sloth/patching/ClassHierarchyException.scala b/core/src/main/scala/sloth/patching/ClassHierarchyException.scala new file mode 100644 index 0000000..8de7080 --- /dev/null +++ b/core/src/main/scala/sloth/patching/ClassHierarchyException.scala @@ -0,0 +1,12 @@ +package sloth.patching + +/** Thrown in strict mode when a type cannot be resolved while recomputing stack map frames. + * + * Widening such a merge to `java/lang/Object` produces frames that no longer describe the code, which the JVM only + * rejects later, at class load time, as a `VerifyError`. + */ +class ClassHierarchyException(val internalName: String) + extends RuntimeException( + s"Cannot resolve class '$internalName' while computing stack map frames. " + + "Supply the jars or class directories that define it as hierarchy classpath." + ) diff --git a/core/src/main/scala/sloth/patching/ClassHierarchySource.scala b/core/src/main/scala/sloth/patching/ClassHierarchySource.scala new file mode 100644 index 0000000..9dd2554 --- /dev/null +++ b/core/src/main/scala/sloth/patching/ClassHierarchySource.scala @@ -0,0 +1,309 @@ +package sloth.patching + +import org.objectweb.asm.{ClassWriter, Opcodes} + +import java.io.Closeable +import java.nio.file.{Files, Path} +import java.util.zip.ZipFile +import scala.collection.mutable + +/** Supplies the classfile bytes ASM needs to answer "what is the common supertype of these two types?" while + * recomputing stack map frames. + * + * Answers are bytes rather than `Class` objects on purpose: in agent mode loading a class early would hand the JVM + * unpatched bytes, and inside a GraalVM native image there are no `.class` resources to read and `Class.forName` + * against a caller-supplied loader does not work either. + */ +trait ClassHierarchySource extends Closeable: + + /** Classfile bytes for a JVM internal name (`a/b/C`), or `None` if this source cannot serve it. */ + def classBytes(internalName: String): Option[Array[Byte]] + + /** When true, an unresolvable type aborts patching instead of widening the merge to `Object`. */ + def failOnUnresolved: Boolean = false + + def close(): Unit = () + +object ClassHierarchySource: + + /** Prefixes whose types are guaranteed free of Scala lazy vals, so reflecting on them is safe. */ + private val jdkPrefixes = Array("java/", "javax/", "jdk/", "sun/", "com/sun/") + + /** The class-level subset of the access flags; `Class.getModifiers` also reports member flags. */ + private val classAccessMask = + Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_INTERFACE | + Opcodes.ACC_ABSTRACT | Opcodes.ACC_ANNOTATION | Opcodes.ACC_ENUM + + /** An in-memory source, keyed by internal name. Used for the classfiles already being patched. */ + def fromClassBytes(classes: Map[String, Array[Byte]]): ClassHierarchySource = + new ClassHierarchySource: + def classBytes(internalName: String): Option[Array[Byte]] = classes.get(internalName) + + /** Reads `.class` entries directly out of jars and class directories, in the given order. */ + def forClasspath(entries: Seq[Path]): ClassHierarchySource = new ClasspathSource(entries) + + /** Reads `.class` resources from a classloader without ever asking it to load a class. */ + def forClassLoader(loader: ClassLoader): ClassHierarchySource = + new ClassHierarchySource: + def classBytes(internalName: String): Option[Array[Byte]] = + val resource = loader.getResourceAsStream(internalName + ".class") + if resource == null then None + else + try Some(resource.readAllBytes()) + catch case _: Throwable => None + finally resource.close() + + /** The resolution chain to use when all a caller has is a classloader. + * + * The system loader is consulted as well because a restricted loader (an agent's, or one built over a single jar) + * frequently cannot see the application's own types. + */ + def forRuntimeClassLoader(loader: ClassLoader): ClassHierarchySource = + val systemLoader = ClassLoader.getSystemClassLoader + val loaders = + if systemLoader ne loader then Seq(forClassLoader(loader), forClassLoader(systemLoader)) + else Seq(forClassLoader(loader)) + chain(loaders :+ jdkStubs*) + + /** Synthesizes supertype-only stubs for JDK types. + * + * From Java 9 on, platform types have no readable `.class` resource, and in a GraalVM native image they frequently + * have no `Class` object either unless the type was registered for reflection — dynamic + * `Class.forName("java.util.jar.JarFile")` fails even though the class is part of the JDK. A stub carrying just the + * access flags, superclass and interfaces is everything frame computation needs. Resolution order: + * 1. class literals for types known to appear in scala3-compiler merges (embeds them in native images) 2. + * `Class.forName` via the bootstrap / platform loader 3. hardcoded hierarchy for the JarFile / ZipFile / + * JarOutputStream cluster + */ + val jdkStubs: ClassHierarchySource = + new ClassHierarchySource: + private val stubCache = new java.util.concurrent.ConcurrentHashMap[String, Array[Byte]]() + + def classBytes(internalName: String): Option[Array[Byte]] = + if !jdkPrefixes.exists(internalName.startsWith) then None + else + Option(stubCache.get(internalName)).orElse { + val bytesOpt = + resolveJdkClass(internalName.replace('/', '.')) + .map(classStubBytes) + .orElse(hardcodedJdkStub(internalName)) + bytesOpt.foreach(stubCache.put(internalName, _)) + bytesOpt + } + + private def resolveJdkClass(binaryName: String): Option[Class[?]] = + // Prefer class literals for types the native image must see; Class.forName of dynamic + // names needs reflection metadata under GraalVM and otherwise returns ClassNotFoundException. + binaryName match + case "java.util.jar.JarFile" => Some(classOf[java.util.jar.JarFile]) + case "java.util.jar.JarOutputStream" => Some(classOf[java.util.jar.JarOutputStream]) + case "java.util.zip.ZipFile" => Some(classOf[java.util.zip.ZipFile]) + case "java.util.zip.ZipOutputStream" => Some(classOf[java.util.zip.ZipOutputStream]) + case "java.util.zip.DeflaterOutputStream" => Some(classOf[java.util.zip.DeflaterOutputStream]) + case "java.util.zip.ZipEntry" => Some(classOf[java.util.zip.ZipEntry]) + case "java.io.FilterOutputStream" => Some(classOf[java.io.FilterOutputStream]) + case "java.io.OutputStream" => Some(classOf[java.io.OutputStream]) + case "java.io.IOException" => Some(classOf[java.io.IOException]) + case "java.io.Closeable" => Some(classOf[java.io.Closeable]) + case "java.lang.AutoCloseable" => Some(classOf[java.lang.AutoCloseable]) + case "java.lang.Exception" => Some(classOf[java.lang.Exception]) + case "java.lang.Throwable" => Some(classOf[java.lang.Throwable]) + case "java.lang.Object" => Some(classOf[java.lang.Object]) + case "java.io.Serializable" => Some(classOf[java.io.Serializable]) + case "java.io.File" => Some(classOf[java.io.File]) + case "java.lang.String" => Some(classOf[java.lang.String]) + case "java.lang.Class" => Some(classOf[Class[?]]) + case "java.lang.Error" => Some(classOf[java.lang.Error]) + case "java.lang.RuntimeException" => Some(classOf[java.lang.RuntimeException]) + case "java.lang.ReflectiveOperationException" => + Some(classOf[java.lang.ReflectiveOperationException]) + case _ => + try Some(Class.forName(binaryName, false, null)) + catch + case _: Throwable => + try Some(Class.forName(binaryName, false, ClassLoader.getPlatformClassLoader)) + catch case _: Throwable => None + + private def classStubBytes(cls: Class[?]): Array[Byte] = + val internalName = cls.getName.replace('.', '/') + val superClass = cls.getSuperclass + val superName = + if superClass != null then superClass.getName.replace('.', '/') + else if internalName == "java/lang/Object" then null + else "java/lang/Object" + val writer = new ClassWriter(0) + writer.visit( + Opcodes.V1_8, + cls.getModifiers & classAccessMask, + internalName, + null, + superName, + cls.getInterfaces.map(_.getName.replace('.', '/')) + ) + writer.visitEnd() + writer.toByteArray + + /** Last-resort stubs when neither class literals nor Class.forName work. Enough for the known + * `FileZipArchive.openZipFile` JarFile/ZipFile merge in scala3-compiler. + */ + private def hardcodedJdkStub(internalName: String): Option[Array[Byte]] = + internalName match + case "java/util/jar/JarFile" => + Some(manualStub("java/util/jar/JarFile", "java/util/zip/ZipFile", Array.empty, isInterface = false)) + case "java/util/jar/JarOutputStream" => + Some( + manualStub( + "java/util/jar/JarOutputStream", + "java/util/zip/ZipOutputStream", + Array.empty, + isInterface = false + ) + ) + case "java/util/zip/ZipOutputStream" => + Some( + manualStub( + "java/util/zip/ZipOutputStream", + "java/util/zip/DeflaterOutputStream", + Array.empty, + isInterface = false + ) + ) + case "java/util/zip/DeflaterOutputStream" => + Some( + manualStub( + "java/util/zip/DeflaterOutputStream", + "java/io/FilterOutputStream", + Array.empty, + isInterface = false + ) + ) + case "java/io/FilterOutputStream" => + Some( + manualStub( + "java/io/FilterOutputStream", + "java/io/OutputStream", + Array.empty, + isInterface = false + ) + ) + case "java/io/OutputStream" => + Some( + manualStub( + "java/io/OutputStream", + "java/lang/Object", + Array("java/io/Closeable", "java/lang/AutoCloseable"), + isInterface = false + ) + ) + case "java/util/zip/ZipFile" => + Some( + manualStub( + "java/util/zip/ZipFile", + "java/lang/Object", + Array("java/io/Closeable", "java/lang/AutoCloseable"), + isInterface = false + ) + ) + case "java/io/Closeable" => + Some( + manualStub( + "java/io/Closeable", + "java/lang/Object", + Array("java/lang/AutoCloseable"), + isInterface = true + ) + ) + case "java/lang/AutoCloseable" => + Some(manualStub("java/lang/AutoCloseable", "java/lang/Object", Array.empty, isInterface = true)) + case "java/io/IOException" => + Some(manualStub("java/io/IOException", "java/lang/Exception", Array.empty, isInterface = false)) + case "java/lang/Exception" => + Some(manualStub("java/lang/Exception", "java/lang/Throwable", Array.empty, isInterface = false)) + case "java/lang/Throwable" => + Some( + manualStub( + "java/lang/Throwable", + "java/lang/Object", + Array("java/io/Serializable"), + isInterface = false + ) + ) + case "java/io/Serializable" => + Some(manualStub("java/io/Serializable", "java/lang/Object", Array.empty, isInterface = true)) + case "java/lang/Object" => + Some(manualStub("java/lang/Object", null, Array.empty, isInterface = false)) + case _ => None + + private def manualStub( + internalName: String, + superName: String, + interfaces: Array[String], + isInterface: Boolean + ): Array[Byte] = + val access = + Opcodes.ACC_PUBLIC | + (if isInterface then Opcodes.ACC_INTERFACE | Opcodes.ACC_ABSTRACT else 0) + val writer = new ClassWriter(0) + writer.visit(Opcodes.V1_8, access, internalName, null, superName, interfaces) + writer.visitEnd() + writer.toByteArray + + /** Tries each source in order and returns the first hit. */ + def chain(sources: ClassHierarchySource*): ClassHierarchySource = + new ClassHierarchySource: + def classBytes(internalName: String): Option[Array[Byte]] = + sources.iterator.map(_.classBytes(internalName)).collectFirst { case Some(bytes) => bytes } + override def failOnUnresolved: Boolean = sources.exists(_.failOnUnresolved) + override def close(): Unit = sources.foreach(_.close()) + + /** Same resolution, but an unresolvable type raises [[ClassHierarchyException]]. */ + def strict(source: ClassHierarchySource): ClassHierarchySource = + new ClassHierarchySource: + def classBytes(internalName: String): Option[Array[Byte]] = source.classBytes(internalName) + override def failOnUnresolved: Boolean = true + override def close(): Unit = source.close() + + /** Serves classfiles from jars (via `ZipFile`) and class directories (via plain file reads). */ + private final class ClasspathSource(entries: Seq[Path]) extends ClassHierarchySource: + + private val zipFiles = mutable.LinkedHashMap.empty[Path, Option[ZipFile]] + + // Synchronized because a single source is shared across the writers of every patched group. + private def zipFileFor(entry: Path): Option[ZipFile] = zipFiles.synchronized { + zipFiles.getOrElseUpdate( + entry, + try Some(new ZipFile(entry.toFile)) + catch case _: Throwable => None + ) + } + + private def fromJar(entry: Path, resource: String): Option[Array[Byte]] = + zipFileFor(entry).flatMap { zip => + Option(zip.getEntry(resource)).flatMap { zipEntry => + val in = zip.getInputStream(zipEntry) + try Some(in.readAllBytes()) + catch case _: Throwable => None + finally in.close() + } + } + + private def fromDirectory(entry: Path, resource: String): Option[Array[Byte]] = + val file = entry.resolve(resource) + if !Files.isRegularFile(file) then None + else + try Some(Files.readAllBytes(file)) + catch case _: Throwable => None + + def classBytes(internalName: String): Option[Array[Byte]] = + val resource = internalName + ".class" + entries.iterator + .map(entry => if Files.isDirectory(entry) then fromDirectory(entry, resource) else fromJar(entry, resource)) + .collectFirst { case Some(bytes) => bytes } + + override def close(): Unit = zipFiles.synchronized { + zipFiles.values.flatten.foreach { zip => + try zip.close() + catch case _: Throwable => () + } + zipFiles.clear() + } diff --git a/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala b/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala index 28e0647..5afc260 100644 --- a/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala +++ b/core/src/main/scala/sloth/patching/ClassLoaderClassWriter.scala @@ -2,62 +2,48 @@ package sloth.patching import org.objectweb.asm.{ClassReader, ClassWriter, Opcodes} +import scala.collection.mutable + /** ClassWriter subclass that resolves class hierarchies WITHOUT triggering class loading. * * ASM's COMPUTE_FRAMES needs to find common superclasses. The default implementation uses Class.forName() which * triggers JVM class loading — problematic in agent mode because it can load classes with unpatched bytes before the * transformer has a chance to patch them. * - * This implementation reads class bytecode via getResourceAsStream and parses superclass names with ASM's ClassReader, - * walking the hierarchy without ever calling loadClass(). - * - * On Java 9+, JDK classes in platform modules (java.base, etc.) may not be accessible via getResourceAsStream from - * arbitrary classloaders. For JDK types (java/, javax/, jdk/, sun/), we fall back to Class.forName() which is safe — - * these classes are already loaded or are guaranteed not to contain Scala lazy vals. + * Supertypes are instead read out of a [[ClassHierarchySource]] with ASM's ClassReader, walking the hierarchy without + * ever calling loadClass(). A type the source cannot serve is reported (logged, or raised in strict mode) rather than + * silently widening the merge to `java/lang/Object`, because such a merge yields frames the JVM rejects with a + * VerifyError. */ -class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(ClassWriter.COMPUTE_FRAMES): +class ClassLoaderClassWriter(source: ClassHierarchySource) extends ClassWriter(ClassWriter.COMPUTE_FRAMES): + + def this(classLoader: ClassLoader) = this(ClassHierarchySource.forRuntimeClassLoader(classLoader)) private case class ClassInfo(superName: String, interfaces: Array[String], isInterface: Boolean) - /** JDK package prefixes where Class.forName fallback is safe (no lazy val patching risk). */ - private val jdkPrefixes = Array("java/", "javax/", "jdk/", "sun/", "com/sun/") + private val reported = mutable.Set.empty[String] private def readClassInfo(internalName: String): Option[ClassInfo] = if internalName == "java/lang/Object" then Some(ClassInfo(null, Array.empty, isInterface = false)) else - readClassInfoFromResource(classLoader, internalName) - .orElse { - // Try system classloader if different — covers cases where the provided - // classloader can't see platform/system classes - val sysCl = ClassLoader.getSystemClassLoader - if (sysCl ne classLoader) then readClassInfoFromResource(sysCl, internalName) else None - } - .orElse(readClassInfoViaReflection(internalName)) + val info = source.classBytes(internalName).flatMap { bytes => + try + val reader = new ClassReader(bytes) + val isInterface = (reader.getAccess & Opcodes.ACC_INTERFACE) != 0 + Some(ClassInfo(reader.getSuperName, reader.getInterfaces, isInterface)) + catch case _: Throwable => None + } + if info.isEmpty then reportUnresolvable(internalName) + info - private def readClassInfoFromResource(cl: ClassLoader, internalName: String): Option[ClassInfo] = - val resource = cl.getResourceAsStream(internalName + ".class") - if resource == null then None - else - try - val reader = new ClassReader(resource) - val isInterface = (reader.getAccess & Opcodes.ACC_INTERFACE) != 0 - Some(ClassInfo(reader.getSuperName, reader.getInterfaces, isInterface)) - catch case _: Throwable => None - finally resource.close() - - /** Fallback: use Class.forName for JDK types that can't be found via getResourceAsStream on Java 9+ (module system). - * This is safe because JDK classes never contain Scala lazy vals, so loading them won't trigger unwanted patching. - */ - private def readClassInfoViaReflection(internalName: String): Option[ClassInfo] = - if !jdkPrefixes.exists(internalName.startsWith) then None - else - try - val cls = Class.forName(internalName.replace('/', '.'), false, classLoader) - val isInterface = cls.isInterface - val superName = if cls.getSuperclass != null then cls.getSuperclass.getName.replace('.', '/') else null - val interfaces = cls.getInterfaces.map(_.getName.replace('.', '/')) - Some(ClassInfo(superName, interfaces, isInterface)) - catch case _: Throwable => None + private def reportUnresolvable(internalName: String): Unit = + if source.failOnUnresolved then throw new ClassHierarchyException(internalName) + else if reported.add(internalName) then + scribe.warn( + s"Cannot resolve class '$internalName' while computing stack map frames; merges involving " + + "it degrade to java/lang/Object, which can make the patched class fail verification. " + + "Supply the jars or class directories that define it as hierarchy classpath." + ) private def getSuperClasses(internalName: String): List[String] = var result = List(internalName) @@ -71,11 +57,11 @@ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(Class current = null result - private def isAssignableFrom(target: String, source: String): Boolean = - if target == source then return true + private def isAssignableFrom(target: String, subType: String): Boolean = + if target == subType then return true if target == "java/lang/Object" then return true - // Walk source's hierarchy checking superclasses and interfaces - var queue = List(source) + // Walk subType's hierarchy checking superclasses and interfaces + var queue = List(subType) var visited = Set.empty[String] while queue.nonEmpty do val current = queue.head @@ -93,10 +79,13 @@ class ClassLoaderClassWriter(classLoader: ClassLoader) extends ClassWriter(Class override def getCommonSuperClass(type1: String, type2: String): String = if type1 == "java/lang/Object" || type2 == "java/lang/Object" then return "java/lang/Object" + // ASM also merges array types; their element hierarchy is irrelevant to the frames we emit. + if type1.startsWith("[") || type2.startsWith("[") then return "java/lang/Object" + val info1 = readClassInfo(type1) val info2 = readClassInfo(type2) - // If we can't read either class, fall back to Object + // If we can't read either class, fall back to Object — readClassInfo has already reported it if info1.isEmpty || info2.isEmpty then return "java/lang/Object" if isAssignableFrom(type1, type2) then type1 diff --git a/tests-jdk11/src/test/scala/sloth/JarProcessorHierarchyTests.scala b/tests-jdk11/src/test/scala/sloth/JarProcessorHierarchyTests.scala new file mode 100644 index 0000000..198ecfb --- /dev/null +++ b/tests-jdk11/src/test/scala/sloth/JarProcessorHierarchyTests.scala @@ -0,0 +1,239 @@ +package sloth + +import munit.FunSuite +import org.objectweb.asm.tree.{ClassNode, FrameNode} +import org.objectweb.asm.{ClassReader, Opcodes} +import sloth.jar.JarProcessor +import sloth.patching.{ClassHierarchyException, ClassHierarchySource, ClassLoaderClassWriter} + +import java.io.InputStream +import java.net.{URL, URLClassLoader} +import java.nio.file.{Files, Path, Paths} +import java.util.jar.{JarEntry, JarOutputStream} +import scala.jdk.CollectionConverters.* + +/** Regression suite for the class-hierarchy resolution used while recomputing stack map frames. + * + * Patching rewrites methods and lets ASM recompute frames, which requires merging reference types. When a merge + * involves types the resolver cannot see, ASM widens the merge to `java/lang/Object`, the frames no longer describe + * the code, and the JVM rejects the class with a `VerifyError`. See https://github.com/VirtusLab/sloth/issues/1. + * + * The `cross-jar-hierarchy` fixture is compiled as one unit and then split into two jars, so the jar being patched + * genuinely cannot see the base/subclass hierarchy its methods merge. + */ +class JarProcessorHierarchyTests extends FunSuite with ExampleLoader { + + override val munitTimeout = scala.concurrent.duration.Duration(600, "s") + + override val examplesDir: os.Path = os.pwd / "tests" / "src" / "test" / "resources" / "fixtures" / "examples" + override val testWorkspace: os.Path = os.temp.dir(prefix = "sloth-jar-hierarchy-tests-", deleteOnExit = false) + override val quietCompilation: Boolean = true + override val quietTests: Boolean = true + + /** The OFFSET-based range, where patching actually rewrites bodies and recomputes frames. */ + val hierarchyVersions: Seq[String] = Seq("3.3.0", "3.7.3") + + override def requiredScalaVersions: Seq[String] = hierarchyVersions + + val exampleName = "cross-jar-hierarchy" + val holderInternalName = "slothtest/crossjar/CrossJarHolder$" + val baseInternalName = "slothtest/crossjar/HierBase" + val hierarchyClassNames: Set[String] = + Set("slothtest/crossjar/HierBase", "slothtest/crossjar/HierSubA", "slothtest/crossjar/HierSubB") + + override def beforeAll(): Unit = + scribe.Logger.root + .clearHandlers() + .clearModifiers() + .withHandler(minimumLevel = Some(scribe.Level.Error)) + .replace() + + lazy val example: LoadedExample = + loadExample(exampleName).getOrElse(fail(s"Failed to compile the '$exampleName' fixture")) + + /** Directory holding the compiled classfiles of the fixture for one Scala version. */ + def compiledDir(version: String): os.Path = + val versionResult = example.compilationResult.results + .get(version) + .filter(_.success) + .getOrElse(fail(s"Fixture '$exampleName' did not compile with Scala $version")) + val anyFile = versionResult.classFiles.head + os.Path(anyFile.absolutePath.toString.stripSuffix(anyFile.relativePath).stripSuffix("/")) + + /** scala-library (and friends) for the fixture, needed to actually initialize the patched class. */ + def scalaRuntimeUrls(version: String): Seq[URL] = + val targetDir = testWorkspace / exampleName / version + val cp = os + .proc( + "scala-cli", + "compile", + "--print-classpath", + "--jvm", + "17", + "--release", + "9", + "--bloop-startup-timeout", + "180s", + "-S", + version, + targetDir.toString + ) + .call(cwd = targetDir, stderr = os.Pipe, stdout = os.Pipe) + .out + .text() + .trim + cp.split(java.io.File.pathSeparatorChar).filter(_.nonEmpty).map(p => Paths.get(p).toUri.toURL).toSeq + + /** Write the given classfiles (internal name -> bytes) into a jar. */ + def writeJar(target: os.Path, classes: Map[String, Array[Byte]]): Path = + val jos = new JarOutputStream(Files.newOutputStream(target.toNIO)) + try + classes.foreach { (internalName, bytes) => + jos.putNextEntry(new JarEntry(internalName + ".class")) + jos.write(bytes) + jos.closeEntry() + } + finally jos.close() + target.toNIO + + /** Split the compiled fixture into `lib-a.jar` (the class to patch) and `lib-b.jar` (hierarchy). */ + def splitFixtureJars(version: String, label: String): (Path, Path) = + val dir = compiledDir(version) + val all = os + .walk(dir) + .filter(p => os.isFile(p) && p.last.endsWith(".class")) + .map(p => p.relativeTo(dir).toString.stripSuffix(".class") -> os.read.bytes(p)) + .toMap + val (hierarchy, rest) = all.partition((name, _) => hierarchyClassNames.contains(name)) + assertEquals( + hierarchy.keySet, + hierarchyClassNames, + s"Fixture should provide the full hierarchy; found ${all.keySet}" + ) + val jarDir = testWorkspace / "jars" / version / label + os.makeDir.all(jarDir) + (writeJar(jarDir / "lib-a.jar", rest), writeJar(jarDir / "lib-b.jar", hierarchy)) + + def readJarEntry(jar: Path, internalName: String): Array[Byte] = + val zip = new java.util.zip.ZipFile(jar.toFile) + try + val entry = Option(zip.getEntry(internalName + ".class")) + .getOrElse(fail(s"$internalName.class missing from $jar")) + zip.getInputStream(entry).readAllBytes() + finally zip.close() + + /** All reference types mentioned by the stack map frames of the methods with the given prefix. */ + def frameTypes(classBytes: Array[Byte], methodNamePrefix: String): Set[String] = + val node = new ClassNode(Opcodes.ASM9) + new ClassReader(classBytes).accept(node, 0) + val methods = node.methods.asScala.filter(_.name.startsWith(methodNamePrefix)) + assert(methods.nonEmpty, s"No method starting with '$methodNamePrefix' in ${node.name}") + methods.flatMap { m => + m.instructions + .iterator() + .asScala + .collect { case f: FrameNode => + val locals = Option(f.local).map(_.asScala.toSeq).getOrElse(Seq.empty) + val stack = Option(f.stack).map(_.asScala.toSeq).getOrElse(Seq.empty) + (locals ++ stack).collect { case s: String => s } + } + .flatten + }.toSet + + for version <- hierarchyVersions if isExampleSelected(exampleName) && isScalaVersionSelected(version) do + + test(s"hierarchy classpath keeps stack map frames precise (Scala $version)") { + val (libA, libB) = splitFixtureJars(version, "frames") + val patched = (testWorkspace / "jars" / version / "frames" / "lib-a-patched.jar").toNIO + + val result = JarProcessor.process(libA, patched, hierarchyClasspath = Seq(libB)) + assertEquals(result.errors, Seq.empty[String], "Patching should not report errors") + assert(result.patchedClasses > 0, "The holder class should have been patched") + + val holderBytes = readJarEntry(patched, holderInternalName) + Seq("choose", "picked$lzyINIT").foreach { method => + val types = frameTypes(holderBytes, method) + assert( + types.contains(baseInternalName), + s"$method frames should merge the subclasses to $baseInternalName, got $types" + ) + } + } + + test(s"patched class loads and initializes over the hierarchy jar (Scala $version)") { + val (libA, libB) = splitFixtureJars(version, "runtime") + val patched = (testWorkspace / "jars" / version / "runtime" / "lib-a-patched.jar").toNIO + + JarProcessor.process(libA, patched, hierarchyClasspath = Seq(libB)) + + val urls = Seq(patched.toUri.toURL, libB.toUri.toURL) ++ scalaRuntimeUrls(version) + val loader = new URLClassLoader(urls.toArray, ClassLoader.getPlatformClassLoader) + try + val holder = Class.forName("slothtest.crossjar.CrossJarHolder$", true, loader) + val module = holder.getField("MODULE$").get(null) + assertEquals(holder.getMethod("picked").invoke(module), "A") + val chosen = holder.getMethod("choose").invoke(module) + assertEquals(chosen.getClass.getMethod("label").invoke(chosen), "A") + finally loader.close() + } + + test(s"missing hierarchy context is reported instead of silently widening (Scala $version)") { + val (libA, _) = splitFixtureJars(version, "strict") + val patched = (testWorkspace / "jars" / version / "strict" / "lib-a-patched.jar").toNIO + + val result = JarProcessor.process(libA, patched, strictHierarchy = true) + assert(result.errors.nonEmpty, "An unresolvable hierarchy must be reported, not silently widened") + assert( + result.errors.exists(_.contains("slothtest/crossjar/HierSub")), + s"The report should name the unresolvable type, got ${result.errors}" + ) + } + + /** A loader that can serve no `.class` resource at all, like a GraalVM native image. */ + class ResourceLessClassLoader extends ClassLoader(null) { + override def getResourceAsStream(name: String): InputStream = null + } + + test("JDK types merge precisely even when no .class resource is readable") { + val blind = ClassHierarchySource.forClassLoader(new ResourceLessClassLoader) + assertEquals(blind.classBytes("java/util/zip/ZipFile"), None, "Precondition: no .class resources") + + val writer = new ClassLoaderClassWriter(ClassHierarchySource.chain(blind, ClassHierarchySource.jdkStubs)) + assertEquals(writer.getCommonSuperClass("java/util/jar/JarFile", "java/util/zip/ZipFile"), "java/util/zip/ZipFile") + assertEquals(writer.getCommonSuperClass("java/lang/Integer", "java/lang/Long"), "java/lang/Number") + } + + test("jdkStubs serves JarFile / JarOutputStream without Class.forName") { + // Class literals + hardcoded fallbacks must answer even when dynamic Class.forName would + // fail (as it does under GraalVM native image without reflection metadata). + assert(ClassHierarchySource.jdkStubs.classBytes("java/util/jar/JarFile").isDefined) + assert(ClassHierarchySource.jdkStubs.classBytes("java/util/jar/JarOutputStream").isDefined) + assert(ClassHierarchySource.jdkStubs.classBytes("java/util/zip/ZipFile").isDefined) + val writer = new ClassLoaderClassWriter(ClassHierarchySource.jdkStubs) + assertEquals( + writer.getCommonSuperClass("java/util/jar/JarFile", "java/util/zip/ZipFile"), + "java/util/zip/ZipFile" + ) + } + + test("unresolvable types raise instead of degrading to Object in strict mode") { + val strict = ClassHierarchySource.strict(ClassHierarchySource.chain()) + val writer = new ClassLoaderClassWriter(strict) + val failure = intercept[ClassHierarchyException] { + writer.getCommonSuperClass("some/unknown/SubA", "some/unknown/SubB") + } + assertEquals(failure.internalName, "some/unknown/SubA") + + val lenient = new ClassLoaderClassWriter(ClassHierarchySource.chain()) + assertEquals( + lenient.getCommonSuperClass("some/unknown/SubA", "some/unknown/SubB"), + "java/lang/Object", + "Non-strict resolution still degrades, but logs a warning" + ) + } + + test("Object stays the answer when it genuinely is the common supertype") { + val writer = new ClassLoaderClassWriter(ClassHierarchySource.jdkStubs) + assertEquals(writer.getCommonSuperClass("java/lang/Integer", "java/lang/Thread"), "java/lang/Object") + } +} diff --git a/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/CrossJarHierarchy.scala b/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/CrossJarHierarchy.scala new file mode 100644 index 0000000..c7246d4 --- /dev/null +++ b/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/CrossJarHierarchy.scala @@ -0,0 +1,27 @@ +package slothtest.crossjar + +abstract class HierBase: + def label: String + +final class HierSubA extends HierBase: + def label: String = "A" + +final class HierSubB extends HierBase: + def label: String = "B" + +object CrossJarHolder: + + // Opaque to the compiler, so the branch below survives as a real stack-map merge point + // instead of being folded away. + private def preferA: Boolean = System.nanoTime() > 0L + + def choose(): HierBase = + val chosen: HierBase = if preferA then new HierSubA else new HierSubB + chosen + + lazy val picked: String = + val chosen: HierBase = if preferA then new HierSubA else new HierSubB + chosen.label + +@main def main(): Unit = + println(s"${CrossJarHolder.choose().label}${CrossJarHolder.picked}") diff --git a/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/metadata.json b/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/metadata.json new file mode 100644 index 0000000..5414b2a --- /dev/null +++ b/tests/src/test/resources/fixtures/examples/cross-jar-hierarchy/metadata.json @@ -0,0 +1,16 @@ +{ + "description": "Lazy val holder that merges two sibling subclasses into their common base — the merge only types correctly if the class hierarchy is resolvable during frame computation (VirtusLab/sloth#1)", + "mainClassName": "slothtest.crossjar.main", + "expectedClasses": [ + { + "className": "CrossJarHolder$", + "lazyVals": [ + { + "name": "picked", + "index": 1 + } + ] + } + ], + "expectedOutput": "AA" +}