diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.groovy new file mode 100644 index 000000000..4f7c4404b --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.groovy @@ -0,0 +1,121 @@ +package com.github.jengelman.gradle.plugins.shadow + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.LibraryElements +import org.gradle.api.attributes.Usage +import org.gradle.api.component.AdhocComponentWithVariants +import org.gradle.api.component.SoftwareComponentFactory +import org.gradle.api.plugins.JavaPlugin +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskProvider +import org.gradle.jvm.tasks.Jar +import org.gradle.plugin.devel.plugins.JavaGradlePluginPlugin + +import javax.inject.Inject + +class ShadowJavaPlugin implements Plugin { + + public static final String SHADOW_JAR_TASK_NAME = 'shadowJar' + public static final String SHADOW_GROUP = 'Shadow' + public static final String SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME = 'shadowRuntimeElements' + + public static final String MODULE_INFO_CLASS = 'module-info.class' + + private final SoftwareComponentFactory softwareComponentFactory + + @Inject + ShadowJavaPlugin(SoftwareComponentFactory softwareComponentFactory) { + this.softwareComponentFactory = softwareComponentFactory + } + + @Override + void apply(Project project) { + def shadowConfiguration = project.configurations.getByName(ShadowBasePlugin.CONFIGURATION_NAME) + def shadowTaskProvider = configureShadowTask(project, shadowConfiguration) + + project.configurations.named(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME) { + it.extendsFrom(shadowConfiguration) + } + + def shadowRuntimeElements = project.configurations.create(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) { Configuration it -> + it.extendsFrom(shadowConfiguration) + it.canBeConsumed = true + it.canBeResolved = false + it.attributes { + it.attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage, Usage.JAVA_RUNTIME)) + it.attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category, Category.LIBRARY)) + it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, project.objects.named(LibraryElements, LibraryElements.JAR)) + it.attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling, Bundling.SHADOWED)) + } + it.outgoing.artifact(shadowTaskProvider) + } + + project.components.named("java", AdhocComponentWithVariants) { + it.addVariantsFromConfiguration(shadowRuntimeElements) { + it.mapToOptional() + } + } + + AdhocComponentWithVariants shadowComponent = softwareComponentFactory.adhoc(ShadowBasePlugin.COMPONENT_NAME) + project.components.add(shadowComponent) + shadowComponent.addVariantsFromConfiguration(shadowRuntimeElements) { + it.mapToMavenScope("runtime") + } + + project.plugins.withType(JavaGradlePluginPlugin).configureEach { + // Remove the gradleApi so it isn't merged into the jar file. + // This is required because 'java-gradle-plugin' adds gradleApi() to the 'api' configuration. + // See https://github.com/gradle/gradle/blob/972c3e5c6ef990dd2190769c1ce31998a9402a79/subprojects/plugin-development/src/main/java/org/gradle/plugin/devel/plugins/JavaGradlePluginPlugin.java#L161 + project.configurations.named(JavaPlugin.API_CONFIGURATION_NAME) { + it.dependencies.remove(project.dependencies.gradleApi()) + } + // Compile only gradleApi() to make sure the plugin can compile against Gradle API. + project.configurations.named(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME) { + it.dependencies.add(project.dependencies.gradleApi()) + } + } + } + + protected static TaskProvider configureShadowTask(Project project, Configuration shadowConfiguration) { + SourceSetContainer sourceSets = project.extensions.getByType(SourceSetContainer) + def jarTask = project.tasks.named(JavaPlugin.JAR_TASK_NAME, Jar) + def taskProvider = project.tasks.register(SHADOW_JAR_TASK_NAME, ShadowJar) { shadow -> + shadow.group = SHADOW_GROUP + shadow.description = 'Create a combined JAR of project and runtime dependencies' + shadow.archiveClassifier.set("all") + shadow.manifest.inheritFrom(jarTask.get().manifest) + def attrProvider = jarTask.map { it.manifest.attributes.get('Class-Path') } + def files = project.objects.fileCollection().from(shadowConfiguration) + shadow.doFirst { + if (!files.empty) { + def attrs = [attrProvider.getOrElse('')] + files.collect { it.name } + shadow.manifest.attributes 'Class-Path': attrs.join(' ').trim() + } + } + shadow.from(sourceSets.main.output) + shadow.configurations = [ + project.configurations.findByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME) ?: + project.configurations.runtime, + ] + /* + Remove excludes like this: + shadowJar { + ... + allowModuleInfos() + } + */ + def excludes = ['META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA'] + if (!shadow.isAllowModuleInfos()) { + excludes.add(MODULE_INFO_CLASS) + } + shadow.exclude(excludes) + } + project.artifacts.add(shadowConfiguration.name, taskProvider) + return taskProvider + } +} diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/internal/RelocationUtil.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/internal/RelocationUtil.groovy new file mode 100644 index 000000000..6054ddfab --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/internal/RelocationUtil.groovy @@ -0,0 +1,26 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import java.util.jar.JarFile + +class RelocationUtil { + + static void configureRelocation(ShadowJar target, String prefix) { + def packages = [] as Set + target.configurations.each { configuration -> + configuration.files.each { jar -> + JarFile jf = new JarFile(jar) + jf.entries().each { entry -> + if (entry.name.endsWith(".class") && entry.name != ShadowJavaPlugin.MODULE_INFO_CLASS) { + packages << entry.name[0..entry.name.lastIndexOf('/') - 1].replaceAll('/', '.') + } + } + jf.close() + } + } + packages.each { + target.relocate(it, "${prefix}.${it}") + } + } +} diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.groovy new file mode 100644 index 000000000..da200bb61 --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.groovy @@ -0,0 +1,672 @@ +package com.github.jengelman.gradle.plugins.shadow.tasks + +import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin +import com.github.jengelman.gradle.plugins.shadow.ShadowStats +import com.github.jengelman.gradle.plugins.shadow.impl.RelocatorRemapper +import com.github.jengelman.gradle.plugins.shadow.internal.UnusedTracker +import com.github.jengelman.gradle.plugins.shadow.internal.ZipCompressor +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.transformers.StandardFilesMergeTransformer +import com.github.jengelman.gradle.plugins.shadow.transformers.Transformer +import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext +import groovy.util.logging.Slf4j +import org.apache.commons.io.FilenameUtils +import org.apache.commons.io.IOUtils +import org.apache.tools.zip.UnixStat +import org.apache.tools.zip.Zip64RequiredException +import org.apache.tools.zip.ZipEntry +import org.apache.tools.zip.ZipFile +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.Action +import org.gradle.api.GradleException +import org.gradle.api.UncheckedIOException +import org.gradle.api.file.FileCopyDetails +import org.gradle.api.file.FilePermissions +import org.gradle.api.file.FileTreeElement +import org.gradle.api.file.RelativePath +import org.gradle.api.internal.DocumentationRegistry +import org.gradle.api.internal.file.CopyActionProcessingStreamAction +import org.gradle.api.internal.file.DefaultFilePermissions +import org.gradle.api.internal.file.DefaultFileTreeElement +import org.gradle.api.internal.file.copy.CopyAction +import org.gradle.api.internal.file.copy.CopyActionProcessingStream +import org.gradle.api.internal.file.copy.FileCopyDetailsInternal +import org.gradle.api.specs.Spec +import org.gradle.api.tasks.WorkResult +import org.gradle.api.tasks.WorkResults +import org.gradle.api.tasks.bundling.Zip +import org.gradle.api.tasks.util.PatternSet +import org.gradle.internal.UncheckedException +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.commons.ClassRemapper + +import javax.annotation.Nonnull +import javax.annotation.Nullable +import java.util.zip.ZipException + +@Slf4j +class ShadowCopyAction implements CopyAction { + static final long CONSTANT_TIME_FOR_ZIP_ENTRIES = (new GregorianCalendar(1980, 1, 1, 0, 0, 0)).getTimeInMillis() + + private final File zipFile + private final ZipCompressor compressor + private final DocumentationRegistry documentationRegistry + private final List transformers + private final List relocators + private final PatternSet patternSet + private final ShadowStats stats + private final String encoding + private final boolean preserveFileTimestamps + private final boolean minimizeJar + private final UnusedTracker unusedTracker + private final boolean allowModuleInfos + + ShadowCopyAction(File zipFile, ZipCompressor compressor, DocumentationRegistry documentationRegistry, + String encoding, List transformers, List relocators, + PatternSet patternSet, ShadowStats stats, + boolean preserveFileTimestamps, boolean minimizeJar, UnusedTracker unusedTracker, + boolean allowModuleInfos) { + + this.zipFile = zipFile + this.compressor = compressor + this.documentationRegistry = documentationRegistry + this.transformers = transformers + this.relocators = relocators + this.patternSet = patternSet + this.stats = stats + this.encoding = encoding + this.preserveFileTimestamps = preserveFileTimestamps + this.minimizeJar = minimizeJar + this.unusedTracker = unusedTracker + this.allowModuleInfos = allowModuleInfos + } + + @Override + WorkResult execute(CopyActionProcessingStream stream) { + Set unusedClasses + if (minimizeJar) { + stream.process(new BaseStreamAction() { + @Override + void visitFile(FileCopyDetails fileDetails) { + // All project sources are already present, we just need + // to deal with JAR dependencies. + if (isArchive(fileDetails)) { + unusedTracker.addDependency(fileDetails.file) + } + } + }) + unusedClasses = unusedTracker.findUnused() + } else { + unusedClasses = Collections.emptySet() + } + + ZipOutputStream zipOutStr + + try { + zipOutStr = compressor.createArchiveOutputStream(zipFile) + } catch (Exception e) { + throw new GradleException("Could not create ZIP '${zipFile.toString()}'", e) + } + + try { + withResource(zipOutStr, new Action() { + void execute(ZipOutputStream outputStream) { + try { + stream.process(new StreamAction(outputStream, encoding, transformers, relocators, patternSet, + unusedClasses, stats)) + processTransformers(outputStream) + } catch (Exception e) { + log.error('ex', e) + //TODO this should not be rethrown + throw e + } + } + }) + } catch (UncheckedIOException e) { + if (e.cause instanceof Zip64RequiredException) { + throw new Zip64RequiredException( + String.format("%s\n\nTo build this archive, please enable the zip64 extension.\nSee: %s", + e.cause.message, documentationRegistry.getDslRefForProperty(Zip, "zip64")) + ) + } + } + return WorkResults.didWork(true) + } + + private void processTransformers(ZipOutputStream stream) { + transformers.each { Transformer transformer -> + if (transformer.hasTransformedResource()) { + transformer.modifyOutputStream(stream, preserveFileTimestamps) + } + } + } + + private long getArchiveTimeFor(long timestamp) { + return preserveFileTimestamps ? timestamp : CONSTANT_TIME_FOR_ZIP_ENTRIES + } + + private ZipEntry setArchiveTimes(ZipEntry zipEntry) { + if (!preserveFileTimestamps) { + zipEntry.setTime(CONSTANT_TIME_FOR_ZIP_ENTRIES) + } + return zipEntry + } + + private static void withResource(T resource, Action action) { + try { + action.execute(resource) + } catch (Throwable t) { + try { + resource.close() + } catch (IOException e) { + log.warn("Could not close resource $resource", e) + } + throw UncheckedException.throwAsUncheckedException(t) + } + + try { + resource.close() + } catch (IOException e) { + throw new UncheckedIOException(e) + } + } + + abstract class BaseStreamAction implements CopyActionProcessingStreamAction { + protected boolean isArchive(FileCopyDetails fileDetails) { + return fileDetails.relativePath.pathString.endsWith('.jar') + } + + protected boolean isClass(FileCopyDetails fileDetails) { + return FilenameUtils.getExtension(fileDetails.path) == 'class' + } + + @Override + void processFile(FileCopyDetailsInternal details) { + if (details.directory) { + visitDir(details) + } else { + visitFile(details) + } + } + + protected void visitDir(FileCopyDetails dirDetails) {} + + protected abstract void visitFile(FileCopyDetails fileDetails) + } + + private class StreamAction extends BaseStreamAction { + + private final ZipOutputStream zipOutStr + private final List transformers + private final List relocators + private final RelocatorRemapper remapper + private final PatternSet patternSet + private final Set unused + private final ShadowStats stats + + private class VisitedFileInfo { + long size + RelativePath originJar + + VisitedFileInfo(long size, @Nonnull RelativePath originJar) { + this.size = size + this.originJar = originJar + } + } + + private Map visitedFiles = new HashMap<>() + + StreamAction(ZipOutputStream zipOutStr, String encoding, List transformers, + List relocators, PatternSet patternSet, Set unused, + ShadowStats stats) { + this.zipOutStr = zipOutStr + this.transformers = transformers + this.relocators = relocators + this.remapper = new RelocatorRemapper(relocators, stats) + this.patternSet = patternSet + this.unused = unused + this.stats = stats + if (encoding != null) { + this.zipOutStr.setEncoding(encoding) + } + } + + /** + * Record visit and return true if visited for the first time. + * + * @param path Visited path. + * @param size Size. + * @param originJar JAR it originated from. + * @return True if wasn't visited already. + */ + private boolean recordVisit(String path, long size, @Nullable RelativePath originJar) { + if (visitedFiles.containsKey(path)) { + return false + } + + if (originJar == null) { + originJar = new RelativePath(false) + } + + visitedFiles.put(path.toString(), new VisitedFileInfo(size, originJar)) + return true + } + + private boolean recordVisit(path) { + return recordVisit(path.toString(), 0, null) + } + + private boolean recordVisit(FileCopyDetails fileCopyDetails) { + return recordVisit(fileCopyDetails.relativePath.toString(), fileCopyDetails.size, null) + } + + @Override + void visitFile(FileCopyDetails fileDetails) { + if (!isArchive(fileDetails)) { + try { + boolean isClass = isClass(fileDetails) + if (!remapper.hasRelocators() || !isClass) { + if (!isTransformable(fileDetails)) { + String mappedPath = remapper.map(fileDetails.relativePath.pathString) + ZipEntry archiveEntry = new ZipEntry(mappedPath) + archiveEntry.setTime(getArchiveTimeFor(fileDetails.lastModified)) + archiveEntry.unixMode = (UnixStat.FILE_FLAG | fileDetails.permissions.toUnixNumeric()) + zipOutStr.putNextEntry(archiveEntry) + fileDetails.copyTo(zipOutStr) + zipOutStr.closeEntry() + } else { + transform(fileDetails) + } + } else if (isClass && !isUnused(fileDetails.path)) { + remapClass(fileDetails) + } + recordVisit(fileDetails) + } catch (Exception e) { + throw new GradleException(String.format("Could not add %s to ZIP '%s'.", fileDetails, zipFile), e) + } + } else { + processArchive(fileDetails) + } + } + + private void processArchive(FileCopyDetails fileDetails) { + stats.startJar() + ZipFile archive = new ZipFile(fileDetails.file) + try { + List archiveElements = archive.entries.collect { + new ArchiveFileTreeElement(new RelativeArchivePath(it)) + } + Spec patternSpec = patternSet.getAsSpec() + List filteredArchiveElements = archiveElements.findAll { ArchiveFileTreeElement archiveElement -> + patternSpec.isSatisfiedBy(archiveElement.asFileTreeElement()) + } + filteredArchiveElements.each { ArchiveFileTreeElement archiveElement -> + if (archiveElement.relativePath.file) { + visitArchiveFile(archiveElement, archive, fileDetails) + } + } + } finally { + archive.close() + } + stats.finishJar() + } + + private void visitArchiveDirectory(RelativeArchivePath archiveDir) { + if (recordVisit(archiveDir.toString())) { + zipOutStr.putNextEntry(archiveDir.entry) + zipOutStr.closeEntry() + } + } + + private void visitArchiveFile(ArchiveFileTreeElement archiveFile, ZipFile archive, FileCopyDetails fileDetails) { + RelativeArchivePath archiveFilePath = archiveFile.relativePath + long archiveFileSize = archiveFile.size + + if (archiveFile.classFile || !isTransformable(archiveFile)) { + String path = archiveFilePath.toString() + + listModuleInfoOnDemand(path, archive, archiveFilePath) + + if (recordVisit(path, archiveFileSize, archiveFilePath) && !isUnused(archiveFilePath.entry.name)) { + if (!remapper.hasRelocators() || !archiveFile.classFile) { + copyArchiveEntry(archiveFilePath, archive) + } else { + remapClass(archiveFilePath, archive) + } + } else { + def archiveFileInVisitedFiles = visitedFiles.get(path) + if (archiveFileInVisitedFiles && (archiveFileInVisitedFiles.size != fileDetails.size)) { + // Give of only a debug-level warning for this file: + final String lowLevelWarningFile = "META-INF/MANIFEST.MF" + + final logDebug = (String msg) -> { log.debug(msg) } + final logWarn = (String msg) -> { log.warn(msg) } + + final Closure logger + if (archiveFilePath.toString() == lowLevelWarningFile) { + logger = logDebug + } else { + logger = logWarn + } + logger("IGNORING ${archiveFilePath} from ${fileDetails.relativePath}," + + " size is different (${fileDetails.size} vs ${archiveFileInVisitedFiles.size})") + if (archiveFileInVisitedFiles.originJar) { + logger("\t--> origin JAR was ${archiveFileInVisitedFiles.originJar}") + } else { + logger("\t--> file originated from project sourcecode") + } + if (new StandardFilesMergeTransformer().canTransformResource(archiveFile)) { + logger("\t--> Recommended transformer is " + StandardFilesMergeTransformer.class.name) + } + } + } + } else { + transform(archiveFile, archive) + } + } + + /** + Information about the 'module-info.class' if it isn't excluded. Including can be done with + allowModuleInfos(), like this: +

+         shadowJar {
+            ...
+            allowModuleInfos()
+         }
+         
+ Based on the discussion in issue 710: GitHub Issue #710. + */ + private void listModuleInfoOnDemand(String path, ZipFile archive, RelativeArchivePath archiveFilePath) { + if (path.endsWith(ShadowJavaPlugin.MODULE_INFO_CLASS) && allowModuleInfos) { + log.warn("======== Warning: {}/{} contains module-info - Listing content ========", + RelativePath.parse(true, archive.name).lastName, path) + + def moduleFileName = "module-info" + def moduleFileSuffix = ".class" + File disassembleModFile = File.createTempFile(moduleFileName, moduleFileSuffix) + + try (InputStream is = archive.getInputStream(archiveFilePath.entry)) { + try (OutputStream os = new FileOutputStream(disassembleModFile)) { + IOUtils.copyLarge(is, os) + } + } + + ProcessBuilder processBuilder = new ProcessBuilder("javap", disassembleModFile.absolutePath) + processBuilder.redirectErrorStream(true) + Process process = processBuilder.start() + InputStream inputStream = process.getInputStream() + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)) + + String line + while ((line = reader.readLine()) != null) { + log.warn(line) + } + + int exitCode = process.waitFor() + if (exitCode != 0) { + log.warn("Process exited with code " + exitCode) + } + + log.warn("======== module-info content listing end ========") + } + } + + private void addParentDirectories(RelativeArchivePath file) { + if (file) { + addParentDirectories(file.parent) + if (!file.file) { + visitArchiveDirectory(file) + } + } + } + + private boolean isUnused(String classPath) { + final String className = FilenameUtils.removeExtension(classPath) + .replace('/' as char, '.' as char) + final boolean result = unused.contains(className) + if (result) { + log.debug("Dropping unused class: $className") + } + return result + } + + private void remapClass(RelativeArchivePath file, ZipFile archive) { + if (file.classFile) { + ZipEntry zipEntry = setArchiveTimes(new ZipEntry(remapper.mapPath(file) + '.class')) + addParentDirectories(new RelativeArchivePath(zipEntry)) + remapClass(archive.getInputStream(file.entry), file.pathString, file.entry.time) + } + } + + private void remapClass(FileCopyDetails fileCopyDetails) { + if (FilenameUtils.getExtension(fileCopyDetails.name) == 'class') { + InputStream is = fileCopyDetails.file.newInputStream() + try { + remapClass(is, fileCopyDetails.path, fileCopyDetails.lastModified) + } finally { + is.close() + } + } + } + + /** + * Applies remapping to the given class with the specified relocation path. The remapped class is then written + * to the zip file. classInputStream is closed automatically to prevent future file leaks. + * See #364 and #408. + */ + private void remapClass(InputStream classInputStream, String path, long lastModified) { + InputStream is = classInputStream + ClassReader cr = new ClassReader(is) + + // We don't pass the ClassReader here. This forces the ClassWriter to rebuild the constant pool. + // Copying the original constant pool should be avoided because it would keep references + // to the original class names. This is not a problem at runtime (because these entries in the + // constant pool are never used), but confuses some tools such as Felix' maven-bundle-plugin + // that use the constant pool to determine the dependencies of a class. + ClassWriter cw = new ClassWriter(0) + + ClassVisitor cv = new ClassRemapper(cw, remapper) + + try { + cr.accept(cv, ClassReader.EXPAND_FRAMES) + } catch (Throwable ise) { + throw new GradleException("Error in ASM processing class " + path, ise) + } finally { + is.close() + } + + byte[] renamedClass = cw.toByteArray() + + // Temporarily remove the multi-release prefix. + String multiReleasePrefix = path.find("^META-INF/versions/\\d+/") ?: "" + path = path.replace(multiReleasePrefix, "") + String mappedName = multiReleasePrefix + remapper.mapPath(path) + + InputStream bis = new ByteArrayInputStream(renamedClass) + try { + // Now we put it back on so the class file is written out with the right extension. + ZipEntry archiveEntry = new ZipEntry(mappedName + ".class") + archiveEntry.setTime(getArchiveTimeFor(lastModified)) + zipOutStr.putNextEntry(archiveEntry) + IOUtils.copyLarge(bis, zipOutStr) + zipOutStr.closeEntry() + } catch (ZipException ignored) { + log.warn("We have a duplicate " + mappedName + " in source project") + } finally { + bis.close() + } + } + + /** + * Copy archive entry. + * + * @param archiveFile Source archive entry. + * @param archive Source archive. + */ + private void copyArchiveEntry(RelativeArchivePath archiveFile, ZipFile archive) { + String mappedPath = remapper.map(archiveFile.entry.name) + ZipEntry entry = new ZipEntry(mappedPath) + entry.setTime(getArchiveTimeFor(archiveFile.entry.time)) + RelativeArchivePath mappedFile = new RelativeArchivePath(entry) + addParentDirectories(mappedFile) + zipOutStr.putNextEntry(mappedFile.entry) + InputStream is = archive.getInputStream(archiveFile.entry) + try { + IOUtils.copyLarge(is, zipOutStr) + } finally { + is.close() + } + zipOutStr.closeEntry() + } + + @Override + protected void visitDir(FileCopyDetails dirDetails) { + try { + // Trailing slash in name indicates that entry is a directory + String path = dirDetails.relativePath.pathString + '/' + ZipEntry archiveEntry = new ZipEntry(path) + archiveEntry.setTime(getArchiveTimeFor(dirDetails.lastModified)) + archiveEntry.unixMode = (UnixStat.DIR_FLAG | dirDetails.permissions.toUnixNumeric()) + zipOutStr.putNextEntry(archiveEntry) + zipOutStr.closeEntry() + recordVisit(dirDetails.relativePath) + } catch (Exception e) { + throw new GradleException(String.format("Could not add %s to ZIP '%s'.", dirDetails, zipFile), e) + } + } + + private void transform(ArchiveFileTreeElement element, ZipFile archive) { + transformAndClose(element, archive, archive.getInputStream(element.relativePath.entry)) + } + + private void transform(FileCopyDetails details) { + transformAndClose(details, null, details.file.newInputStream()) + } + + private void transformAndClose(FileTreeElement element, @Nullable ZipFile archive, InputStream is) { + try { + String mappedPath = remapper.map(element.relativePath.pathString) + transformers.find { it.canTransformResource(element) }.transform( + TransformerContext.builder() + .path(mappedPath) + .origin(archive) + .is(is) + .relocators(relocators) + .stats(stats) + .build() + ) + } finally { + is.close() + } + } + + private boolean isTransformable(FileTreeElement element) { + return transformers.any { it.canTransformResource(element) } + } + + } + + class RelativeArchivePath extends RelativePath { + + ZipEntry entry + + RelativeArchivePath(ZipEntry entry) { + super(!entry.directory, entry.name.split('/')) + this.entry = entry + } + + boolean isClassFile() { + return lastName.endsWith('.class') + } + + @Override + RelativeArchivePath getParent() { + if (!segments || segments.length == 1) { + return null + } else { + //Parent is always a directory so add / to the end of the path + String path = segments[0..-2].join('/') + '/' + return new RelativeArchivePath(setArchiveTimes(new ZipEntry(path))) + } + } + } + + class ArchiveFileTreeElement implements FileTreeElement { + + private final RelativeArchivePath archivePath + + ArchiveFileTreeElement(RelativeArchivePath archivePath) { + this.archivePath = archivePath + } + + boolean isClassFile() { + return archivePath.classFile + } + + @Override + File getFile() { + return null + } + + @Override + boolean isDirectory() { + return archivePath.entry.directory + } + + @Override + long getLastModified() { + return archivePath.entry.lastModifiedDate.time + } + + @Override + long getSize() { + return archivePath.entry.size + } + + @Override + InputStream open() { + return null + } + + @Override + void copyTo(OutputStream outputStream) { + + } + + @Override + boolean copyTo(File file) { + return false + } + + @Override + String getName() { + return archivePath.pathString + } + + @Override + String getPath() { + return archivePath.lastName + } + + @Override + RelativeArchivePath getRelativePath() { + return archivePath + } + + @Override + int getMode() { + return archivePath.entry.unixMode + } + + @Override + FilePermissions getPermissions() { + return new DefaultFilePermissions(getMode()) + } + + FileTreeElement asFileTreeElement() { + return new DefaultFileTreeElement(null, new RelativePath(!isDirectory(), archivePath.segments), null, null) + } + } +} diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.java b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.java new file mode 100644 index 000000000..31620d3d3 --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.java @@ -0,0 +1,523 @@ +package com.github.jengelman.gradle.plugins.shadow.tasks; + +import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin; +import com.github.jengelman.gradle.plugins.shadow.ShadowStats; +import com.github.jengelman.gradle.plugins.shadow.internal.DefaultDependencyFilter; +import com.github.jengelman.gradle.plugins.shadow.internal.DependencyFilter; +import com.github.jengelman.gradle.plugins.shadow.internal.GradleVersionUtil; +import com.github.jengelman.gradle.plugins.shadow.internal.MinimizeDependencyFilter; +import com.github.jengelman.gradle.plugins.shadow.internal.RelocationUtil; +import com.github.jengelman.gradle.plugins.shadow.internal.UnusedTracker; +import com.github.jengelman.gradle.plugins.shadow.internal.ZipCompressor; +import com.github.jengelman.gradle.plugins.shadow.relocation.CacheableRelocator; +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator; +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator; +import com.github.jengelman.gradle.plugins.shadow.transformers.AppendingTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.CacheableTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.GroovyExtensionModuleTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.ServiceFileTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.StandardFilesMergeTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.Transformer; +import org.gradle.api.Action; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DuplicatesStrategy; +import org.gradle.api.file.FileCollection; +import org.gradle.api.internal.DocumentationRegistry; +import org.gradle.api.internal.file.FileResolver; +import org.gradle.api.internal.file.copy.CopyAction; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.util.PatternSet; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; + +@CacheableTask +public class ShadowJar extends Jar implements ShadowSpec { + + private List transformers; + private List relocators; + private List configurations; + private transient DependencyFilter dependencyFilter; + private boolean enableRelocation; + private String relocationPrefix = "shadow"; + private boolean minimizeJar; + private final transient DependencyFilter dependencyFilterForMinimize; + private FileCollection toMinimize; + private FileCollection apiJars; + private FileCollection sourceSetsClassesDirs; + + private final ShadowStats shadowStats = new ShadowStats(); + + private final ConfigurableFileCollection includedDependencies = getProject().files(new Callable() { + + @Override + public FileCollection call() { + return dependencyFilter.resolve(configurations); + } + }); + + private boolean isAllowModuleInfos; + + public ShadowJar() { + super(); + setDuplicatesStrategy( + DuplicatesStrategy.INCLUDE); //shadow filters out files later. This was the default behavior in Gradle < 6.x + dependencyFilter = new DefaultDependencyFilter(getProject()); + dependencyFilterForMinimize = new MinimizeDependencyFilter(getProject()); + setManifest(new DefaultInheritManifest(getProject(), getServices().get(FileResolver.class))); + /* + Add as default the StandardFilesMergeTransformer, remove it with "removeDefaultTransformers()". + This is added by default, because otherwise: + a) In projects with many dependencies the user gets flooded with information about duplicated entries + like "META-INF/notice.txt", "META-INF/license.txt"... + b) Important licensing information written in META-INF/license.txt and other files may be lost. + c) Helpful information written in readme files may be lost. + d) The merging of plain text files is safe, there is no important logic to follow. Not like MANIFEST.MF, + property files, xml files, etc. Merged HTML may not look that good, but it works. + */ + transformers = new ArrayList<>(Collections.singletonList(new StandardFilesMergeTransformer())); + relocators = new ArrayList<>(); + configurations = new ArrayList<>(); + + this.getInputs().property("minimize", (Callable) () -> minimizeJar); + this.getOutputs().doNotCacheIf("Has one or more transforms or relocators that are not cacheable", + task -> { + for (Transformer transformer : transformers) { + if (!isCacheableTransform(transformer.getClass())) { + return true; + } + } + for (Relocator relocator : relocators) { + if (!isCacheableRelocator(relocator.getClass())) { + return true; + } + } + return false; + }); + } + + @Override + public ShadowJar minimize() { + minimizeJar = true; + return this; + } + + @Override + public ShadowJar minimize(Action c) { + minimize(); + if (c != null) { + c.execute(dependencyFilterForMinimize); + } + return this; + } + + @Override + @Internal + public ShadowStats getStats() { + return shadowStats; + } + + @Override + public InheritManifest getManifest() { + return (InheritManifest) super.getManifest(); + } + + @Override + @NotNull + protected CopyAction createCopyAction() { + DocumentationRegistry documentationRegistry = getServices().get(DocumentationRegistry.class); + final UnusedTracker unusedTracker = minimizeJar ? UnusedTracker.forProject(getApiJars(), + getSourceSetsClassesDirs().getFiles(), getToMinimize()) : null; + return new ShadowCopyAction(getArchiveFile().get().getAsFile(), getInternalCompressor(), documentationRegistry, + this.getMetadataCharset(), transformers, relocators, getRootPatternSet(), shadowStats, + isPreserveFileTimestamps(), minimizeJar, unusedTracker, isAllowModuleInfos); + } + + @Classpath + FileCollection getToMinimize() { + if (toMinimize == null) { + toMinimize = minimizeJar + ? dependencyFilterForMinimize.resolve(configurations).minus(getApiJars()) + : getProject().getObjects().fileCollection(); + } + return toMinimize; + } + + @Classpath + FileCollection getApiJars() { + if (apiJars == null) { + apiJars = minimizeJar + ? UnusedTracker.getApiJarsFromProject(getProject()) + : getProject().getObjects().fileCollection(); + } + return apiJars; + } + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + FileCollection getSourceSetsClassesDirs() { + if (sourceSetsClassesDirs == null) { + ConfigurableFileCollection allClassesDirs = getProject().getObjects().fileCollection(); + if (minimizeJar) { + for (SourceSet sourceSet : getProject().getExtensions().getByType(SourceSetContainer.class)) { + FileCollection classesDirs = sourceSet.getOutput().getClassesDirs(); + allClassesDirs.from(classesDirs); + } + } + sourceSetsClassesDirs = allClassesDirs.filter(File::isDirectory); + } + return sourceSetsClassesDirs; + } + + @Internal + protected ZipCompressor getInternalCompressor() { + return GradleVersionUtil.getInternalCompressor(getEntryCompression(), this); + } + + @TaskAction + @Override + protected void copy() { + if (enableRelocation) { + RelocationUtil.configureRelocation(this, relocationPrefix); + } + from(getIncludedDependencies()); + super.copy(); + getLogger().info(shadowStats.toString()); + } + + @Classpath + public FileCollection getIncludedDependencies() { + return includedDependencies; + } + + /** + * Utility method for assisting between changes in Gradle 1.12 and 2.x. + * + * @return this + */ + @Internal + protected PatternSet getRootPatternSet() { + return GradleVersionUtil.getRootPatternSet(getMainSpec()); + } + + /** + * Configure inclusion/exclusion of module and project dependencies into uber jar. + * + * @param c the configuration of the filter + * @return this + */ + @Override + public ShadowJar dependencies(Action c) { + if (c != null) { + c.execute(dependencyFilter); + } + return this; + } + + /** + * Add a Transformer instance for modifying JAR resources and configure. + * + * @param clazz the transformer to add. Must have a no-arg constructor + * @return this + */ + @Override + public ShadowJar transform(Class clazz) throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { + return transform(clazz, null); + } + + /** + * Add a Transformer instance for modifying JAR resources and configure. + * + * @param clazz the transformer class to add. Must have no-arg constructor + * @param c the configuration for the transformer + * @return this + */ + @Override + public ShadowJar transform(Class clazz, Action c) + throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { + T transformer = clazz.getDeclaredConstructor().newInstance(); + addTransform(transformer, c); + return this; + } + + /** + * Removes all default transformers. + *
Right now only {@link StandardFilesMergeTransformer} is added as default transformer, this method removes + * it. + * + * @return this + */ + public ShadowJar removeDefaultTransformers() { + final java.util.Optional standardFilesMergeTransformer = transformers.stream() // + .filter(StandardFilesMergeTransformer.class::isInstance) // + .findAny(); + standardFilesMergeTransformer.ifPresent(transformer -> transformers.remove(transformer)); + return this; + } + + /** + * Allows module-info.class's to be included in the final jar, and informs about the contents + * of the module-info.class files it finds. + * + * @return this + */ + public ShadowJar allowModuleInfos() { + this.isAllowModuleInfos = true; + getExcludes().remove(ShadowJavaPlugin.MODULE_INFO_CLASS); + return this; + } + + @Internal + public boolean isAllowModuleInfos() { + return isAllowModuleInfos; + } + + private boolean isCacheableTransform(Class clazz) { + return clazz.isAnnotationPresent(CacheableTransformer.class); + } + + /** + * Add a preconfigured transformer instance. + * + * @param transformer the transformer instance to add + * @return this + */ + @Override + public ShadowJar transform(Transformer transformer) { + addTransform(transformer, null); + return this; + } + + private void addTransform(T transformer, Action c) { + if (c != null) { + c.execute(transformer); + } + + transformers.add(transformer); + } + + /** + * Syntactic sugar for merging service files in JARs. + * + * @return this + */ + @Override + public ShadowJar mergeServiceFiles() { + try { + transform(ServiceFileTransformer.class); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | + InstantiationException ignored) { + } + return this; + } + + /** + * Syntactic sugar for merging service files in JARs. + * + * @return this + */ + @Override + public ShadowJar mergeServiceFiles(final String rootPath) { + try { + transform(ServiceFileTransformer.class, serviceFileTransformer -> serviceFileTransformer.setPath(rootPath)); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | + InstantiationException ignored) { + } + return this; + } + + /** + * Syntactic sugar for merging service files in JARs. + * + * @return this + */ + @Override + public ShadowJar mergeServiceFiles(Action configureClosure) { + try { + transform(ServiceFileTransformer.class, configureClosure); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | + InstantiationException ignored) { + } + return this; + } + + /** + * Syntactic sugar for merging Groovy extension module descriptor files in JARs + * + * @return this + */ + @Override + public ShadowJar mergeGroovyExtensionModules() { + try { + transform(GroovyExtensionModuleTransformer.class); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | + InstantiationException ignored) { + } + return this; + } + + /** + * Syntax sugar for merging service files in JARs + * + * @return this + */ + @Override + public ShadowJar append(final String resourcePath) { + try { + transform(AppendingTransformer.class, transformer -> transformer.setResource(resourcePath)); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | + InstantiationException ignored) { + } + return this; + } + + /** + * Add a class relocator that maps each class in the pattern to the provided destination. + * + * @param pattern the source pattern to relocate + * @param destination the destination package + * @return this + */ + @Override + public ShadowJar relocate(String pattern, String destination) { + return relocate(pattern, destination, null); + } + + /** + * Add a class relocator that maps each class in the pattern to the provided destination. + * + * @param pattern the source pattern to relocate + * @param destination the destination package + * @param configure the configuration of the relocator + * @return this + */ + @Override + public ShadowJar relocate(String pattern, String destination, Action configure) { + SimpleRelocator relocator = new SimpleRelocator(pattern, destination, new ArrayList(), + new ArrayList()); + addRelocator(relocator, configure); + return this; + } + + /** + * Add a relocator instance. + * + * @param relocator the relocator instance to add + * @return this + */ + @Override + public ShadowJar relocate(Relocator relocator) { + addRelocator(relocator, null); + return this; + } + + /** + * Add a relocator of the provided class. + * + * @param relocatorClass the relocator class to add. Must have a no-arg constructor. + * @return this + */ + @Override + public ShadowJar relocate(Class relocatorClass) + throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { + return relocate(relocatorClass, null); + } + + private void addRelocator(R relocator, Action configure) { + if (configure != null) { + configure.execute(relocator); + } + + relocators.add(relocator); + } + + /** + * Add a relocator of the provided class and configure. + * + * @param relocatorClass the relocator class to add. Must have a no-arg constructor + * @param configure the configuration for the relocator + * @return this + */ + @Override + public ShadowJar relocate(Class relocatorClass, Action configure) + throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { + R relocator = relocatorClass.getDeclaredConstructor().newInstance(); + addRelocator(relocator, configure); + return this; + } + + private boolean isCacheableRelocator(Class relocatorClass) { + return relocatorClass.isAnnotationPresent(CacheableRelocator.class); + } + + @Nested + public List getTransformers() { + return this.transformers; + } + + public void setTransformers(List transformers) { + this.transformers = transformers; + } + + @Nested + public List getRelocators() { + return this.relocators; + } + + public void setRelocators(List relocators) { + this.relocators = relocators; + } + + @Classpath @Optional + public List getConfigurations() { + return this.configurations; + } + + public void setConfigurations(List configurations) { + this.configurations = configurations; + } + + @Internal + public DependencyFilter getDependencyFilter() { + return this.dependencyFilter; + } + + public void setDependencyFilter(DependencyFilter filter) { + this.dependencyFilter = filter; + } + + @Input + public boolean isEnableRelocation() { + return enableRelocation; + } + + public void setEnableRelocation(boolean enableRelocation) { + this.enableRelocation = enableRelocation; + } + + @Input + public String getRelocationPrefix() { + return relocationPrefix; + } + + public void setRelocationPrefix(String relocationPrefix) { + this.relocationPrefix = relocationPrefix; + } +} diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonTransformer.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonTransformer.groovy new file mode 100644 index 000000000..810a34988 --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonTransformer.groovy @@ -0,0 +1,158 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import org.gradle.api.file.FileTreeElement +import org.gradle.api.logging.Logging +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional + +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive +import com.google.gson.JsonParser +import com.google.gson.Gson + +import org.apache.tools.zip.ZipOutputStream +import org.apache.tools.zip.ZipEntry + +/** + * Merge multiple occurrence of JSON files. + * + * @author Logic Fan, extended to process an array of files by Jan-Hendrik Diederich + */ +@CacheableTransformer +class JsonTransformer implements Transformer { + private static final GSON = new Gson() + private static final LOGGER = Logging.getLogger(JsonTransformer.class) + + @Optional + @Input + List paths + + private Map matchedPath = [:] + + @Override + boolean canTransformResource(FileTreeElement element) { + String path = element.relativePath.pathString + for (p in paths) { + if (path.equalsIgnoreCase(p)) { + matchedPath[path] = null + return true + } + } + return false + } + + @Override + void transform(TransformerContext context) { + String path = context.getPath() + final JsonElement j + try { + j = JsonParser.parseReader(new InputStreamReader(context.is, "UTF-8")) + } catch (Exception e) { + throw new RuntimeException("error on processing json", e) + } + + matchedPath[path] = (matchedPath[path] == null) ? j : mergeJson(matchedPath[path], j) + } + + @Override + boolean hasTransformedResource() { + return !matchedPath.isEmpty() + } + + @Override + void modifyOutputStream(ZipOutputStream os, boolean preserveFileTimestamps) { + if (paths == null) { + throw new IllegalArgumentException("\"paths\" is null and not set") + } + for (Map.Entry entrySet in matchedPath) { + ZipEntry entry = new ZipEntry(entrySet.key) + entry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, entry.time) + os.putNextEntry(entry) + os.write(GSON.toJson(entrySet.value).getBytes()) + } + matchedPath = [:] + } + + /** + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
{@code lhs} {@code rhs} {@code return}
Any {@code JsonNull} {@code lhs}
{@code JsonNull} Any {@code rhs}
{@code JsonArray} {@code JsonArray} concatenation
{@code JsonObject} {@code JsonObject} merge for each key
{@code JsonPrimitive} {@code JsonPrimitive}return lhs if {@code lhs.equals(rhs)}, error otherwise
Other error
+ * @param lhs a {@code JsonElement} + * @param rhs a {@code JsonElement} + * @param id used for logging purpose only + * @return the merged {@code JsonElement} + */ + private static JsonElement mergeJson(JsonElement lhs, JsonElement rhs, String id = "") { + if (rhs == null || rhs instanceof JsonNull) { + return lhs + } else if (lhs == null || lhs instanceof JsonNull) { + return rhs + } else if (lhs instanceof JsonArray && rhs instanceof JsonArray) { + return mergeJsonArray(lhs as JsonArray, rhs as JsonArray) + } else if (lhs instanceof JsonObject && rhs instanceof JsonObject) { + return mergeJsonObject(lhs as JsonObject, rhs as JsonObject, id) + } else if (lhs instanceof JsonPrimitive && rhs instanceof JsonPrimitive) { + return mergeJsonPrimitive(lhs as JsonPrimitive, rhs as JsonPrimitive, id) + } else { + LOGGER.warn("conflicts for property {} detected, {} & {}", + id, lhs.toString(), rhs.toString()) + return lhs + } + } + + private static JsonPrimitive mergeJsonPrimitive(JsonPrimitive lhs, JsonPrimitive rhs, String id) { + // In Groovy, {@code a == b} is equivalent to {@code a.equals(b)} + if (lhs != rhs) { + LOGGER.warn("conflicts for property {} detected, {} & {}", + id, lhs.toString(), rhs.toString()) + } + return lhs + } + + private static JsonObject mergeJsonObject(JsonObject lhs, JsonObject rhs, String id) { + JsonObject object = new JsonObject() + + Set properties = new HashSet<>() + properties.addAll(lhs.keySet()) + properties.addAll(rhs.keySet()) + for (String property : properties) { + object.add(property, + mergeJson(lhs.get(property), rhs.get(property), id + ":" + property)) + } + + return object + } + + private static JsonArray mergeJsonArray(JsonArray lhs, JsonArray rhs) { + JsonArray array = new JsonArray() + + array.addAll(lhs) + array.addAll(rhs) + + return array + } +} \ No newline at end of file diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformer.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformer.groovy new file mode 100644 index 000000000..72c1639d2 --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformer.groovy @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License") you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.github.jengelman.gradle.plugins.shadow.transformers + + +import org.apache.commons.io.FilenameUtils +import org.apache.commons.io.IOUtils +import org.apache.tools.zip.ZipEntry +import org.apache.tools.zip.ZipOutputStream +import org.codehaus.plexus.util.IOUtil +import org.gradle.api.file.FileTreeElement + +/** + * Merges standard files, like "META-INF/license.txt", "META-INF/notice.txt", "readme.txt" into one, + * writing as prefix where the content comes from, so no license information or important hints gets lost. + * + * @author Jan-Hendrik Diederich + */ +@CacheableTransformer +class StandardFilesMergeTransformer implements Transformer { + private class StandardFile implements Serializable { + List origins = new ArrayList<>(); + String content; + + StandardFile(String origin, String content) { + this.origins.add(origin) + this.content = content + } + } + + private final List mergedFiles = [ + "META-INF/license", // + "META-INF/notice", // + "META-INF/readme", // + "readme", // + ] + + private final List fileExtensions = [ + "txt", "md", "htm", "html" + ] + + // Can't use normal HashMap, ...notice.txt and ...NOTICE.txt would otherwise be different entries. + private Map> fileEntries = new TreeMap<>(String.CASE_INSENSITIVE_ORDER) + + @Override + boolean canTransformResource(FileTreeElement element) { + String path = element.relativePath.pathString + mergedFiles.stream() // + .anyMatch(mergeFile -> { + if (path.equalsIgnoreCase(mergeFile)) { + return true + } else { + for (extension in fileExtensions) { + if (path.equalsIgnoreCase(mergeFile + "." + extension)) { + return true + } + } + return false + } + }) + } + + @Override + void transform(TransformerContext context) { + List files = fileEntries.computeIfAbsent(context.path, key -> new ArrayList<>()) + + OutputStream outputStream = new ByteArrayOutputStream() + IOUtils.copyLarge(context.is, outputStream) + + def fileContent = outputStream.toString() + // Remove leading and trailing newlines. Don't trim whitespaces, so centered headers stay centered. + def trimmedFileContent = fileContent.replaceAll("^[\\r\\n]+|[\\r\\n]+\$", "") + + var standardFile = files.stream() // + .filter(entry -> trimmedFileContent.equalsIgnoreCase(entry.content)) // + .findAny() + String originName = context.origin != null + ? FilenameUtils.getName(context.origin.name) + : "Sourcecode" + if (standardFile.isPresent()) { + standardFile.get().origins.add(originName) + } else { + files.add(new StandardFile(originName, trimmedFileContent)) + } + } + + @Override + boolean hasTransformedResource() { + return fileEntries.size() > 0 + } + + @Override + void modifyOutputStream(ZipOutputStream os, boolean preserveFileTimestamps) { + fileEntries.each { String path, List files -> + ZipEntry entry = new ZipEntry(path) + entry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, entry.time) + os.putNextEntry(entry) + IOUtil.copy(toInputStream(files), os) + os.closeEntry() + } + } + + private static InputStream toInputStream(List entries) { + String joined = entries.stream() // + .map(entry -> "Origins: " + entry.origins.sort().join(", ") // + + "\n\n" + entry.content) // + .collect() // + .join("\n" + "=".repeat(80) + "\n") + new ByteArrayInputStream(joined.getBytes()) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext.groovy b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext.groovy new file mode 100644 index 000000000..d5dfc956b --- /dev/null +++ b/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext.groovy @@ -0,0 +1,27 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import com.github.jengelman.gradle.plugins.shadow.ShadowStats +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowCopyAction +import groovy.transform.Canonical +import groovy.transform.builder.Builder +import org.apache.tools.zip.ZipFile + +import javax.annotation.Nullable + +@Canonical +@Builder +class TransformerContext { + + String path + InputStream is + List relocators + ShadowStats stats + + @Nullable + ZipFile origin + + static long getEntryTimestamp(boolean preserveFileTimestamps, long entryTime) { + preserveFileTimestamps ? entryTime : ShadowCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES + } +} diff --git a/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowPluginSpec.groovy b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowPluginSpec.groovy new file mode 100644 index 000000000..017e2c40f --- /dev/null +++ b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/ShadowPluginSpec.groovy @@ -0,0 +1,1333 @@ +package com.github.jengelman.gradle.plugins.shadow + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import com.github.jengelman.gradle.plugins.shadow.util.PluginSpecification +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.plugins.JavaPlugin +import org.gradle.testfixtures.ProjectBuilder +import org.gradle.testkit.runner.BuildResult +import spock.lang.Ignore +import spock.lang.IgnoreIf +import spock.lang.Issue +import spock.lang.Unroll + +import java.util.jar.Attributes +import java.util.jar.JarFile + +class ShadowPluginSpec extends PluginSpecification { + + def 'apply plugin'() { + given: + String projectName = 'myshadow' + String version = '1.0.0' + + Project project = ProjectBuilder.builder().withName(projectName).build() + project.version = version + + when: + project.plugins.apply(ShadowPlugin) + + then: + project.plugins.hasPlugin(ShadowPlugin) + project.plugins.hasPlugin(com.github.jengelman.gradle.plugins.shadow.legacy.LegacyShadowPlugin) + + and: + assert !project.tasks.findByName('shadowJar') + + when: + project.plugins.apply(JavaPlugin) + + then: + ShadowJar shadow = project.tasks.findByName('shadowJar') + assert shadow + assert shadow.archiveBaseName.get() == projectName + assert shadow.destinationDirectory.get().asFile == new File(project.layout.buildDirectory.asFile.get(), 'libs') + assert shadow.archiveVersion.get() == version + assert shadow.archiveClassifier.get() == 'all' + assert shadow.archiveExtension.get() == 'jar' + + and: + Configuration shadowConfig = project.configurations.findByName('shadow') + assert shadowConfig + shadowConfig.artifacts.file.contains(shadow.archiveFile.get().asFile) + + } + + @IgnoreIf({ + // Gradle 8.3 doesn't support Java 21. + JavaVersion.current().majorVersion.toInteger() >= 21 + }) + @Unroll + def 'Compatible with Gradle #version'() { + given: + File one = buildJar('one.jar').insertFile('META-INF/services/shadow.Shadow', + 'one # NOTE: No newline terminates this line/file').write() + + repo.module('shadow', 'two', '1.0').insertFile('META-INF/services/shadow.Shadow', + 'two # NOTE: No newline terminates this line/file').publish() + + buildFile << """ + dependencies { + implementation 'junit:junit:3.8.2' + implementation files('${escapedPath(one)}') + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + mergeServiceFiles() + } + """.stripIndent() + + when: + run(['shadowJar']) { + it.withGradleVersion(version) + it.withDebug(true) + it.withTestKitDir(getTestKitDir()) + } + + then: + assert output.exists() + + where: + version << ['8.3'] + } + + def 'Error in Gradle versions < 8.3'() { + given: + buildFile << """ + dependencies { + implementation 'junit:junit:3.8.2' + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + mergeServiceFiles() + } + """.stripIndent() + + expect: + runWithFailure(['shadowJar']) { + it.withGradleVersion('7.0') + it.withDebug(true) + it.withTestKitDir(getTestKitDir()) + } + } + + def 'shadow copy'() { + given: + URL artifact = this.class.classLoader.getResource('test-artifact-1.0-SNAPSHOT.jar') + URL project = this.class.classLoader.getResource('test-project-1.0-SNAPSHOT.jar') + + buildFile << """ + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + from('${artifact.path}') + from('${project.path}') + } + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + } + + def 'warns when a file is masked by a previously shadowed resource'() { + given: + URL artifact = this.class.classLoader.getResource('test-artifact-1.0-SNAPSHOT.jar') + URL project = this.class.classLoader.getResource('test-project-1.0-SNAPSHOT.jar') + + buildFile << """ + |task shadow(type: ${ShadowJar.name}) { + | destinationDirectory = buildDir + | archiveBaseName = 'shadow' + | from('${artifact.path}') + | from('${project.path}') + |} + """.stripMargin() + + when: + BuildResult result = run('shadow') + + then: + assert result.output =~ /\s*IGNORING Weird-File\.StrangeFormat from test-project-1\.0-SNAPSHOT\.jar,/ + / size is different \([0-9]{4} vs [0-9]{2}\)\s+--> origin JAR was Weird-File.StrangeFormat/ + + /* Shouldn't appear, because the default StandardFileTransformer should've merged it, + instead of just dropping all following licenses. */ + assert !result.output.contains('license.txt') + // No module listing should appear. module-info.class is excluded by default. + assert !result.output.contains('open module org.example') + } + + def 'Tests the removal of the default transformer'() { + given: + URL artifact = this.class.classLoader.getResource('test-artifact-1.0-SNAPSHOT.jar') + URL project = this.class.classLoader.getResource('test-project-1.0-SNAPSHOT.jar') + + buildFile << """ + |task shadow(type: ${ShadowJar.name}) { + | destinationDirectory = buildDir + | archiveBaseName = 'shadow' + | removeDefaultTransformers() + | from('${artifact.path}') + | from('${project.path}') + |} + """.stripMargin() + + when: + BuildResult result = run('shadow') + + then: + assert result.output =~ /\s*IGNORING Weird-File\.StrangeFormat from test-project-1\.0-SNAPSHOT\.jar,/ + / size is different \([0-9]{4} vs [0-9]{2}\)\s+--> origin JAR was Weird-File.StrangeFormat/ + /\s+IGNORING test\.json from test-project-1\.0-SNAPSHOT.jar, size is different/ + // Without the StandardFileTransformer there should be a warning about multiple license files with the same name. + assert result.output.contains('license.txt') + // No module listing should appear. module-info.class is excluded by default. + assert !result.output.contains('open module org.example') + } + + def 'Tests the info about the module-info.class, if removed from standard excludes'() { + given: + URL artifact = this.class.classLoader.getResource('test-artifact-1.0-SNAPSHOT.jar') + URL project = this.class.classLoader.getResource('test-project-1.0-SNAPSHOT.jar') + + buildFile << """ + |task shadow(type: ${ShadowJar.name}) { + | destinationDirectory = buildDir + | archiveBaseName = 'shadow' + | allowModuleInfos() + | from('${artifact.path}') + | from('${project.path}') + |} + """.stripMargin() + + when: + BuildResult result = run('shadow') + + then: + assert result.output.contains('module-info.class') + // Because allowModuleInfos() is used, the module listing should appear. + assert result.output.contains('open module org.example') + } + + def 'include project sources'() { + given: + file('src/main/java/shadow/Passed.java') << ''' + package shadow; + public class Passed {} + '''.stripIndent() + + buildFile << """ + dependencies { implementation 'junit:junit:3.8.2' } + + // tag::rename[] + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveBaseName = 'shadow' + archiveClassifier = null + archiveVersion = null + archiveVersion.convention(null) + } + // end::rename[] + """.stripIndent() + + when: + run('shadowJar') + + then: + contains(output("shadow.jar"), ['shadow/Passed.class', 'junit/framework/Test.class']) + + and: + doesNotContain(output("shadow.jar"), ['/']) + } + + def 'include project dependencies'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + + import client.Client; + + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = getFile('server/build/libs/server-all.jar') + + when: + run(':server:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'client/Client.class', + 'server/Server.class', + 'junit/framework/Test.class' + ]) + } + + /** + * 'Server' depends on 'Client'. 'junit' is independent. + * The minimize shall remove 'junit'. + */ + def 'minimize by keeping only transitive dependencies'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + + import client.Client; + + public class Server { + private final String client = Client.class.getName(); + } + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize() + } + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = getFile('server/build/libs/server-all.jar') + + when: + runWithDebug(':server:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'client/Client.class', + 'server/Server.class' + ]) + doesNotContain(serverOutput, ['junit/framework/Test.class']) + } + + /** + * 'Client', 'Server' and 'junit' are independent. + * 'junit' is excluded from the minimize. + * The minimize shall remove 'Client' but not 'junit'. + */ + def 'exclude a dependency from minimize'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + exclude(dependency('junit:junit:.*')) + } + } + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = getFile('server/build/libs/server-all.jar') + + when: + runWithDebug(':server:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'server/Server.class', + 'junit/framework/Test.class' + ]) + doesNotContain(serverOutput, ['client/Client.class']) + } + + /** + * 'Client', 'Server' and 'junit' are independent. + * Unused classes of 'client' and theirs dependencies shouldn't be removed. + */ + def 'exclude a project from minimize '() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + exclude(project(':client')) + } + } + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = file('server/build/libs/server-all.jar') + + when: + runWithDebug(':server:shadowJar') + + then: + contains(serverOutput, [ + 'client/Client.class', + 'server/Server.class' + ]) + } + + /** + * 'Client', 'Server' and 'junit' are independent. + * Unused classes of 'client' and theirs dependencies shouldn't be removed. + */ + def 'exclude a project from minimize - shall not exclude transitive dependencies that are used in subproject'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + import junit.framework.TestCase; + public class Client extends TestCase { + public static void main(String[] args) {} + } + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + exclude(project(':client')) + } + } + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = file('server/build/libs/server-all.jar') + + when: + runWithDebug(':server:shadowJar') + + then: + contains(serverOutput, [ + 'client/Client.class', + 'server/Server.class', + 'junit/framework/TestCase.class' + ]) + } + + /** + * 'Client', 'Server' and 'junit' are independent. + * Unused classes of 'client' and theirs dependencies shouldn't be removed. + */ + def 'exclude a project from minimize - shall not exclude transitive dependencies from subproject that are not used'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client { } + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize { + exclude(project(':client')) + } + } + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(':client') } + """.stripIndent() + + File serverOutput = file('server/build/libs/server-all.jar') + + when: + runWithDebug(':server:shadowJar') + + then: + contains(serverOutput, [ + 'client/Client.class', + 'server/Server.class', + 'junit/framework/TestCase.class' + ]) + } + + + /** + * 'api' used as api for 'impl', and depended on 'lib'. 'junit' is independent. + * The minimize shall remove 'junit', but not 'api'. + * Unused classes of 'api' and theirs dependencies also shouldn't be removed. + */ + def 'use minimize with dependencies with api scope'() { + given: + file('settings.gradle') << """ + include 'api', 'lib', 'impl' + """.stripIndent() + + file('lib/src/main/java/lib/LibEntity.java') << """ + package lib; + public interface LibEntity {} + """.stripIndent() + + file('lib/src/main/java/lib/UnusedLibEntity.java') << """ + package lib; + public class UnusedLibEntity implements LibEntity {} + """.stripIndent() + + file('lib/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + """.stripIndent() + + file('api/src/main/java/api/Entity.java') << """ + package api; + public interface Entity {} + """.stripIndent() + + file('api/src/main/java/api/UnusedEntity.java') << """ + package api; + import lib.LibEntity; + public class UnusedEntity implements LibEntity {} + """.stripIndent() + + file('api/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + dependencies { + implementation 'junit:junit:3.8.2' + implementation project(':lib') + } + """.stripIndent() + + file('impl/src/main/java/impl/SimpleEntity.java') << """ + package impl; + import api.Entity; + public class SimpleEntity implements Entity {} + """.stripIndent() + + file('impl/build.gradle') << """ + apply plugin: 'java-library' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize() + } + + repositories { maven { url "${repo.uri}" } } + dependencies { api project(':api') } + """.stripIndent() + + File serverOutput = getFile('impl/build/libs/impl-all.jar') + + when: + runWithDebug(':impl:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'impl/SimpleEntity.class', + 'api/Entity.class', + 'api/UnusedEntity.class', + 'lib/LibEntity.class', + ]) + doesNotContain(serverOutput, ['junit/framework/Test.class', 'lib/UnusedLibEntity.class']) + } + + /** + * 'api' used as api for 'impl', and 'lib' used as api for 'api'. + * Unused classes of 'api' and 'lib' shouldn't be removed. + */ + def 'use minimize with transitive dependencies with api scope'() { + given: + file('settings.gradle') << """ + include 'api', 'lib', 'impl' + """.stripIndent() + + file('lib/src/main/java/lib/LibEntity.java') << """ + package lib; + public interface LibEntity {} + """.stripIndent() + + file('lib/src/main/java/lib/UnusedLibEntity.java') << """ + package lib; + public class UnusedLibEntity implements LibEntity {} + """.stripIndent() + + file('lib/build.gradle') << """ + apply plugin: 'java' + repositories { maven { url "${repo.uri}" } } + """.stripIndent() + + file('api/src/main/java/api/Entity.java') << """ + package api; + public interface Entity {} + """.stripIndent() + + file('api/src/main/java/api/UnusedEntity.java') << """ + package api; + import lib.LibEntity; + public class UnusedEntity implements LibEntity {} + """.stripIndent() + + file('api/build.gradle') << """ + apply plugin: 'java-library' + repositories { maven { url "${repo.uri}" } } + dependencies { api project(':lib') } + """.stripIndent() + + file('impl/src/main/java/impl/SimpleEntity.java') << """ + package impl; + import api.Entity; + public class SimpleEntity implements Entity {} + """.stripIndent() + + file('impl/build.gradle') << """ + apply plugin: 'java-library' + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + minimize() + } + + repositories { maven { url "${repo.uri}" } } + dependencies { api project(':api') } + """.stripIndent() + + File serverOutput = getFile('impl/build/libs/impl-all.jar') + + when: + runWithDebug(':impl:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'impl/SimpleEntity.class', + 'api/Entity.class', + 'api/UnusedEntity.class', + 'lib/LibEntity.class', + 'lib/UnusedLibEntity.class' + ]) + } + + def 'depend on project shadow jar'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + relocate 'junit.framework', 'client.junit.framework' + } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + + import client.Client; + import client.junit.framework.Test; + + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(path: ':client', configuration: 'shadow') } + """.stripIndent() + + File serverOutput = getFile('server/build/libs/server.jar') + + when: + run(':server:jar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'server/Server.class' + ]) + + and: + doesNotContain(serverOutput, [ + 'client/Client.class', + 'junit/framework/Test.class', + 'client/junit/framework/Test.class' + ]) + } + + def 'shadow a project shadow jar'() { + given: + file('settings.gradle') << """ + include 'client', 'server' + """.stripIndent() + + file('client/src/main/java/client/Client.java') << """ + package client; + public class Client {} + """.stripIndent() + + file('client/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + repositories { maven { url "${repo.uri}" } } + dependencies { implementation 'junit:junit:3.8.2' } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + relocate 'junit.framework', 'client.junit.framework' + } + """.stripIndent() + + file('server/src/main/java/server/Server.java') << """ + package server; + + import client.Client; + import client.junit.framework.Test; + + public class Server {} + """.stripIndent() + + file('server/build.gradle') << """ + apply plugin: 'java' + apply plugin: 'com.gradleup.shadow' + + repositories { maven { url "${repo.uri}" } } + dependencies { implementation project(path: ':client', configuration: 'shadow') } + """.stripIndent() + + File serverOutput = getFile('server/build/libs/server-all.jar') + + when: + run(':server:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'client/Client.class', + 'client/junit/framework/Test.class', + 'server/Server.class', + ]) + + and: + doesNotContain(serverOutput, [ + 'junit/framework/Test.class' + ]) + } + + def "exclude INDEX.LIST, *.SF, *.DSA, and *.RSA by default"() { + given: + repo.module('shadow', 'a', '1.0') + .insertFile('a.properties', 'a') + .insertFile('META-INF/INDEX.LIST', 'JarIndex-Version: 1.0') + .insertFile('META-INF/a.SF', 'Signature File') + .insertFile('META-INF/a.DSA', 'DSA Signature Block') + .insertFile('META-INF/a.RSA', 'RSA Signature Block') + .insertFile('META-INF/a.properties', 'key=value') + .publish() + + file('src/main/java/shadow/Passed.java') << ''' + package shadow; + public class Passed {} + '''.stripIndent() + + buildFile << """ + dependencies { implementation 'shadow:a:1.0' } + """.stripIndent() + + when: + run('shadowJar') + + then: + contains(output, ['a.properties', 'META-INF/a.properties']) + + and: + doesNotContain(output, ['META-INF/INDEX.LIST', 'META-INF/a.SF', 'META-INF/a.DSA', 'META-INF/a.RSA']) + } + + def "include runtime configuration by default"() { + given: + repo.module('shadow', 'a', '1.0') + .insertFile('a.properties', 'a') + .publish() + + repo.module('shadow', 'b', '1.0') + .insertFile('b.properties', 'b') + .publish() + + buildFile << """ + dependencies { + runtimeOnly 'shadow:a:1.0' + shadow 'shadow:b:1.0' + } + """.stripIndent() + + when: + run('shadowJar') + + then: + contains(output, ['a.properties']) + + and: + doesNotContain(output, ['b.properties']) + } + + def "include java-library configurations by default"() { + given: + repo.module('shadow', 'api', '1.0') + .insertFile('api.properties', 'api') + .publish() + + repo.module('shadow', 'implementation-dep', '1.0') + .insertFile('implementation-dep.properties', 'implementation-dep') + .publish() + + repo.module('shadow', 'implementation', '1.0') + .insertFile('implementation.properties', 'implementation') + .dependsOn('implementation-dep') + .publish() + + repo.module('shadow', 'runtimeOnly', '1.0') + .insertFile('runtimeOnly.properties', 'runtimeOnly') + .publish() + + buildFile.text = getDefaultBuildScript('java-library') + buildFile << """ + dependencies { + api 'shadow:api:1.0' + implementation 'shadow:implementation:1.0' + runtimeOnly 'shadow:runtimeOnly:1.0' + } + """.stripIndent() + + when: + runWithDebug('shadowJar') + + then: + contains(output, ['api.properties', 'implementation.properties', + 'runtimeOnly.properties', 'implementation-dep.properties']) + } + + def "doesn't include compileOnly configuration by default"() { + given: + repo.module('shadow', 'a', '1.0') + .insertFile('a.properties', 'a') + .publish() + + repo.module('shadow', 'b', '1.0') + .insertFile('b.properties', 'b') + .publish() + + buildFile << """ + dependencies { + runtimeOnly 'shadow:a:1.0' + compileOnly 'shadow:b:1.0' + } + """.stripIndent() + + when: + run('shadowJar') + + then: + contains(output, ['a.properties']) + + and: + doesNotContain(output, ['b.properties']) + } + + def "default copying strategy"() { + given: + repo.module('shadow', 'a', '1.0') + .insertFile('META-INF/MANIFEST.MF', 'MANIFEST A') + .publish() + + repo.module('shadow', 'b', '1.0') + .insertFile('META-INF/MANIFEST.MF', 'MANIFEST B') + .publish() + + buildFile << """ + dependencies { + runtimeOnly 'shadow:a:1.0' + runtimeOnly 'shadow:b:1.0' + } + """.stripIndent() + + when: + run('shadowJar') + + then: + JarFile jar = new JarFile(output) + assert jar.entries().collect().size() == 2 + } + + def "Class-Path in Manifest not added if empty"() { + given: + + buildFile << """ + dependencies { implementation 'junit:junit:3.8.2' } + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + + and: + JarFile jar = new JarFile(output) + Attributes attributes = jar.manifest.getMainAttributes() + assert attributes.getValue('Class-Path') == null + } + + @Issue('SHADOW-65') + def "add shadow configuration to Class-Path in Manifest"() { + given: + + buildFile << """ + // tag::shadowConfig[] + dependencies { + shadow 'junit:junit:3.8.2' + } + // end::shadowConfig[] + + // tag::jarManifest[] + jar { + manifest { + attributes 'Class-Path': '/libs/a.jar' + } + } + // end::jarManifest[] + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + + and: 'SHADOW-65 - combine w/ existing Class-Path' + JarFile jar = new JarFile(output) + Attributes attributes = jar.manifest.getMainAttributes() + String classpath = attributes.getValue('Class-Path') + assert classpath == '/libs/a.jar junit-3.8.2.jar' + + } + + @Issue('SHADOW-92') + def "do not include null value in Class-Path when jar file does not contain Class-Path"() { + given: + + buildFile << """ + dependencies { shadow 'junit:junit:3.8.2' } + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + + and: + JarFile jar = new JarFile(output) + Attributes attributes = jar.manifest.getMainAttributes() + String classpath = attributes.getValue('Class-Path') + assert classpath == 'junit-3.8.2.jar' + + } + + @Issue('SHADOW-203') + def "support ZipCompression.STORED"() { + given: + + buildFile << """ + dependencies { shadow 'junit:junit:3.8.2' } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + zip64 true + entryCompression = org.gradle.api.tasks.bundling.ZipEntryCompression.STORED + } + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + + } + + def 'api project dependency with version'() { + given: + file('settings.gradle') << """ + include 'api', 'lib', 'impl' + """.stripIndent() + + file('lib/build.gradle') << """ + apply plugin: 'java' + version = '1.0' + repositories { maven { url "${repo.uri}" } } + """.stripIndent() + + file('api/src/main/java/api/UnusedEntity.java') << """ + package api; + public class UnusedEntity {} + """.stripIndent() + + file('api/build.gradle') << """ + apply plugin: 'java' + version = '1.0' + repositories { maven { url "${repo.uri}" } } + dependencies { + implementation 'junit:junit:3.8.2' + implementation project(':lib') + } + """.stripIndent() + + file('impl/build.gradle') << """ + apply plugin: 'java-library' + apply plugin: 'com.gradleup.shadow' + + version = '1.0' + repositories { maven { url "${repo.uri}" } } + dependencies { api project(':api') } + + shadowJar.minimize() + """.stripIndent() + + File serverOutput = getFile('impl/build/libs/impl-1.0-all.jar') + + when: + runWithDebug(':impl:shadowJar') + + then: + serverOutput.exists() + contains(serverOutput, [ + 'api/UnusedEntity.class', + ]) + } + + @Issue('SHADOW-143') + @Ignore("This spec requires > 15 minutes and > 8GB of disk space to run") + def "check large zip files with zip64 enabled"() { + given: + repo.module('shadow', 'a', '1.0') + .insertFile('a.properties', 'a') + .insertFile('a2.properties', 'a2') + .publish() + + file('src/main/java/myapp/Main.java') << """ + package myapp; + public class Main { + public static void main(String[] args) { + System.out.println("TestApp: Hello World! (" + args[0] + ")"); + } + } + """.stripIndent() + + buildFile << """ + apply plugin: 'application' + + application { + mainClass = 'myapp.Main' + } + + dependencies { + implementation 'shadow:a:1.0' + } + + def generatedResourcesDir = new File(project.layout.buildDirectory.asFile.get(), "generated-resources") + + task generateResources { + doLast { + def rnd = new Random() + def buf = new byte[128 * 1024] + for (x in 0..255) { + def dir = new File(generatedResourcesDir, x.toString()) + dir.mkdirs() + for (y in 0..255) { + def file = new File(dir, y.toString()) + rnd.nextBytes(buf) + file.bytes = buf + } + } + } + } + + sourceSets { + main { + output.dir(generatedResourcesDir, builtBy: generateResources) + } + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + zip64 = true + } + + runShadow { + args 'foo' + } + """.stripIndent() + + settingsFile << "rootProject.name = 'myapp'" + + when: + BuildResult result = run('runShadow') + + then: 'tests that runShadow executed and exited' + assert result.output.contains('TestApp: Hello World! (foo)') + + + } + + @Issue("SHADOW-303") + @Ignore("Plugin has been deprecated") + def "doesn't error when adding aspectj plugin"() { + given: + buildFile.text = """ + buildscript { + repositories { + maven { + url "https://maven.eveoh.nl/content/repositories/releases" + } + } + + dependencies { + classpath "nl.eveoh:gradle-aspectj:2.0" + } + } + """.stripIndent() + + buildFile << defaultBuildScript + + buildFile << """ + project.ext { + aspectjVersion = '1.8.12' + } + + apply plugin: 'aspectj' + apply plugin: 'application' + + application { + mainClass = 'myapp.Main' + } + + repositories { + mavenCentral() + } + + runShadow { + args 'foo' + } + + """ + + file('src/main/java/myapp/Main.java') << """ + package myapp; + public class Main { + public static void main(String[] args) { + System.out.println("TestApp: Hello World! (" + args[0] + ")"); + } + } + """.stripIndent() + + when: + BuildResult result = run('runShadow') + + then: 'tests that runShadow executed and exited' + assert result.output.contains('TestApp: Hello World! (foo)') + } + + @Issue("https://github.com/GradleUp/shadow/issues/609") + def "doesn't error when using application mainClass property"() { + given: + buildFile.text = defaultBuildScript + + buildFile << """ + project.ext { + aspectjVersion = '1.8.12' + } + + apply plugin: 'application' + + application { + mainClass.set('myapp.Main') + } + + repositories { + mavenCentral() + } + + runShadow { + args 'foo' + } + + """ + + file('src/main/java/myapp/Main.java') << """ + package myapp; + public class Main { + public static void main(String[] args) { + System.out.println("TestApp: Hello World! (" + args[0] + ")"); + } + } + """.stripIndent() + + when: + BuildResult result = run('runShadow') + + then: 'tests that runShadow executed and exited' + assert result.output.contains('TestApp: Hello World! (foo)') + } + + @Issue("https://github.com/GradleUp/shadow/pull/459") + def 'exclude gradleApi() by default'() { + given: + buildFile.text = getDefaultBuildScript('java-gradle-plugin') + + file('src/main/java/my/plugin/MyPlugin.java') << """ + package my.plugin; + import org.gradle.api.Plugin; + import org.gradle.api.Project; + public class MyPlugin implements Plugin { + public void apply(Project project) { + System.out.println("MyPlugin: Hello World!"); + } + } + """.stripIndent() + file('src/main/resources/META-INF/gradle-plugins/my.plugin.properties') << """ + implementation-class=my.plugin.MyPlugin + """.stripIndent() + + when: + run('shadowJar') + + then: + assert output.exists() + + and: + JarFile jar = new JarFile(output) + assert jar.entries().collect().findAll { it.name.endsWith('.class') }.size() == 1 + } + + private String escapedPath(File file) { + file.path.replaceAll('\\\\', '\\\\\\\\') + } +} diff --git a/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonAppendingTransformerTest.groovy b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonAppendingTransformerTest.groovy new file mode 100644 index 000000000..f6d373af2 --- /dev/null +++ b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/JsonAppendingTransformerTest.groovy @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License") you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.github.jengelman.gradle.plugins.shadow.transformers + +import com.github.jengelman.gradle.plugins.shadow.ShadowStats +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.apache.tools.zip.ZipOutputStream +import org.junit.Before +import org.junit.Test + +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertTrue + +/** + * Test for {@link JsonTransformer}. + * + * @author Jan-Hendrik Diederich + * + * Modified from com.github.jengelman.gradle.plugins.shadow.transformers.XmlAppendingTransformerTest.java + */ +class JsonAppendingTransformerTest extends TransformerTestSupport { + + JsonTransformer transformer + + static final String TEST_ARTIFACT_JAR = 'test-artifact-1.0-SNAPSHOT.jar' + static final String TEST_PROJECT_JAR = 'test-project-1.0-SNAPSHOT.jar' + + static final String TEST_JSON = 'test.json' + static final String TEST2_JSON = 'test2.json' + + @Before + void setUp() { + transformer = new JsonTransformer() + } + + @Test + void testCanTransformResource() { + transformer.paths = ["test.json"] + + assertTrue(this.transformer.canTransformResource(getFileElement("test.json"))) + assertFalse(this.transformer.canTransformResource(getFileElement("META-INF/MANIFEST.MF"))) + } + + @Test + void transformResource() { + transformer.transform(new TransformerContext(TEST_JSON, readFromTestJar(TEST_ARTIFACT_JAR, TEST_JSON), + Collections. emptyList(), new ShadowStats())) + transformer.transform(new TransformerContext(TEST2_JSON, readFromTestJar(TEST_ARTIFACT_JAR, TEST2_JSON), + Collections. emptyList(), new ShadowStats())) + + transformer.transform(new TransformerContext(TEST_JSON, readFromTestJar(TEST_PROJECT_JAR, TEST_JSON), + Collections. emptyList(), new ShadowStats())) + transformer.transform(new TransformerContext(TEST2_JSON, readFromTestJar(TEST_PROJECT_JAR, TEST2_JSON), + Collections. emptyList(), new ShadowStats())) + + def zipFileName = "testable-zip-file-" + def zipFileSuffix = ".jar" + def testableZipFile = File.createTempFile(zipFileName, zipFileSuffix) + def fileOutputStream = new FileOutputStream(testableZipFile) + def bufferedOutputStream = new BufferedOutputStream(fileOutputStream) + def zipOutputStream = new ZipOutputStream(bufferedOutputStream) + + transformer.paths = [TEST_JSON, TEST2_JSON] + try { + transformer.modifyOutputStream(zipOutputStream, false) + } finally { + zipOutputStream.close() + bufferedOutputStream.close() + fileOutputStream.close() + } + // Read 1st file. + String targetJson = readFromZipFile(testableZipFile.absolutePath, TEST_JSON) + println("Target JSON: \"" + targetJson + "\"") + + assertFalse(targetJson.isEmpty()) + assertTrue(targetJson.contains("\"C: Only here\"")) + + JsonElement jsonElement = JsonParser.parseString(targetJson) + JsonObject jsonObject = jsonElement.getAsJsonObject() + + JsonElement subAA = jsonObject.get("a.a") + + JsonElement subAA1 = subAA.getAsJsonObject().get("a.sub1") + assertEquals("A Sub 1", subAA1.asString) + + JsonElement subAA2 = subAA.getAsJsonObject().get("a.sub2") + assertEquals("A Sub 2", subAA2.asString) + + // Read 2nd file. + String target2Json = readFromZipFile(testableZipFile.absolutePath, TEST2_JSON) + assertFalse(target2Json.isEmpty()) + JsonElement jsonElement2 = JsonParser.parseString(target2Json) + JsonObject jsonObject2 = jsonElement2.getAsJsonObject() + JsonArray jsonArray2 = jsonObject2.get("Array").asJsonArray + assertEquals(List.of("A", "B", "C", "C", "D", "E"), + (List) jsonArray2.collect({ it -> it.getAsString() })) + } + + static InputStream readFromTestJar(String resourceName, String fileName) { + try (ZipInputStream inputStream = new ZipInputStream(getResourceStream(resourceName))) { + while (true) { + ZipEntry entry = inputStream.nextEntry + if (entry == null) { + break + } else if (entry.name == fileName) { + // Read the content of the entry + byte[] buffer = new byte[entry.size] + inputStream.read(buffer) + return new ByteArrayInputStream(buffer) + } + } + } + throw new IllegalArgumentException("Missing entry " + fileName) + } + + static String readFromZipFile(String resourceName, String fileName) { + def zip = new ZipFile(resourceName) + try { + ZipEntry entry = zip.getEntry(fileName) + if (!entry) { + throw new IllegalArgumentException("Missing entry " + fileName + " in " + resourceName) + } + return new String(zip.getInputStream(entry).readAllBytes()) + } finally { + zip.close() + } + } + + private static InputStream getResourceStream(String resource) { + JsonAppendingTransformerTest.class.classLoader.getResourceAsStream(resource) + } +} diff --git a/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformerTest.groovy b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformerTest.groovy new file mode 100644 index 000000000..3dba6bf28 --- /dev/null +++ b/src/test/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/StandardFilesMergeTransformerTest.groovy @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License") you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.github.jengelman.gradle.plugins.shadow.transformers + +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertTrue + +/** + * Test for {@link StandardFilesMergeTransformer}. + * + * @author Benjamin Bentmann + * @version $Id: ApacheNoticeResourceTransformerTest.java 673906 2008-07-04 05:03:20Z brett $ + * + * Modified from org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformerTest.java + */ +class StandardFilesMergeTransformerTest extends TransformerTestSupport { + + private StandardFilesMergeTransformer transformer + + static { + /* + * NOTE: The Turkish locale has an usual case transformation for the letters "I" and "i", making it a prime + * choice to test for improper case-less string comparisons. + */ + Locale.setDefault(new Locale("tr")) + } + + @Before + void setUp() { + this.transformer = new StandardFilesMergeTransformer() + } + + @Test + void testCanTransformResource() { + assertTrue(this.transformer.canTransformResource(getFileElement("META-INF/NOTICE"))) + assertTrue(this.transformer.canTransformResource(getFileElement("META-INF/NOTICE.TXT"))) + assertTrue(this.transformer.canTransformResource(getFileElement("META-INF/Notice.txt"))) + assertTrue(this.transformer.canTransformResource(getFileElement("META-INF/Notice.hTml"))) + assertFalse(this.transformer.canTransformResource(getFileElement("META-INF/MANIFEST.MF"))) + } +} \ No newline at end of file diff --git a/src/test/resources/test-artifact-1.0-SNAPSHOT.jar b/src/test/resources/test-artifact-1.0-SNAPSHOT.jar new file mode 100644 index 000000000..d12faa897 Binary files /dev/null and b/src/test/resources/test-artifact-1.0-SNAPSHOT.jar differ diff --git a/src/test/resources/test-project-1.0-SNAPSHOT.jar b/src/test/resources/test-project-1.0-SNAPSHOT.jar new file mode 100644 index 000000000..3edb83b54 Binary files /dev/null and b/src/test/resources/test-project-1.0-SNAPSHOT.jar differ