diff --git a/build.gradle b/build.gradle index b863f28..b6fe227 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ targetCompatibility = javaVersion group = 'org.cadixdev' archivesBaseName = project.name.toLowerCase() -version = '0.2.0' +version = '0.2.1' repositories { mavenCentral() @@ -39,6 +39,7 @@ artifacts { if (project.hasProperty('ossrhUsername') && project.hasProperty('ossrhPassword')) { apply plugin: 'signing' signing { + useGpgCmd() required { !version.endsWith('-SNAPSHOT') && gradle.taskGraph.hasTask(tasks.uploadArchives) } sign configurations.archives } diff --git a/src/main/java/org/cadixdev/atlas/Atlas.java b/src/main/java/org/cadixdev/atlas/Atlas.java index 67e9428..094a91a 100644 --- a/src/main/java/org/cadixdev/atlas/Atlas.java +++ b/src/main/java/org/cadixdev/atlas/Atlas.java @@ -8,6 +8,7 @@ import org.cadixdev.atlas.jar.JarFile; import org.cadixdev.atlas.util.CascadingClassProvider; +import org.cadixdev.atlas.util.JarRepacker; import org.cadixdev.bombe.analysis.InheritanceProvider; import org.cadixdev.bombe.asm.analysis.ClassProviderInheritanceProvider; import org.cadixdev.bombe.asm.jar.ClassProvider; @@ -18,6 +19,8 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Function; /** @@ -35,6 +38,48 @@ public class Atlas implements Closeable { private final List<Function<AtlasTransformerContext, JarEntryTransformer>> transformers = new ArrayList<>(); private final List<JarFile> classpath = new ArrayList<>(); + private final ExecutorService executorService; + private final boolean manageExecutor; + + /** + * Creates an Atlas with an associated executor service. + * + * @param executorService The executor service + * @param manageExecutor Whether to shutdown the executor service when closing the atlas + * @since 0.2.1 + */ + private Atlas(final ExecutorService executorService, final boolean manageExecutor) { + this.executorService = executorService; + this.manageExecutor = manageExecutor; + } + + /** + * Creates an Atlas with an associated executor service. + * + * @param executorService The executor service + * @since 0.2.1 + */ + public Atlas(final ExecutorService executorService) { + this(executorService, false); + } + + /** + * Creates an Atlas with a default executor service (made with {@link Executors#newWorkStealingPool(int)}). + * + * @param parallelism The targeted parallelism level + * @since 0.2.1 + */ + public Atlas(final int parallelism) { + this(Executors.newWorkStealingPool(parallelism), true); + } + + /** + * Creates an Atlas with a default executor service, {@link Executors#newWorkStealingPool()}. + */ + public Atlas() { + this(Executors.newWorkStealingPool(), true); + } + /** * Adds the given JAR file to the Atlas classpath, allowing the * {@link InheritanceProvider inheritance provider} to have a more complete view @@ -102,7 +147,9 @@ public void run(final JarFile jar, final Path output) throws IOException { .toArray(JarEntryTransformer[]::new); // Transform the JAR, and save to the output path - jar.transform(output, transformers); + jar.transform(output, this.executorService, transformers); + + JarRepacker.verifyJarManifest(output); } /** @@ -112,6 +159,10 @@ public void run(final JarFile jar, final Path output) throws IOException { */ @Override public void close() throws IOException { + if (this.manageExecutor) { + this.executorService.shutdown(); + } + for (final JarFile jar : this.classpath) { jar.close(); } diff --git a/src/main/java/org/cadixdev/atlas/jar/JarFile.java b/src/main/java/org/cadixdev/atlas/jar/JarFile.java index b9b48fd..cf45e0b 100644 --- a/src/main/java/org/cadixdev/atlas/jar/JarFile.java +++ b/src/main/java/org/cadixdev/atlas/jar/JarFile.java @@ -23,15 +23,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; -import java.util.HashSet; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.jar.Manifest; import java.util.stream.Stream; @@ -156,17 +155,41 @@ else if (name.endsWith(".class")) { * @throws IOException Should an issue with reading or writing occur */ public void transform(final Path export, final JarEntryTransformer... transformers) throws IOException { + final ExecutorService executorService = Executors.newWorkStealingPool(); + try { + this.transform(export, executorService, transformers); + } + finally { + executorService.shutdown(); + } + } + + /** + * Transforms the JAR file, with the given {@link JarEntryTransformer}s, writing + * to the given output JAR path. + * + * @param export The JAR path to write to + * @param executorService The executor service to use + * @param transformers The transformers to use + * @throws IOException Should an issue with reading or writing occur + * @since 0.2.1 + */ + public void transform(final Path export, final ExecutorService executorService, final JarEntryTransformer... transformers) throws IOException { Files.deleteIfExists(export); try (final FileSystem fs = NIOHelper.openZip(export, true)) { - final CompletableFuture<Void> future = CompletableFuture.allOf(this.walk().map(path -> CompletableFuture.supplyAsync(() -> { + final CompletableFuture<Void> future = CompletableFuture.allOf(this.walk().map(path -> CompletableFuture.runAsync(() -> { try { // Get the entry AbstractJarEntry entry = this.get(path); - if (entry == null) return null; + if (entry == null) return; // Transform the entry for (final JarEntryTransformer transformer : transformers) { entry = entry.accept(transformer); + + // If a transformer wants to remove an entry, it should return null. + // TODO: document this in Bombe + if (entry == null) return; } // Write to jar @@ -182,8 +205,7 @@ public void transform(final Path export, final JarEntryTransformer... transforme catch (final IOException ex) { throw new CompletionException(ex); } - return null; - })).toArray(CompletableFuture[]::new)); + }, executorService)).toArray(CompletableFuture[]::new)); future.get(); } @@ -197,7 +219,8 @@ public void transform(final Path export, final JarEntryTransformer... transforme catch (final IOException ioe) { throw ioe; } - catch (final Throwable ignored) { + catch (final Throwable cause) { + throw new RuntimeException(cause); } } } diff --git a/src/main/java/org/cadixdev/atlas/util/JarRepacker.java b/src/main/java/org/cadixdev/atlas/util/JarRepacker.java new file mode 100644 index 0000000..fc64302 --- /dev/null +++ b/src/main/java/org/cadixdev/atlas/util/JarRepacker.java @@ -0,0 +1,151 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.cadixdev.atlas.util; + +import static java.nio.file.StandardCopyOption.ATOMIC_MOVE; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +/** + * A helper class for repackaging jar files so that they're read by Java correctly, see {@link #verifyJarManifest(Path)} + * for more details. + * + * @author Kyle Wood + * @since 0.2.1 + */ +public final class JarRepacker { + + private JarRepacker() { + } + + /** + * {@link JarInputStream} requires that if a {@code META-INF/MANIFEST.MF} record is present in a jar file, it must be + * the first entry. + * <p> + * In order to maintain compatibility with the jars Atlas produces, this method will check first if + * the output jar has any manifest file at all, and if it does, if it is retrievable by {@link JarInputStream}. + * <p> + * If the output jar does have a manifest file that {@link JarInputStream} can't access, then this method will repack + * the jar to fix the issue. For performance reasons Atlas remapping process remaps jar entries in parallel, and it + * uses the NIO zip file system API, which we have no control of. Since this repacking process is a simple copy it is + * still very fast (compared to the remapping operation). + * + * @param outputJar The jar produced by the atlas transformation. + * @throws IOException If an IO error occurs. + */ + public static void verifyJarManifest(final Path outputJar) throws IOException { + final boolean maybeNeedsRepack; + try (final JarInputStream input = new JarInputStream(Files.newInputStream(outputJar))) { + maybeNeedsRepack = input.getManifest() == null; + } + if (maybeNeedsRepack) { + final boolean hasManifest; + try (final JarFile outputJarFile = new JarFile(outputJar.toFile())) { + hasManifest = outputJarFile.getManifest() != null; + } + if (hasManifest) { + fixJarManifest(outputJar); + } + } + } + + /** + * Given that the output jar needs to be fixed, repack the given jar with the {@code META-INF/MANIFEST.MF} file as + * the first entry. + * + * @param outputJar The file to repack. + * @throws IOException If an IO error occurs. + * @see #verifyJarManifest(Path) + */ + private static void fixJarManifest(final Path outputJar) throws IOException { + final byte[] buffer = new byte[8192]; + + final Path tempOut = Files.createTempFile(outputJar.getParent(), "atlas", "jar"); + try { + try (final JarOutputStream out = new JarOutputStream(Files.newOutputStream(tempOut)); + final JarFile jarFile = new JarFile(outputJar.toFile())) { + + final boolean skipManifest = copyManifest(jarFile, out); + + final Enumeration<JarEntry> entries = jarFile.entries(); + while (entries.hasMoreElements()) { + final JarEntry currentEntry = entries.nextElement(); + final String name = currentEntry.getName(); + if (skipManifest && (name.equals("META-INF/") || name.equalsIgnoreCase("META-INF/MANIFEST.MF"))) { + continue; + } + + out.putNextEntry(new ZipEntry(name)); + try (final InputStream input = jarFile.getInputStream(currentEntry)) { + copy(input, out, buffer); + } + finally { + out.closeEntry(); + } + } + } + + Files.move(tempOut, outputJar, REPLACE_EXISTING, ATOMIC_MOVE); + } + finally { + Files.deleteIfExists(tempOut); + } + } + + /** + * Finds the manifest entry in the given {@code jarFile} and copies it into {@code out}. + * + * @param jarFile The input file to read the manifest from. + * @param out The output stream to write the manifest to. + * @return {@code true} if the manifest file was copied successfully. + * @throws IOException If an IO error occurs. + */ + private static boolean copyManifest(final JarFile jarFile, final JarOutputStream out) throws IOException { + final Manifest manifest = jarFile.getManifest(); + if (manifest == null) { + // something weird happened, but don't error + return false; + } + + out.putNextEntry(new ZipEntry("META-INF/")); + out.closeEntry(); + + out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + manifest.write(out); + out.closeEntry(); + + return true; + } + + /** + * Copy all of the data from the {@code from} input to the {@code to} output. + * + * @param from The input to copy from. + * @param to The output to copy to. + * @param buffer The byte array to use as the copy buffer. + * @throws IOException If an IO error occurs. + */ + private static void copy(final InputStream from, final OutputStream to, final byte[] buffer) throws IOException { + int read; + while ((read = from.read(buffer)) != -1) { + to.write(buffer, 0, read); + } + } + +}