diff options
Diffstat (limited to 'src/main')
-rw-r--r-- | src/main/java/bscv/asm/BoSCoVicinoLoader.java | 124 | ||||
-rw-r--r-- | src/main/java/bscv/asm/api/annotations/Patch.java | 2 | ||||
-rw-r--r-- | src/main/java/bscv/asm/patches/TestPatch.java | 30 | ||||
-rw-r--r-- | src/main/java/bscv/asm/util/ClassPath22.java | 670 |
4 files changed, 793 insertions, 33 deletions
diff --git a/src/main/java/bscv/asm/BoSCoVicinoLoader.java b/src/main/java/bscv/asm/BoSCoVicinoLoader.java index 77d5f6e..15a2bbf 100644 --- a/src/main/java/bscv/asm/BoSCoVicinoLoader.java +++ b/src/main/java/bscv/asm/BoSCoVicinoLoader.java @@ -1,27 +1,33 @@ package bscv.asm; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.*; -import java.util.stream.Collectors; - import bscv.asm.api.AnnotationChecker; +import bscv.asm.util.ClassPath22; import bscv.asm.api.annotations.Inject; import bscv.asm.api.annotations.Patch; import com.google.common.collect.HashMultimap; +import cpw.mods.modlauncher.api.INameMappingService; +import cpw.mods.modlauncher.serviceapi.ILaunchPluginService; +import net.minecraftforge.fml.common.ObfuscationReflectionHelper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.objectweb.asm.ClassReader; import org.objectweb.asm.Type; - -import cpw.mods.modlauncher.serviceapi.ILaunchPluginService; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; +import javax.annotation.Nullable; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + public class BoSCoVicinoLoader implements ILaunchPluginService { - public static final String NAME = "boscovicino_asm"; //temp name + public static final String NAME = "boscovicino_asm"; //TODO: temp name public static final Logger LOGGER = LogManager.getLogger("BoSCoVicinoASM"); private static final EnumSet<Phase> YAY = EnumSet.of(Phase.BEFORE); @@ -30,6 +36,7 @@ public class BoSCoVicinoLoader implements ILaunchPluginService { private static final HashMultimap<String, Class<?>> patchClasses = HashMultimap.create(); public BoSCoVicinoLoader() { + loadPatchesPozzed(); LOGGER.info("BoSCoVicinoLoader instantiated successfully!"); } @@ -40,9 +47,6 @@ public class BoSCoVicinoLoader implements ILaunchPluginService { @Override public EnumSet<Phase> handlesClass(Type classType, final boolean isEmpty) { - //if(!isEmpty && shouldHandle(classType)) - // LOGGER.info("CLAZZ >>> {}", classType.getClassName()); - //return NAY; throw new IllegalStateException("Outdated ModLauncher"); //mixin does it } @@ -50,99 +54,155 @@ public class BoSCoVicinoLoader implements ILaunchPluginService { public EnumSet<Phase> handlesClass(Type classType, final boolean isEmpty, final String reason) { if(isEmpty) return NAY; String name = classType.getClassName(); - if(name.startsWith("net.minecraft.") || name.indexOf('.') == -1) + if(name.startsWith("net.minecraft.") || name.indexOf('.') == -1) { + LOGGER.debug("Marked {} as to-be-handled", classType.getClassName()); return YAY; + } else return NAY; + /* TODO: either bring back or delete this (probably the latter) else { try { if(hasAnnotation(name)) { Class<?> patch = Class.forName(name); - patchClasses.put(Type.getDescriptor(patch.getAnnotation(Patch.class).value()), patch); - return YAY; + //patchClasses.put(patch.getAnnotation(Patch.class).value(), patch); + //LOGGER.info("Found patch class {}", patch.getName()); } } catch(IOException | ClassNotFoundException e) { LOGGER.debug("Could not load {}", name); } return NAY; - } + } */ } @Override public int processClassWithFlags(Phase phase, ClassNode classNode, Type classType, String reason) { - if(patchClasses.containsKey(classType.getDescriptor())) { + LOGGER.debug("Processing class {} in phase {} of {}", classType.getClassName(), phase.name(), reason); + if(patchClasses.containsKey(classType.getClassName())) { + LOGGER.info("Found class with descriptor {} among the patches", classType.getClassName()); //can have multiple patches working on same class //each of these classes may attempt to patch a method one or more times Set<String> classMethodNames = classNode.methods .stream() - .map(m -> m.name) + .map(m -> deobfuscator(m.name)) .collect(Collectors.toSet()); Set<String> classMethodDescriptors = classNode.methods .stream() .map(m -> m.desc) .collect(Collectors.toSet()); - Set<Class<?>> relevantPatches = patchClasses.get(classType.getDescriptor()); HashMultimap<Object, Method> patches = HashMultimap.create(); - relevantPatches + patchClasses + .get(classType.getClassName()) .forEach(p -> { Set<Method> injectors = getInjectorsInPatch(p) .stream() .filter(m -> { Inject a = m.getAnnotation(Inject.class); - return - classMethodNames.contains(a.methodName()) + LOGGER.info("Found {} injector for method {}{}", m.getName(), a.methodName(), descriptorBuilder(a.returnType(), a.parameters())); + return classMethodNames.contains(a.methodName()) && classMethodDescriptors.contains(descriptorBuilder(a.returnType(), a.parameters())); }) .collect(Collectors.toSet()); if(!injectors.isEmpty()) { try { patches.putAll(p.newInstance(), injectors); + StringBuilder sb = new StringBuilder(); + for(Method i : injectors) + sb.append(i.getName()).append(","); + LOGGER.info("Stored patch {} with injectors {}", p, sb.toString()); } catch(InstantiationException | IllegalAccessException e) { - throw new RuntimeException(e); //todo: better catch + LOGGER.error("Something went wrong while instantiating patch {}", p); + throw new RuntimeException(e); //todo: better error handling } } }); - if(patches.isEmpty()) + if(patches.isEmpty()) { + LOGGER.info("No valid patches found for {}!", classNode.name.replace('/', '.')); return ComputeFlags.NO_REWRITE; - else { + } else { boolean success = false; for(Object p : patches.keys()) for(Method m : patches.get(p)) - success = success || processInjector(classNode, p, m); + success = processInjector(classNode, p, m) || success; + LOGGER.info("Altered class {}", classNode.name); return success ? ComputeFlags.COMPUTE_FRAMES : ComputeFlags.NO_REWRITE; //todo: is this the right flag? } } else return ComputeFlags.NO_REWRITE; } - private boolean hasAnnotation(String name) throws IOException { + /* TODO: Get fucking rid of this! + * This method is pure evil. It scans the JAR and forcefully loads the patches. + * Sure, it works, but it makes it impossible to split Patch Framework and client. It's + * also very slow. + * Cherry on top: ClassPath in Guava 21 is broken. My solution was to shamelessly paste + * the version from Guava 22 as ClassPath22. License-wise there shouldn't be any problem, + * but I have no intention of keeping it. It's only used here, and this method needs to + * go as soon as humanly possible. Don't worry, I'm not bloating your precious codebase. + * P.S.: Yes, that's why your shot at dynloading Modules didn't work. + **/ + private static void loadPatchesPozzed() { + try { + ClassPath22.from(BoSCoVicinoLoader.class.getClassLoader()) + .getAllClasses() + .stream() + .filter(ci -> { + try { + return hasAnnotation(ci.getName()); + } catch(IOException e) { + throw new RuntimeException(e); + } + }) + .map(ClassPath22.ClassInfo::load) + .forEach(patch -> { + patchClasses.put(patch.getAnnotation(Patch.class).value(), patch); + LOGGER.info("Found patch class {}", patch.getName()); + }); + } catch(IOException e) { + e.printStackTrace(); + } + } + + private static boolean hasAnnotation(String name) throws IOException { ClassReader cr = new ClassReader(name); AnnotationChecker ac = new AnnotationChecker(Patch.class); cr.accept(ac, 0); return ac.isAnnotationPresent(); } - private Set<Method> getInjectorsInPatch(Class<?> patch) { + private static Set<Method> getInjectorsInPatch(Class<?> patch) { return Arrays.stream(patch.getDeclaredMethods()) .filter(m -> m.isAnnotationPresent(Inject.class)) .collect(Collectors.toSet()); } - public static String descriptorBuilder(Class<?> returnType, Class<?>[] parameters) { + public static String descriptorBuilder(@Nullable Class<?> returnType, Class<?> ... parameters) { StringBuilder desc = new StringBuilder("("); for(Class<?> p : parameters) desc.append(Type.getDescriptor(p)); desc.append(")"); - desc.append(Type.getDescriptor(returnType)); + if(returnType != null) + desc.append(Type.getDescriptor(returnType)); return desc.toString(); } - private boolean processInjector(ClassNode target, Object patch, Method injector) { + public static String descriptorBuilder(Class<?>[] parameters) { + return descriptorBuilder(null, parameters); + } + + private static String deobfuscator(String srg) { + /* FIXME: this will only work in client env (runClient task) + Find a different way to deobf searge names or try to circumvent + this step altogether (serve already deobfed names?) */ + return ObfuscationReflectionHelper.remapName(INameMappingService.Domain.METHOD, srg); + } + + private static boolean processInjector(ClassNode target, Object patch, Method injector) { //get relevant method from target Optional<MethodNode> targetMethod = target.methods .stream() .filter(m -> { Inject a = injector.getAnnotation(Inject.class); - return m.name.equals(a.methodName()) && m.desc.equals(descriptorBuilder(a.returnType(), a.parameters())); + return m.name.equals(a.methodName()) && m.desc.startsWith(descriptorBuilder(a.parameters())); }) .findAny(); //there can literally only be one so this is safe and more efficient than findFirst diff --git a/src/main/java/bscv/asm/api/annotations/Patch.java b/src/main/java/bscv/asm/api/annotations/Patch.java index 4685845..2b6963f 100644 --- a/src/main/java/bscv/asm/api/annotations/Patch.java +++ b/src/main/java/bscv/asm/api/annotations/Patch.java @@ -8,5 +8,5 @@ import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Patch { - Class<?> value(); + String value(); } diff --git a/src/main/java/bscv/asm/patches/TestPatch.java b/src/main/java/bscv/asm/patches/TestPatch.java new file mode 100644 index 0000000..b1454ab --- /dev/null +++ b/src/main/java/bscv/asm/patches/TestPatch.java @@ -0,0 +1,30 @@ +package bscv.asm.patches; + +import bscv.asm.api.annotations.Inject; +import bscv.asm.api.annotations.Patch; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.MethodNode; + +/** + * When working as intended, this patch will crash the game + * as soon it finished loading with a NegativeArraySizeException. + */ +@Patch("net.minecraft.client.Minecraft") +public class TestPatch implements Opcodes { + + @Inject(methodName = "tick", returnType = void.class, parameters = {}, description = "Test injection!") + public void inject(MethodNode main) { + InsnList insnList = new InsnList(); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + insnList.add(new InsnNode(POP)); + main.instructions.insert(insnList); + } +} diff --git a/src/main/java/bscv/asm/util/ClassPath22.java b/src/main/java/bscv/asm/util/ClassPath22.java new file mode 100644 index 0000000..0edc825 --- /dev/null +++ b/src/main/java/bscv/asm/util/ClassPath22.java @@ -0,0 +1,670 @@ +package bscv.asm.util; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.StandardSystemProperty.JAVA_CLASS_PATH; +import static com.google.common.base.StandardSystemProperty.PATH_SEPARATOR; +import static java.util.logging.Level.WARNING; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; +import com.google.common.base.Splitter; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.google.common.io.ByteSource; +import com.google.common.io.CharSource; +import com.google.common.io.Resources; +import com.google.common.reflect.Reflection; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.Charset; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.logging.Logger; +import javax.annotation.CheckForNull; + +/** + * Scans the source of a {@link ClassLoader} and finds all loadable classes and resources. + * + * <h2>Prefer <a href="https://github.com/classgraph/classgraph/wiki">ClassGraph</a> over {@code + * ClassPath22}</h2> + * + * <p>We recommend using <a href="https://github.com/classgraph/classgraph/wiki">ClassGraph</a> + * instead of {@code ClassPath22}. ClassGraph improves upon {@code ClassPath22} in several ways, + * including addressing many of its limitations. Limitations of {@code ClassPath22} include: + * + * <ul> + * <li>It looks only for files and JARs in URLs available from {@link URLClassLoader} instances or + * the {@linkplain ClassLoader#getSystemClassLoader() system class loader}. This means it does + * not look for classes in the <i>module path</i>. + * <li>It understands only {@code file:} URLs. This means that it does not understand <a + * href="https://openjdk.java.net/jeps/220">{@code jrt:/} URLs</a>, among <a + * href="https://github.com/classgraph/classgraph/wiki/Classpath-specification-mechanisms">others</a>. + * <li>It does not know how to look for classes when running under an Android VM. (ClassGraph does + * not support this directly, either, but ClassGraph documents how to <a + * href="https://github.com/classgraph/classgraph/wiki/Build-Time-Scanning">perform build-time + * classpath scanning and make the results available to an Android app</a>.) + * <li>Like all of Guava, it is not tested under Windows. We have gotten <a + * href="https://github.com/google/guava/issues/2130">a report of a specific bug under + * Windows</a>. + * <li>It <a href="https://github.com/google/guava/issues/2712">returns only one resource for a + * given path</a>, even if resources with that path appear in multiple jars or directories. + * <li>It assumes that <a href="https://github.com/google/guava/issues/3349">any class with a + * {@code $} in its name is a nested class</a>. + * </ul> + * + * <h2>{@code ClassPath22} and symlinks</h2> + * + * <p>In the case of directory classloaders, symlinks are supported but cycles are not traversed. + * This guarantees discovery of each <em>unique</em> loadable resource. However, not all possible + * aliases for resources on cyclic paths will be listed. + * + * @author Ben Yu + * @since 14.0 + */ +public final class ClassPath22 { + private static final Logger logger = Logger.getLogger(ClassPath22.class.getName()); + + /** Separator for the Class-Path manifest attribute value in jar files. */ + private static final Splitter CLASS_PATH_ATTRIBUTE_SEPARATOR = + Splitter.on(" ").omitEmptyStrings(); + + private static final String CLASS_FILE_NAME_EXTENSION = ".class"; + + private final ImmutableSet<ResourceInfo> resources; + + private ClassPath22(ImmutableSet<ResourceInfo> resources) { + this.resources = resources; + } + + /** + * Returns a {@code ClassPath22} representing all classes and resources loadable from {@code + * classloader} and its ancestor class loaders. + * + * <p><b>Warning:</b> {@code ClassPath22} can find classes and resources only from: + * + * <ul> + * <li>{@link URLClassLoader} instances' {@code file:} URLs + * <li>the {@linkplain ClassLoader#getSystemClassLoader() system class loader}. To search the + * system class loader even when it is not a {@link URLClassLoader} (as in Java 9), {@code + * ClassPath22} searches the files from the {@code java.class.path} system property. + * </ul> + * + * @throws IOException if the attempt to read class path resources (jar files or directories) + * failed. + */ + public static ClassPath22 from(ClassLoader classloader) throws IOException { + ImmutableSet<LocationInfo> locations = locationsFrom(classloader); + + // Add all locations to the scanned set so that in a classpath [jar1, jar2], where jar1 has a + // manifest with Class-Path pointing to jar2, we won't scan jar2 twice. + Set<File> scanned = new HashSet<>(); + for (LocationInfo location : locations) { + scanned.add(location.file()); + } + + // Scan all locations + ImmutableSet.Builder<ResourceInfo> builder = ImmutableSet.builder(); + for (LocationInfo location : locations) { + builder.addAll(location.scanResources(scanned)); + } + return new ClassPath22(builder.build()); + } + + /** + * Returns all resources loadable from the current class path, including the class files of all + * loadable classes but excluding the "META-INF/MANIFEST.MF" file. + */ + public ImmutableSet<ResourceInfo> getResources() { + return resources; + } + + /** + * Returns all classes loadable from the current class path. + * + * @since 16.0 + */ + public ImmutableSet<ClassInfo> getAllClasses() { + return FluentIterable.from(resources).filter(ClassInfo.class).toSet(); + } + + /** + * Returns all top level classes loadable from the current class path. Note that "top-level-ness" + * is determined heuristically by class name (see {@link ClassInfo#isTopLevel}). + */ + public ImmutableSet<ClassInfo> getTopLevelClasses() { + return FluentIterable.from(resources) + .filter(ClassInfo.class) + .filter(ClassInfo::isTopLevel) + .toSet(); + } + + /** Returns all top level classes whose package name is {@code packageName}. */ + public ImmutableSet<ClassInfo> getTopLevelClasses(String packageName) { + checkNotNull(packageName); + ImmutableSet.Builder<ClassInfo> builder = ImmutableSet.builder(); + for (ClassInfo classInfo : getTopLevelClasses()) { + if (classInfo.getPackageName().equals(packageName)) { + builder.add(classInfo); + } + } + return builder.build(); + } + + /** + * Returns all top level classes whose package name is {@code packageName} or starts with {@code + * packageName} followed by a '.'. + */ + public ImmutableSet<ClassInfo> getTopLevelClassesRecursive(String packageName) { + checkNotNull(packageName); + String packagePrefix = packageName + '.'; + ImmutableSet.Builder<ClassInfo> builder = ImmutableSet.builder(); + for (ClassInfo classInfo : getTopLevelClasses()) { + if (classInfo.getName().startsWith(packagePrefix)) { + builder.add(classInfo); + } + } + return builder.build(); + } + + /** + * Represents a class path resource that can be either a class file or any other resource file + * loadable from the class path. + * + * @since 14.0 + */ + public static class ResourceInfo { + private final File file; + private final String resourceName; + + final ClassLoader loader; + + static ResourceInfo of(File file, String resourceName, ClassLoader loader) { + if (resourceName.endsWith(CLASS_FILE_NAME_EXTENSION)) { + return new ClassInfo(file, resourceName, loader); + } else { + return new ResourceInfo(file, resourceName, loader); + } + } + + ResourceInfo(File file, String resourceName, ClassLoader loader) { + this.file = checkNotNull(file); + this.resourceName = checkNotNull(resourceName); + this.loader = checkNotNull(loader); + } + + /** + * Returns the url identifying the resource. + * + * <p>See {@link ClassLoader#getResource} + * + * @throws NoSuchElementException if the resource cannot be loaded through the class loader, + * despite physically existing in the class path. + */ + public final URL url() { + URL url = loader.getResource(resourceName); + if (url == null) { + throw new NoSuchElementException(resourceName); + } + return url; + } + + /** + * Returns a {@link ByteSource} view of the resource from which its bytes can be read. + * + * @throws NoSuchElementException if the resource cannot be loaded through the class loader, + * despite physically existing in the class path. + * @since 20.0 + */ + public final ByteSource asByteSource() { + return Resources.asByteSource(url()); + } + + /** + * Returns a {@link CharSource} view of the resource from which its bytes can be read as + * characters decoded with the given {@code charset}. + * + * @throws NoSuchElementException if the resource cannot be loaded through the class loader, + * despite physically existing in the class path. + * @since 20.0 + */ + public final CharSource asCharSource(Charset charset) { + return Resources.asCharSource(url(), charset); + } + + /** Returns the fully qualified name of the resource. Such as "com/mycomp/foo/bar.txt". */ + public final String getResourceName() { + return resourceName; + } + + /** Returns the file that includes this resource. */ + final File getFile() { + return file; + } + + @Override + public int hashCode() { + return resourceName.hashCode(); + } + + @Override + public boolean equals(@CheckForNull Object obj) { + if (obj instanceof ResourceInfo) { + ResourceInfo that = (ResourceInfo) obj; + return resourceName.equals(that.resourceName) && loader == that.loader; + } + return false; + } + + // Do not change this arbitrarily. We rely on it for sorting ResourceInfo. + @Override + public String toString() { + return resourceName; + } + } + + /** + * Represents a class that can be loaded through {@link #load}. + * + * @since 14.0 + */ + public static final class ClassInfo extends ResourceInfo { + private final String className; + + ClassInfo(File file, String resourceName, ClassLoader loader) { + super(file, resourceName, loader); + this.className = getClassName(resourceName); + } + + /** + * Returns the package name of the class, without attempting to load the class. + * + * <p>Behaves similarly to {@code class.getPackage().}{@link Package#getName() getName()} but + * does not require the class (or package) to be loaded. + * + * <p>But note that this method may behave differently for a class in the default package: For + * such classes, this method always returns an empty string. But under some version of Java, + * {@code class.getPackage().getName()} produces a {@code NullPointerException} because {@code + * class.getPackage()} returns {@code null}. + */ + public String getPackageName() { + return Reflection.getPackageName(className); + } + + /** + * Returns the simple name of the underlying class as given in the source code. + * + * <p>Behaves similarly to {@link Class#getSimpleName()} but does not require the class to be + * loaded. + * + * <p>But note that this class uses heuristics to identify the simple name. See a related + * discussion in <a href="https://github.com/google/guava/issues/3349">issue 3349</a>. + */ + public String getSimpleName() { + int lastDollarSign = className.lastIndexOf('$'); + if (lastDollarSign != -1) { + String innerClassName = className.substring(lastDollarSign + 1); + // local and anonymous classes are prefixed with number (1,2,3...), anonymous classes are + // entirely numeric whereas local classes have the user supplied name as a suffix + return CharMatcher.inRange('0', '9').trimLeadingFrom(innerClassName); + } + String packageName = getPackageName(); + if (packageName.isEmpty()) { + return className; + } + + // Since this is a top level class, its simple name is always the part after package name. + return className.substring(packageName.length() + 1); + } + + /** + * Returns the fully qualified name of the class. + * + * <p>Behaves identically to {@link Class#getName()} but does not require the class to be + * loaded. + */ + public String getName() { + return className; + } + + /** + * Returns true if the class name "looks to be" top level (not nested), that is, it includes no + * '$' in the name. This method may return false for a top-level class that's intentionally + * named with the '$' character. If this is a concern, you could use {@link #load} and then + * check on the loaded {@link Class} object instead. + * + * @since 30.1 + */ + public boolean isTopLevel() { + return className.indexOf('$') == -1; + } + + /** + * Loads (but doesn't link or initialize) the class. + * + * @throws LinkageError when there were errors in loading classes that this class depends on. + * For example, {@link NoClassDefFoundError}. + */ + public Class<?> load() { + try { + return loader.loadClass(className); + } catch (ClassNotFoundException e) { + // Shouldn't happen, since the class name is read from the class path. + throw new IllegalStateException(e); + } + } + + @Override + public String toString() { + return className; + } + } + + /** + * Returns all locations that {@code classloader} and parent loaders load classes and resources + * from. Callers can {@linkplain LocationInfo#scanResources scan} individual locations selectively + * or even in parallel. + */ + static ImmutableSet<LocationInfo> locationsFrom(ClassLoader classloader) { + ImmutableSet.Builder<LocationInfo> builder = ImmutableSet.builder(); + for (Map.Entry<File, ClassLoader> entry : getClassPathEntries(classloader).entrySet()) { + builder.add(new LocationInfo(entry.getKey(), entry.getValue())); + } + return builder.build(); + } + + /** + * Represents a single location (a directory or a jar file) in the class path and is responsible + * for scanning resources from this location. + */ + static final class LocationInfo { + final File home; + private final ClassLoader classloader; + + LocationInfo(File home, ClassLoader classloader) { + this.home = checkNotNull(home); + this.classloader = checkNotNull(classloader); + } + + /** Returns the file this location is from. */ + public final File file() { + return home; + } + + /** Scans this location and returns all scanned resources. */ + public ImmutableSet<ResourceInfo> scanResources() throws IOException { + return scanResources(new HashSet<File>()); + } + + /** + * Scans this location and returns all scanned resources. + * + * <p>This file and jar files from "Class-Path" entry in the scanned manifest files will be + * added to {@code scannedFiles}. + * + * <p>A file will be scanned at most once even if specified multiple times by one or multiple + * jar files' "Class-Path" manifest entries. Particularly, if a jar file from the "Class-Path" + * manifest entry is already in {@code scannedFiles}, either because it was scanned earlier, or + * it was intentionally added to the set by the caller, it will not be scanned again. + * + * <p>Note that when you call {@code location.scanResources(scannedFiles)}, the location will + * always be scanned even if {@code scannedFiles} already contains it. + */ + public ImmutableSet<ResourceInfo> scanResources(Set<File> scannedFiles) throws IOException { + ImmutableSet.Builder<ResourceInfo> builder = ImmutableSet.builder(); + scannedFiles.add(home); + scan(home, scannedFiles, builder); + return builder.build(); + } + + private void scan(File file, Set<File> scannedUris, ImmutableSet.Builder<ResourceInfo> builder) + throws IOException { + try { + if (!file.exists()) { + return; + } + } catch (SecurityException e) { + logger.warning("Cannot access " + file + ": " + e); + // TODO(emcmanus): consider whether to log other failure cases too. + return; + } + if (file.isDirectory()) { + scanDirectory(file, builder); + } else { + scanJar(file, scannedUris, builder); + } + } + + private void scanJar( + File file, Set<File> scannedUris, ImmutableSet.Builder<ResourceInfo> builder) + throws IOException { + JarFile jarFile; + try { + jarFile = new JarFile(file); + } catch (IOException e) { + // Not a jar file + return; + } + try { + for (File path : getClassPathFromManifest(file, jarFile.getManifest())) { + // We only scan each file once independent of the classloader that file might be + // associated with. + if (scannedUris.add(path.getCanonicalFile())) { + scan(path, scannedUris, builder); + } + } + scanJarFile(jarFile, builder); + } finally { + try { + jarFile.close(); + } catch (IOException ignored) { // similar to try-with-resources, but don't fail scanning + } + } + } + + private void scanJarFile(JarFile file, ImmutableSet.Builder<ResourceInfo> builder) { + Enumeration<JarEntry> entries = file.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.isDirectory() || entry.getName().equals(JarFile.MANIFEST_NAME)) { + continue; + } + builder.add(ResourceInfo.of(new File(file.getName()), entry.getName(), classloader)); + } + } + + private void scanDirectory(File directory, ImmutableSet.Builder<ResourceInfo> builder) + throws IOException { + Set<File> currentPath = new HashSet<>(); + currentPath.add(directory.getCanonicalFile()); + scanDirectory(directory, "", currentPath, builder); + } + + /** + * Recursively scan the given directory, adding resources for each file encountered. Symlinks + * which have already been traversed in the current tree path will be skipped to eliminate + * cycles; otherwise symlinks are traversed. + * + * @param directory the root of the directory to scan + * @param packagePrefix resource path prefix inside {@code classloader} for any files found + * under {@code directory} + * @param currentPath canonical files already visited in the current directory tree path, for + * cycle elimination + */ + private void scanDirectory( + File directory, + String packagePrefix, + Set<File> currentPath, + ImmutableSet.Builder<ResourceInfo> builder) + throws IOException { + File[] files = directory.listFiles(); + if (files == null) { + logger.warning("Cannot read directory " + directory); + // IO error, just skip the directory + return; + } + for (File f : files) { + String name = f.getName(); + if (f.isDirectory()) { + File deref = f.getCanonicalFile(); + if (currentPath.add(deref)) { + scanDirectory(deref, packagePrefix + name + "/", currentPath, builder); + currentPath.remove(deref); + } + } else { + String resourceName = packagePrefix + name; + if (!resourceName.equals(JarFile.MANIFEST_NAME)) { + builder.add(ResourceInfo.of(f, resourceName, classloader)); + } + } + } + } + + @Override + public boolean equals(@CheckForNull Object obj) { + if (obj instanceof LocationInfo) { + LocationInfo that = (LocationInfo) obj; + return home.equals(that.home) && classloader.equals(that.classloader); + } + return false; + } + + @Override + public int hashCode() { + return home.hashCode(); + } + + @Override + public String toString() { + return home.toString(); + } + } + + /** + * Returns the class path URIs specified by the {@code Class-Path} manifest attribute, according + * to <a + * href="http://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Main_Attributes">JAR + * File Specification</a>. If {@code manifest} is null, it means the jar file has no manifest, and + * an empty set will be returned. + */ + @VisibleForTesting + static ImmutableSet<File> getClassPathFromManifest( + File jarFile, @CheckForNull Manifest manifest) { + if (manifest == null) { + return ImmutableSet.of(); + } + ImmutableSet.Builder<File> builder = ImmutableSet.builder(); + String classpathAttribute = + manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH.toString()); + if (classpathAttribute != null) { + for (String path : CLASS_PATH_ATTRIBUTE_SEPARATOR.split(classpathAttribute)) { + URL url; + try { + url = getClassPathEntry(jarFile, path); + } catch (MalformedURLException e) { + // Ignore bad entry + logger.warning("Invalid Class-Path entry: " + path); + continue; + } + if (url.getProtocol().equals("file")) { + builder.add(toFile(url)); + } + } + } + return builder.build(); + } + + @VisibleForTesting + static ImmutableMap<File, ClassLoader> getClassPathEntries(ClassLoader classloader) { + LinkedHashMap<File, ClassLoader> entries = Maps.newLinkedHashMap(); + // Search parent first, since it's the order ClassLoader#loadClass() uses. + ClassLoader parent = classloader.getParent(); + if (parent != null) { + entries.putAll(getClassPathEntries(parent)); + } + for (URL url : getClassLoaderUrls(classloader)) { + if (url.getProtocol().equals("file")) { + File file = toFile(url); + if (!entries.containsKey(file)) { + entries.put(file, classloader); + } + } + } + return ImmutableMap.copyOf(entries); + } + + private static ImmutableList<URL> getClassLoaderUrls(ClassLoader classloader) { + if (classloader instanceof URLClassLoader) { + return ImmutableList.copyOf(((URLClassLoader) classloader).getURLs()); + } + if (classloader.equals(ClassLoader.getSystemClassLoader())) { + return parseJavaClassPath(); + } + return ImmutableList.of(); + } + + /** + * Returns the URLs in the class path specified by the {@code java.class.path} {@linkplain + * System#getProperty system property}. + */ + @VisibleForTesting // TODO(b/65488446): Make this a public API. + static ImmutableList<URL> parseJavaClassPath() { + ImmutableList.Builder<URL> urls = ImmutableList.builder(); + for (String entry : Splitter.on(PATH_SEPARATOR.value()).split(JAVA_CLASS_PATH.value())) { + try { + try { + urls.add(new File(entry).toURI().toURL()); + } catch (SecurityException e) { // File.toURI checks to see if the file is a directory + urls.add(new URL("file", null, new File(entry).getAbsolutePath())); + } + } catch (MalformedURLException e) { + logger.log(WARNING, "malformed classpath entry: " + entry, e); + } + } + return urls.build(); + } + + /** + * Returns the absolute uri of the Class-Path entry value as specified in <a + * href="http://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Main_Attributes">JAR + * File Specification</a>. Even though the specification only talks about relative urls, absolute + * urls are actually supported too (for example, in Maven surefire plugin). + */ + @VisibleForTesting + static URL getClassPathEntry(File jarFile, String path) throws MalformedURLException { + return new URL(jarFile.toURI().toURL(), path); + } + + @VisibleForTesting + static String getClassName(String filename) { + int classNameEnd = filename.length() - CLASS_FILE_NAME_EXTENSION.length(); + return filename.substring(0, classNameEnd).replace('/', '.'); + } + + // TODO(benyu): Try java.nio.file.Paths#get() when Guava drops JDK 6 support. + @VisibleForTesting + static File toFile(URL url) { + checkArgument(url.getProtocol().equals("file")); + try { + return new File(url.toURI()); // Accepts escaped characters like %20. + } catch (URISyntaxException e) { // URL.toURI() doesn't escape chars. + return new File(url.getPath()); // Accepts non-escaped chars like space. + } + } +} |