diff --git a/build.gradle b/build.gradle index 36a2e62..861fbbe 100644 --- a/build.gradle +++ b/build.gradle @@ -21,12 +21,19 @@ dependencies { compile "org.ow2.asm:asm-commons:$asmVersion" compile "org.cadixdev:bombe:$bombeVersion" compile "org.cadixdev:bombe-jar:$bombeVersion" + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" } processResources { from 'LICENSE.txt' } +test { + useJUnitPlatform() +} + task javadocJar(type: Jar, dependsOn: 'javadoc') { from javadoc.destinationDir classifier = 'javadoc' diff --git a/gradle.properties b/gradle.properties index 2d30440..a0f6c66 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,3 +8,4 @@ inceptionYear = 2019 javaVersion = 1.8 asmVersion = 7.1 bombeVersion = 0.5.0-SNAPSHOT +junitVersion = 5.7.1 diff --git a/src/main/java/org/cadixdev/atlas/Atlas.java b/src/main/java/org/cadixdev/atlas/Atlas.java index c6f0f4f..f6fe5a9 100644 --- a/src/main/java/org/cadixdev/atlas/Atlas.java +++ b/src/main/java/org/cadixdev/atlas/Atlas.java @@ -7,6 +7,7 @@ package org.cadixdev.atlas; import org.cadixdev.atlas.jar.JarFile; +import org.cadixdev.atlas.jar.JarTransformFailedException; import org.cadixdev.atlas.util.CompositeClassProvider; import org.cadixdev.atlas.util.JarRepacker; import org.cadixdev.bombe.analysis.InheritanceProvider; @@ -111,6 +112,8 @@ public Atlas install(final Function failedLocations = new ConcurrentHashMap<>(); try (final FileSystem fs = NIOHelper.openZip(export, true)) { final CompletableFuture future = CompletableFuture.allOf(this.walk().map(path -> CompletableFuture.runAsync(() -> { try { @@ -203,12 +207,23 @@ public void transform(final Path export, final ExecutorService executorService, Files.write(outEntry, entry.getContents()); Files.setLastModifiedTime(outEntry, FileTime.fromMillis(entry.getTime())); } - catch (final IOException ex) { - throw new CompletionException(ex); + catch (final Exception ex) { + failedLocations.put(path, ex); } }, executorService)).toArray(CompletableFuture[]::new)); - future.get(); + future.handle((value, error) -> { + // Capture errors + if (error != null) { + throw new RuntimeException(error); + } + return value; + }).get(); + + if (!failedLocations.isEmpty()) { + // Some entries failed to transform + throw new JarTransformFailedException("Some entries in " + this.getName() + " failed to be transformed", failedLocations); + } // Add additions from transformers for (final JarEntryTransformer transformer : transformers) { @@ -254,6 +269,8 @@ public void transform(final Path export, final ExecutorService executorService, * * @param transformers The transformers to use * @throws IOException Should an issue with reading occur + * @throws JarTransformFailedException if one or more transformers threw + * exceptions while processing a jar entry * @since 0.2.2 */ public void process(final JarEntryTransformer... transformers) throws IOException { @@ -277,10 +294,13 @@ public void process(final JarEntryTransformer... transformers) throws IOExceptio * @param executorService The executor service to use * @param transformers The transformers to use * @throws IOException Should an issue with reading occur + * @throws JarTransformFailedException if one or more transformers threw + * exceptions while processing a jar entry * @since 0.2.2 */ public void process(final ExecutorService executorService, final JarEntryTransformer... transformers) throws IOException { + final Map failedLocations = new ConcurrentHashMap<>(); final CompletableFuture future = CompletableFuture.allOf(this.walk().map(path -> CompletableFuture.runAsync(() -> { try { // Get the entry @@ -293,13 +313,24 @@ public void process(final ExecutorService executorService, final JarEntryTransfo if (entry == null) return; } } - catch (final IOException ex) { - throw new CompletionException(ex); + catch (final Exception ex) { + failedLocations.put(path, ex); } }, executorService)).toArray(CompletableFuture[]::new)); try { - future.get(); + future.handle((value, error) -> { + // Capture errors + if (error != null) { + throw new RuntimeException(error); + } + return value; + }).get(); + + if (!failedLocations.isEmpty()) { + // Some entries failed to transform + throw new JarTransformFailedException("Some entries in " + this.getName() + " failed to be transformed", failedLocations); + } } catch (final InterruptedException ex) { throw new RuntimeException(ex); diff --git a/src/main/java/org/cadixdev/atlas/jar/JarTransformFailedException.java b/src/main/java/org/cadixdev/atlas/jar/JarTransformFailedException.java new file mode 100644 index 0000000..28b21cf --- /dev/null +++ b/src/main/java/org/cadixdev/atlas/jar/JarTransformFailedException.java @@ -0,0 +1,69 @@ +/* + * 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.jar; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * An exception thrown when one or more entries in a jar fail to transform. + * + * @since 0.3.0 + */ +public final class JarTransformFailedException extends IOException { + + private static final long serialVersionUID = 3971759325502243118L; + + private final Map failedPaths; + + /** + * Create a new exception. + * + * @param message the user-visible message + * @param failedPaths a map from failed entry to + */ + public JarTransformFailedException(final String message, final Map failedPaths) { + super(message); + this.failedPaths = Collections.unmodifiableMap(new HashMap<>(failedPaths)); + if (this.failedPaths.size() == 1) { + this.initCause(this.failedPaths.values().iterator().next()); + } + } + + /** + * Return an unmodifiable snapshot of a map of jar path to the exception + * thrown at the path. + * + * @return the failed paths + */ + public Map getFailedPaths() { + return this.failedPaths; + } + + @Override + public String getMessage() { + final String superMessage = super.getMessage(); + if (this.failedPaths.size() == 1) { // details already included in cause + return superMessage + " (in entry " + this.failedPaths.keySet().iterator().next().getName() + " )"; + } + + final StringBuilder message = new StringBuilder(superMessage == null ? "Failed to transform jar: " : superMessage); + for (final Map.Entry failure : this.failedPaths.entrySet()) { + message.append(System.lineSeparator()) + .append("- ") + .append(failure.getKey().getName()); + final String elementMessage = failure.getValue().getMessage(); + if (elementMessage != null) { + message.append(": ") + .append(elementMessage); + } + } + return message.toString(); + } +} diff --git a/src/test/java/org/cadixdev/atlas/jar/JarFileTest.java b/src/test/java/org/cadixdev/atlas/jar/JarFileTest.java new file mode 100644 index 0000000..6990e25 --- /dev/null +++ b/src/test/java/org/cadixdev/atlas/jar/JarFileTest.java @@ -0,0 +1,164 @@ +/* + * 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.jar; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.cadixdev.bombe.jar.JarClassEntry; +import org.cadixdev.bombe.jar.JarEntryTransformer; +import org.cadixdev.bombe.jar.JarResourceEntry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +class JarFileTest { + + @TempDir + static Path workDir; + + private static final AtomicInteger ORIG_COUNTER = new AtomicInteger(); + private static final AtomicInteger OUTPUT_COUNTER = new AtomicInteger(); + + @Test + void testSingleTransformFailure() throws IOException { + final Path sourceJar = simpleTempJar(); + final Path output = nextOutputFile(); + + try (final JarFile jar = new JarFile(sourceJar)) { + final JarTransformFailedException ex = assertThrows(JarTransformFailedException.class, () -> jar.transform(output, new FailingEntryTransformer("two.properties"))); + assertEquals(1, ex.getFailedPaths().size()); + assertNotNull(ex.getFailedPaths().get(new JarPath("two.properties"))); + assertTrue(ex.getCause() instanceof RuntimeException); // error when single-failure + } + } + + @Test + void testMultiTransformFailure() throws IOException { + final Path sourceJar = simpleTempJar(); + final Path output = nextOutputFile(); + + try (final JarFile jar = new JarFile(sourceJar)) { + final JarTransformFailedException ex = assertThrows(JarTransformFailedException.class, () -> jar.transform(output, new FailingEntryTransformer("one.properties", "two.properties"))); + assertEquals(2, ex.getFailedPaths().size()); + assertNotNull(ex.getFailedPaths().get(new JarPath("one.properties"))); + assertNotNull(ex.getFailedPaths().get(new JarPath("two.properties"))); + assertNull(ex.getCause()); // null when multiple failures + } + } + + @Test + void testSingleProcessFailure() throws IOException { + final Path sourceJar = simpleTempJar(); + + try (final JarFile jar = new JarFile(sourceJar)) { + final JarTransformFailedException ex = assertThrows(JarTransformFailedException.class, () -> jar.process(new FailingEntryTransformer("two.properties"))); + assertEquals(1, ex.getFailedPaths().size()); + assertNotNull(ex.getFailedPaths().get(new JarPath("two.properties"))); + assertTrue(ex.getCause() instanceof RuntimeException); // error when single-failure + } + } + + @Test + void testMultiProcessFailure() throws IOException { + final Path sourceJar = simpleTempJar(); + try (final JarFile jar = new JarFile(sourceJar)) { + final JarTransformFailedException ex = assertThrows(JarTransformFailedException.class, () -> jar.process(new FailingEntryTransformer("one.properties", "two.properties"))); + assertEquals(2, ex.getFailedPaths().size()); + assertNotNull(ex.getFailedPaths().get(new JarPath("one.properties"))); + assertNotNull(ex.getFailedPaths().get(new JarPath("two.properties"))); + assertNull(ex.getCause()); // null when multiple failures + } + } + + /** + * Make a simple temporary jar with some properties files. + * + * @return the temporary jar + * @throws IOException if failed to create jar + */ + private static Path simpleTempJar() throws IOException { + return makeTempJar( + entry("one.properties", "hello"), + entry("two.properties", "world"), + entry("three.properties", "whee") + ); + } + + + /** + * A transformer that will throw a {@link RuntimeException} on chose + * entries, for testing error handling. + */ + private static class FailingEntryTransformer implements JarEntryTransformer { + private final Set namesToFail; + + FailingEntryTransformer(final String... failingNames) { + this.namesToFail = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(failingNames))); + } + + @Override + public JarClassEntry transform(final JarClassEntry entry) { + if (this.namesToFail.contains(entry.getName())) { + throw new RuntimeException("injected failure on " + entry.getName()); + } + return entry; + } + + @Override + public JarResourceEntry transform(final JarResourceEntry entry) { + if (this.namesToFail.contains(entry.getName())) { + throw new RuntimeException("injected failure on " + entry.getName()); + } + return entry; + } + } + + // Test file production // + + private static Map.Entry entry(final String key, final String value) { + return new AbstractMap.SimpleImmutableEntry<>(key, value); + } + + // Create a jar in the test directory + @SafeVarargs + private static Path makeTempJar(final Map.Entry... files) throws IOException { + final Path test = workDir.resolve("orig" + ORIG_COUNTER.getAndIncrement() + ".jar"); + try (final JarOutputStream os = new JarOutputStream(Files.newOutputStream(test))) { + for (final Map.Entry file : files) { + os.putNextEntry(new ZipEntry(file.getKey())); + + os.write(file.getValue().getBytes(StandardCharsets.UTF_8)); + os.write('\n'); + } + } + + return test; + } + + // Get a unique output file + private static Path nextOutputFile() { + return workDir.resolve("output" + OUTPUT_COUNTER.getAndIncrement() + ".jar"); + } + +}