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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ project/.sbtboot/
.bsp/
.bloop/
.cursor/
.scala-build/
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <directory>

# Patch a JAR that references types living in the rest of the classpath
java -jar cli/target/scala-3.8.1/sloth.jar <file.jar> --hierarchy-classpath <cp>
```

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
Expand Down
5 changes: 4 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down
44 changes: 28 additions & 16 deletions cli/src/main/scala/sloth/cli/Main.scala
Original file line number Diff line number Diff line change
@@ -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}

Expand Down Expand Up @@ -33,14 +33,23 @@ object Main {
def successful: Boolean = failed == 0
}

private val usage = "Usage: sloth <directory|file.jar> [--hierarchy-classpath <cp>]"

/** 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 <directory|file.jar>")
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"))
Expand All @@ -49,7 +58,7 @@ object Main {

// Dispatch to JAR mode or directory mode
if (targetPath.ext == "jar") {
processJar(targetPath)
processJar(targetPath, hierarchyClasspath)
return
}

Expand Down Expand Up @@ -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()
Expand All @@ -114,15 +126,15 @@ 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()

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:"))
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 32 additions & 8 deletions core/src/main/scala/sloth/jar/JarProcessor.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]()
Expand All @@ -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)

Expand All @@ -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)
Expand Down
Loading
Loading