Skip to content

Commit

Permalink
feat: dependency minimizer
Browse files Browse the repository at this point in the history
  • Loading branch information
terminalsin committed Dec 12, 2024
1 parent c755628 commit bb1ba98
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 0 deletions.
22 changes: 22 additions & 0 deletions dev.skidfuscator.obfuscator.dependanalysis/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
plugins {
id 'java'
}

group = 'dev.skidfuscator.community'
version = '2.0.0-SNAPSHOT'

repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}

dependencies {
api project(':modasm')

testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package dev.skidfuscator.dependanalysis;

import dev.skidfuscator.dependanalysis.visitor.HierarchyVisitor;
import org.objectweb.asm.ClassReader;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

/**
* Due to the nature of the Java Virtual Machine and the wealth of tools offered by OW2 ASM, we can
* analyze the hierarchy of classes within a given JAR and selectively load only the minimal set of
* dependencies required. By parsing the main JAR’s class definitions and walking up its hierarchy
* chain, we identify which subset of external JARs is truly needed.
*
* This class orchestrates the resolution by:
* - Indexing the classes found in a directory of library JARs.
* - Parsing the main JAR’s classes and discovering their superclasses and implemented interfaces.
* - Recursively climbing the class hierarchy to find any library JAR that must be included.
*/
public class DependencyAnalyzer {
private final Path mainJar;
private final Path librariesDir;

// Maps className to the jar that hosts it (for library jars)
private final Map<String, Path> classToLibraryMap = new HashMap<>();
// Cache of previously computed hierarchies to avoid re-analysis
private final Map<String, DependencyClassHierarchy> classHierarchyCache = new HashMap<>();

public DependencyAnalyzer(Path mainJar, Path librariesDir) {
this.mainJar = mainJar;
this.librariesDir = librariesDir;
}

/**
* Analyze the main jar’s classes, build their hierarchies, and return
* the minimal set of library jars required to resolve the entire chain.
*/
public Set<Path> analyze() throws IOException {
// Step 1: Index library jars
indexLibraries();

// Step 2: Get all classes from main jar
Set<String> mainClasses = loadClassesFromJar(mainJar);

// Step 3: Resolve hierarchical dependencies
Set<Path> requiredJars = new HashSet<>();
for (String cls : mainClasses) {
resolveHierarchy(cls, requiredJars, mainJar, new HashSet<>());
}
return requiredJars;
}

/**
* Recursively resolves the hierarchy of a given class, adding necessary jars as discovered.
*/
private void resolveHierarchy(String className, Set<Path> requiredJars, Path sourceJar, Set<String> visited) throws IOException {
if (visited.contains(className)) return;
visited.add(className);

DependencyClassHierarchy hierarchy = loadClassHierarchy(className, sourceJar);

// If we found a class from a library jar
if (!hierarchy.isMainJarClass && hierarchy.sourceJar != null) {
requiredJars.add(hierarchy.sourceJar);
}

// Resolve superclass
if (hierarchy.superName != null && !hierarchy.superName.isEmpty()) {
Path jarForSuper = hierarchy.isMainJarClass ? mainJar : classToLibraryMap.get(hierarchy.superName);
if (jarForSuper == null && hierarchy.superName != null) {
jarForSuper = classToLibraryMap.get(hierarchy.superName);
}
if (jarForSuper != null) {
resolveHierarchy(hierarchy.superName, requiredJars, jarForSuper, visited);
}
}

// Resolve interfaces
for (String iface : hierarchy.interfaces) {
Path jarForIface = hierarchy.isMainJarClass ? mainJar : classToLibraryMap.get(iface);
if (jarForIface == null && iface != null) {
jarForIface = classToLibraryMap.get(iface);
}
if (jarForIface != null) {
resolveHierarchy(iface, requiredJars, jarForIface, visited);
}
}
}

/**
* Load the class hierarchy for a given class. If cached, return the cache.
* Otherwise, parse from either the main jar or a known library jar.
*/
private DependencyClassHierarchy loadClassHierarchy(String className, Path presumedJar) throws IOException {
if (classHierarchyCache.containsKey(className)) {
return classHierarchyCache.get(className);
}

boolean fromMainJar = false;
InputStream classStream = getClassStream(mainJar, className);
Path jarSource = null;
if (classStream != null) {
fromMainJar = true;
jarSource = mainJar;
} else {
Path libJar = classToLibraryMap.get(className);
if (libJar == null) {
// Not found in known jars
DependencyClassHierarchy notFound = new DependencyClassHierarchy(className, null, new String[0], true, null);
classHierarchyCache.put(className, notFound);
return notFound;
}
classStream = getClassStream(libJar, className);
jarSource = libJar;
}

if (classStream == null) {
DependencyClassHierarchy notFound = new DependencyClassHierarchy(className, null, new String[0], true, null);
classHierarchyCache.put(className, notFound);
return notFound;
}

ClassReader cr = new ClassReader(classStream);
HierarchyVisitor visitor = new HierarchyVisitor();
cr.accept(visitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES | ClassReader.SKIP_CODE);

DependencyClassHierarchy hierarchy = new DependencyClassHierarchy(
className,
visitor.superName,
visitor.interfaces,
fromMainJar,
fromMainJar ? null : jarSource
);
classHierarchyCache.put(className, hierarchy);
return hierarchy;
}

/**
* Index all library jars found in librariesDir by their contained classes.
*/
private void indexLibraries() throws IOException {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(librariesDir, "*.jar")) {
for (Path jar : stream) {
try (JarFile jarFile = new JarFile(jar.toFile())) {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (!entry.isDirectory() && entry.getName().endsWith(".class")) {
String className = entry.getName().replace('/', '.').replace(".class", "");
classToLibraryMap.put(className, jar);
}
}
}
}
}
}

