diff --git a/base/src/main/java/proguard/InputReader.java b/base/src/main/java/proguard/InputReader.java index a9e0b351..6a8ad42e 100644 --- a/base/src/main/java/proguard/InputReader.java +++ b/base/src/main/java/proguard/InputReader.java @@ -22,6 +22,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; import proguard.classfile.kotlin.KotlinConstants; import proguard.classfile.util.*; import proguard.classfile.visitor.*; @@ -34,7 +35,11 @@ import proguard.util.*; import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; import java.util.List; +import java.util.Set; import static proguard.DataEntryReaderFactory.getFilterExcludingVersionedClasses; @@ -231,6 +236,27 @@ public void readInput(String messagePrefix, } } + // TODO: For debugging + public static void main(String[] args) throws IOException { + Set withJmods = new HashSet<>(); + File file = new File("jdk-25-with-jmods/jmods"); + new InputReader(new Configuration()).readInput("message", new ClassPathEntry(file, false), dataEntry -> withJmods.add(dataEntry.getName())); + + file = new File("jdk-25-without-jmods/jmods"); + Set withoutJmods = new HashSet<>(); + new InputReader(new Configuration()).readInput("message", new ClassPathEntry(file, false), dataEntry -> withoutJmods.add(dataEntry.getName())); + + Set missing = new HashSet<>(withJmods); + missing.removeAll(withoutJmods); + Set unexpected = new HashSet<>(withoutJmods); + unexpected.removeAll(withJmods); + + System.out.println("=== MISSING ==="); + missing.forEach(System.out::println); + + System.out.println("=== UNEXPECTED ==="); + unexpected.forEach(System.out::println); + } /** * Reads the given input class path entry. @@ -259,18 +285,26 @@ private void readInput(String messagePrefix, classPathEntry.getName(), filter != null || classPathEntry.isFiltered() ? " (filtered)" : "" ); - - // Create a reader that can unwrap jars, wars, ears, jmods and zips. - DataEntryReader reader = - new DataEntryReaderFactory(configuration.android) - .createDataEntryReader(classPathEntry, - dataEntryReader); - - // Create the data entry source. - DataEntrySource source = - new DirectorySource(classPathEntry.getFile()); - - // Set he feature name for the class files and resource files + + File classPathFile = classPathEntry.getFile(); + DataEntryReader reader; + DataEntrySource source = maybeGetJrtFallback(classPathFile, classPathEntry.isJmod()); + if (source != null) { + reader = dataEntryReader; + logger.info("Using jrt:/ file system fallback due to missing jmods directory: {}", classPathFile); + } else { + // Create a reader that can unwrap jars, wars, ears, jmods and zips. + reader = + new DataEntryReaderFactory(configuration.android) + .createDataEntryReader(classPathEntry, + dataEntryReader); + + // Create the data entry source. + source = + new DirectorySource(classPathFile); + } + + // Set the feature name for the class files and resource files // that we'll read. featureName = classPathEntry.getFeatureName(); @@ -283,6 +317,55 @@ private void readInput(String messagePrefix, } } + /** + * Fallback for JDK >= 24 without {@code jmods} directory, see + * issue 473. + * + *

