blob: 6603f272125c995cf9e8aa1a403880f3415336a6 [file] [log] [blame]
/*
* Copyright 2014-present Facebook, Inc.
*
* Licensed 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.facebook.buck.java;
import static com.facebook.buck.zip.ZipOutputStreams.HandleDuplicates.APPEND_TO_ZIP;
import com.facebook.buck.event.BuckEventBus;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.io.DirectoryTraversal;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.zip.CustomZipOutputStream;
import com.facebook.buck.zip.ZipOutputStreams;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.Enumeration;
import java.util.Map;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.logging.Level;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.annotation.Nullable;
public class JarDirectoryStepHelper {
private JarDirectoryStepHelper() {}
public static void createJarFile(
Path pathToOutputFile,
ImmutableSet<Path> entriesToJar,
@Nullable String mainClass,
@Nullable Path manifestFile,
boolean mergeManifests,
Iterable<Pattern> blacklist,
ExecutionContext context) throws IOException {
ProjectFilesystem filesystem = context.getProjectFilesystem();
// Write the manifest, as appropriate.
Manifest manifest = new Manifest();
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
try (CustomZipOutputStream outputFile = ZipOutputStreams.newOutputStream(
filesystem.getFileForRelativePath(pathToOutputFile), APPEND_TO_ZIP)) {
Set<String> alreadyAddedEntries = Sets.newHashSet();
ProjectFilesystem projectFilesystem = context.getProjectFilesystem();
for (Path entry : entriesToJar) {
File file = projectFilesystem.getFileForRelativePath(entry);
if (file.isFile()) {
// Assume the file is a ZIP/JAR file.
copyZipEntriesToJar(file,
outputFile,
manifest,
alreadyAddedEntries,
context.getBuckEventBus(),
blacklist);
} else if (file.isDirectory()) {
addFilesInDirectoryToJar(
file,
outputFile,
alreadyAddedEntries,
context.getBuckEventBus());
} else {
throw new IllegalStateException("Must be a file or directory: " + file);
}
}
// Read the user supplied manifest file, allowing it to overwrite existing entries in the
// uber manifest we've built.
if (manifestFile != null) {
try (FileInputStream manifestStream = new FileInputStream(
filesystem.getFileForRelativePath(manifestFile))) {
Manifest userSupplied = new Manifest(manifestStream);
// In the common case, we want to use the merged manifests. In the uncommon case, we just
// want to use the one the user gave us.
if (mergeManifests) {
merge(manifest, userSupplied);
} else {
manifest = userSupplied;
}
}
}
// The process of merging the manifests means that existing entries are
// overwritten. To ensure that our main_class is set as expected, we
// write it here.
if (mainClass != null) {
manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, mainClass);
}
JarEntry manifestEntry = new JarEntry(JarFile.MANIFEST_NAME);
outputFile.putNextEntry(manifestEntry);
manifest.write(outputFile);
}
}
/**
* @param file is assumed to be a zip file.
* @param jar is the file being written.
* @param manifest that should get a copy of (@code jar}'s manifest entries.
* @param alreadyAddedEntries is used to avoid duplicate entries.
*/
private static void copyZipEntriesToJar(File file,
final CustomZipOutputStream jar,
Manifest manifest,
Set<String> alreadyAddedEntries,
BuckEventBus eventBus,
Iterable<Pattern> blacklist) throws IOException {
try (ZipFile zip = new ZipFile(file)) {
zipEntryLoop:
for (Enumeration<? extends ZipEntry> entries = zip.entries(); entries.hasMoreElements(); ) {
ZipEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.equals(JarFile.MANIFEST_NAME)) {
Manifest readManifest = readManifest(zip, entry);
merge(manifest, readManifest);
continue;
}
// We're in the process of merging a bunch of different jar files. These typically contain
// just ".class" files and the manifest, but they can also include things like license files
// from third party libraries and config files. We should include those license files within
// the jar we're creating. Extracting them is left as an exercise for the consumer of the
// jar. Because we don't know which files are important, the only ones we skip are
// duplicate class files.
if (!isDuplicateAllowed(entryName) && !alreadyAddedEntries.add(entryName)) {
// Duplicate entries. Skip.
eventBus.post(ConsoleEvent.create(
determineSeverity(entry), "Duplicate found when adding file to jar: %s", entryName));
continue;
}
for (Pattern p : blacklist) {
if (p.matcher(entryName).matches()) {
eventBus.post(ConsoleEvent.create(
Level.FINE, "Skipping adding file to jar: %s", entryName));
continue zipEntryLoop;
}
}
ZipEntry newEntry = new ZipEntry(entry);
// For deflated entries, the act of re-"putting" this entry means we're re-compressing
// the data that we've just uncompressed. Due to various environmental issues (e.g. a
// newer version of zlib, changed compression settings), we may end up with a different
// compressed size. This causes an issue in java's `java.util.zip.ZipOutputStream`
// implementation, as it only updates the compressed size field if one of `crc`,
// `compressedSize`, or `size` is -1. When we copy the entry as-is, none of these are
// -1, and we may end up with an incorrect compressed size, in which case, we'll get an
// exception. So, for deflated entries, reset the compressed size to -1 (as the
// ZipEntry(String) would).
// See https://github.com/spearce/buck/commit/8338c1c3d4a546f577eed0c9941d9f1c2ba0a1b7.
if (entry.getMethod() == ZipEntry.DEFLATED) {
newEntry.setCompressedSize(-1);
}
jar.putNextEntry(newEntry);
InputStream inputStream = zip.getInputStream(entry);
ByteStreams.copy(inputStream, jar);
jar.closeEntry();
}
}
}
private static Level determineSeverity(ZipEntry entry) {
return entry.isDirectory() ? Level.FINE : Level.INFO;
}
private static Manifest readManifest(ZipFile zip, ZipEntry manifestMfEntry) throws IOException {
try (
ByteArrayOutputStream output = new ByteArrayOutputStream((int) manifestMfEntry.getSize());
InputStream stream = zip.getInputStream(manifestMfEntry)
) {
ByteStreams.copy(stream, output);
ByteArrayInputStream rawManifest = new ByteArrayInputStream(output.toByteArray());
return new Manifest(rawManifest);
}
}
/**
* @param directory that must not contain symlinks with loops.
* @param jar is the file being written.
*/
private static void addFilesInDirectoryToJar(File directory,
final CustomZipOutputStream jar,
final Set<String> alreadyAddedEntries,
final BuckEventBus eventBus) throws IOException {
new DirectoryTraversal(directory) {
@Override
public void visit(File file, String relativePath) {
JarEntry entry = new JarEntry(relativePath.replace('\\', '/'));
String entryName = entry.getName();
entry.setTime(file.lastModified());
try {
// We expect there to be many duplicate entries for things like directories. Creating
// those repeatedly would be lame, so don't do that.
if (!isDuplicateAllowed(entryName) && !alreadyAddedEntries.add(entryName)) {
if (!entryName.endsWith("/")) {
eventBus.post(ConsoleEvent.create(
determineSeverity(entry),
"Duplicate found when adding directory to jar: %s", relativePath));
}
return;
}
jar.putNextEntry(entry);
Files.copy(file, jar);
jar.closeEntry();
} catch (IOException e) {
Throwables.propagate(e);
}
}
@Override
public void visitDirectory(File directory, String relativePath) throws IOException {
if (relativePath.isEmpty()) {
// root of the tree. Skip.
return;
}
String entryName = relativePath.replace('\\', '/') + "/";
if (alreadyAddedEntries.contains(entryName)) {
return;
}
JarEntry entry = new JarEntry(entryName);
entry.setTime(directory.lastModified());
jar.putNextEntry(entry);
jar.closeEntry();
}
}.traverse();
}
/**
* Merge entries from two Manifests together, with existing attributes being
* overwritten.
*
* @param into The Manifest to modify.
* @param from The Manifest to copy from.
*/
private static void merge(Manifest into, Manifest from) {
Attributes attributes = from.getMainAttributes();
if (attributes != null) {
for (Map.Entry<Object, Object> attribute : attributes.entrySet()) {
into.getMainAttributes().put(attribute.getKey(), attribute.getValue());
}
}
Map<String, Attributes> entries = from.getEntries();
if (entries != null) {
for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
into.getEntries().put(entry.getKey(), entry.getValue());
}
}
}
private static boolean isDuplicateAllowed(String name) {
return !name.endsWith(".class") && !name.endsWith("/");
}
}