/**
* Load all classes from a given jar.
*/
private Set<String> loadClassesFromJar(Path jarPath) throws IOException {
Set<String> classes = new HashSet<>();
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (!entry.isDirectory() && entry.getName().endsWith(".class")) {
String className = entry.getName().replace('/', '.').replace(".class", "");
classes.add(className);
}
}
}
return classes;
}

/**
* Retrieve an InputStream for a specified class from a given jar.
*/
private InputStream getClassStream(Path jar, String className) throws IOException {
// Need a fresh stream for each read attempt
JarFile jf = new JarFile(jar.toFile());
String path = className.replace('.', '/') + ".class";
JarEntry entry = jf.getJarEntry(path);
if (entry == null) {
jf.close();
return null;
}
return new ClosableInputStreamWrapper(jf, jf.getInputStream(entry));
}

/**
* A wrapper that closes the JarFile once the InputStream is closed.
*/
private static class ClosableInputStreamWrapper extends InputStream {
private final JarFile jarFile;
private final InputStream delegate;

public ClosableInputStreamWrapper(JarFile jarFile, InputStream delegate) {
this.jarFile = jarFile;
this.delegate = delegate;
}

@Override
public int read() throws IOException {
return delegate.read();
}

@Override
public void close() throws IOException {
delegate.close();
jarFile.close();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dev.skidfuscator.dependanalysis;

import java.nio.file.Path;

/**
* A simple data structure holding the hierarchy information of a single class:
* - The class name
* - Its immediate superclass name
* - Any interfaces it implements
* - Whether it was found in the main jar or a library jar
* - The source jar path (if from a library)
*/
public class DependencyClassHierarchy {
public final String className;
public final String superName;
public final String[] interfaces;
public final boolean isMainJarClass;
public final Path sourceJar;

public DependencyClassHierarchy(String className, String superName, String[] interfaces, boolean isMainJarClass, Path sourceJar) {
this.className = className;
this.superName = superName;
this.interfaces = interfaces;
this.isMainJarClass = isMainJarClass;
this.sourceJar = sourceJar;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.skidfuscator.dependanalysis;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;

/**
* A simple entry point for demonstration purposes. Provide:
* java com.example.dependencyanalyzer.Main <main.jar> <lib_folder>
*
* For instance:
* java com.example.dependencyanalyzer.Main my-app.jar libs/
*/
public class Main {
public static void main(String[] args) throws Exception {
if (args.length < 2) {
System.err.println("Usage: java com.example.dependencyanalyzer.Main <main.jar> <lib_folder>");
System.exit(1);
}

Path mainJar = Paths.get(args[0]);
Path libs = Paths.get(args[1]);

DependencyAnalyzer analyzer = new DependencyAnalyzer(mainJar, libs);
Set<Path> requiredJars = analyzer.analyze();

System.out.println("Required jars:");
for (Path jar : requiredJars) {
System.out.println(" - " + jar);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.skidfuscator.dependanalysis.visitor;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;

/**
* A ClassVisitor used to extract the superclass and interface names from a given class.
* It normalizes slashes in class names to dot notation.
*/
public class HierarchyVisitor extends ClassVisitor {
public String superName;
public String[] interfaces = new String[0];

public HierarchyVisitor() {
super(Opcodes.ASM9);
}

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
if (superName != null) {
this.superName = superName.replace('/', '.');
}
if (interfaces != null) {
String[] replaced = new String[interfaces.length];
for (int i = 0; i < interfaces.length; i++) {
replaced[i] = interfaces[i].replace('/', '.');
}
this.interfaces = replaced;
}
super.visit(version, access, name, signature, superName, interfaces);
}
}
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ void initBase() {
include(':annotations')
include ':commons'
include ':pure-analysis'
include ':depend-analysis'
include ':obfuscator'
include ':client-standalone'
include ':gradle-plugin'

project(":annotations").projectDir = file('dev.skidfuscator.annotations')
project(":commons").projectDir = file('dev.skidfuscator.commons')
project(":pure-analysis").projectDir = file('dev.skidfuscator.obfuscator.pureanalysis')
project(":depend-analysis").projectDir = file('dev.skidfuscator.obfuscator.dependanalysis')
project(":obfuscator").projectDir = file('dev.skidfuscator.obfuscator')
project(":client-standalone").projectDir = file('dev.skidfuscator.client.standalone')
project(":gradle-plugin").projectDir = file('dev.skidfuscator.gradle-plugin')
Expand Down

0 comments on commit bb1ba98

Please sign in to comment.