If the class path file is detected to be the non-existent {@code JAVA_HOME/jmods} directory or a jmod file + * within it, creates a {@link DataEntrySource} which uses the {@code jrt:/} file system to read JDK classes. + * + * @return a data entry source if the class path file refers to the non-existent {@code JAVA_HOME/jmods} directory, + * or {@code null} otherwise (e.g. does not refer to jmods directory, or jmods directory exists) + */ + @Nullable + private static JrtDataEntrySource maybeGetJrtFallback(File classPathFile, boolean isJmodFile) { + File jmodsDir = classPathFile; + String moduleName = null; + + // Handle `jmods/.jmod` + if (isJmodFile) { + jmodsDir = classPathFile.getParentFile(); + if (jmodsDir == null) { + return null; + } + + String fileName = classPathFile.getName(); + moduleName = fileName.substring(0, fileName.lastIndexOf('.')); + } + + // First check if this is really a non-existent `JAVA_HOME/jmods` directory + if (!jmodsDir.getName().equals("jmods")) { + return null; + } + if (jmodsDir.exists()) { + return null; + } + + Path javaHome = jmodsDir.toPath().getParent(); + if (javaHome == null) { + return null; + } + + Path jrtFsJarPath = JrtDataEntrySource.getJrtFsJarPath(javaHome); + if (!Files.isRegularFile(jrtFsJarPath)) { + // Not actually a JDK JAVA_HOME (or the `jrt-fs.jar` is missing for whatever reason) + return null; + } + + // Now we are sure that this is the jmods directory of a JDK, and that the `jrt:/` file system can be used + return new JrtDataEntrySource(javaHome, moduleName); + } + /** * This resource file visitor attaches the current resource name, if any, diff --git a/base/src/main/java/proguard/JrtDataEntry.java b/base/src/main/java/proguard/JrtDataEntry.java new file mode 100644 index 00000000..4f2840b0 --- /dev/null +++ b/base/src/main/java/proguard/JrtDataEntry.java @@ -0,0 +1,72 @@ +package proguard; + +import proguard.io.DataEntry; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Data entry created by {@link JrtDataEntrySource}. + */ +public class JrtDataEntry implements DataEntry { + private final Path jrtPath; + private final String name; + private final long size; + + private InputStream inputStream = null; + + public JrtDataEntry(Path jrtPath, String name, long size) { + this.jrtPath = jrtPath; + this.name = name; + this.size = size; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalName() { + return getName(); + } + + @Override + public long getSize() { + return size; + } + + @Override + public boolean isDirectory() { + return false; + } + + @Override + public InputStream getInputStream() throws IOException { + if (inputStream == null) { + inputStream = new BufferedInputStream(Files.newInputStream(jrtPath)); + } + return inputStream; + } + + @Override + public void closeInputStream() throws IOException { + if (inputStream != null) { + inputStream.close(); + inputStream = null; + } + } + + @Override + public DataEntry getParent() { + return null; + } + + @Override + public String toString() { + return "jrt:/" + jrtPath; + } +} diff --git a/base/src/main/java/proguard/JrtDataEntrySource.java b/base/src/main/java/proguard/JrtDataEntrySource.java new file mode 100644 index 00000000..cc055f00 --- /dev/null +++ b/base/src/main/java/proguard/JrtDataEntrySource.java @@ -0,0 +1,101 @@ +package proguard; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import proguard.io.DataEntryReader; +import proguard.io.DataEntrySource; + +import java.io.Closeable; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.stream.Stream; + +/** + * Data entry source which reads JDK classes using the {@code jrt:/} file system. + * + * @see JEP 220 + */ +public class JrtDataEntrySource implements DataEntrySource, Closeable { + @Nullable + private final String moduleName; + private final FileSystem jrtFileSystem; + private final URLClassLoader fallbackClassLoader; + + /** + * @param javaHome path to JAVA_HOME whose classes should be read + * @param moduleName name of the module whose classes should be read; {@code null} to read the classes of + * all modules + */ + public JrtDataEntrySource(Path javaHome, @Nullable String moduleName) { + this.moduleName = moduleName; + + Path jrtFsJarPath = getJrtFsJarPath(javaHome); + if (!Files.isRegularFile(jrtFsJarPath)) { + throw new IllegalArgumentException("Missing file in JAVA_HOME: " + jrtFsJarPath); + } + try { + // This class loader is used as fallback when the current JDK is JDK 8 and does not itself have the + // provider for the `jrt:/` file system + fallbackClassLoader = new URLClassLoader(new URL[] {jrtFsJarPath.toUri().toURL()}); + } catch (MalformedURLException e) { + throw new RuntimeException("Failed converting file path to URL", e); + } + + try { + jrtFileSystem = FileSystems.newFileSystem(URI.create("jrt:/"), Collections.singletonMap("java.home", javaHome.toString()), fallbackClassLoader); + } catch (Exception e) { + // Should not happen normally + throw new RuntimeException("Failed creating jrt:/ file system for JAVA_HOME " + javaHome); + } + } + + @Override + public void pumpDataEntries(DataEntryReader dataEntryReader) throws IOException { + Path modulesPath = jrtFileSystem.getPath("modules"); + if (moduleName != null) { + Path moduleDir = modulesPath.resolve(moduleName); + if (!Files.isDirectory(moduleDir)) { + throw new IOException("Module '" + moduleName + "' does not exist"); + } + processModuleFiles(moduleDir, dataEntryReader); + } else { + try (Stream moduleDirs = Files.list(modulesPath)) { + for (Path moduleDir : (Iterable) moduleDirs::iterator) { + processModuleFiles(moduleDir, dataEntryReader); + } + } + } + } + + private void processModuleFiles(Path moduleDir, DataEntryReader dataEntryReader) throws IOException { + Files.walkFileTree(moduleDir, new SimpleFileVisitor() { + @Override + public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException { + String name = moduleDir.relativize(file).toString(); + dataEntryReader.read(new JrtDataEntry(file, name, attrs.size())); + return FileVisitResult.CONTINUE; + } + }); + } + + @Override + public void close() throws IOException { + jrtFileSystem.close(); + // Note: Closing the class loader might not be thread-safe because by default the JDK caches the underlying + // JarFile, so closing it implicitly here might affect other users of that jar + fallbackClassLoader.close(); + } + + /** + * Gets the path for the {@code jrt-fs.jar}. + */ + public static Path getJrtFsJarPath(Path javaHome) { + return javaHome.resolve("lib").resolve("jrt-fs.jar"); + } +}