blob: 44d4f0262b69e5a07506740b475b67827ae81beb [file] [log] [blame]
// Copyright 2014 The Bazel Authors. All rights reserved.
//
// 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.google.devtools.build.singlejar;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.devtools.build.singlejar.ZipEntryFilter.CustomMergeStrategy;
import com.google.devtools.build.singlejar.ZipEntryFilter.StrategyCallback;
import com.google.devtools.build.zip.ExtraData;
import com.google.devtools.build.zip.ExtraDataList;
import com.google.devtools.build.zip.ZipFileEntry;
import com.google.devtools.build.zip.ZipFileEntry.Compression;
import com.google.devtools.build.zip.ZipReader;
import com.google.devtools.build.zip.ZipUtil;
import com.google.devtools.build.zip.ZipWriter;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.DeflaterInputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import javax.annotation.Nullable;
/**
* An object that combines multiple ZIP files into a single file. It only
* supports a subset of the ZIP format, specifically:
* <ul>
* <li>It only supports STORE and DEFLATE storage methods.</li>
* <li>It only supports 32-bit ZIP files.</li>
* </ul>
*
* <p>These restrictions are also present in the JDK implementations
* {@link java.util.jar.JarInputStream}, {@link java.util.zip.ZipInputStream},
* though they are not documented there.
*
* <p>IMPORTANT NOTE: Callers must call {@link #finish()} or {@link #close()}
* at the end of processing to ensure that the output buffers are flushed and
* the ZIP file is complete.
*
* <p>This class performs only rudimentary data checking. If the input files
* are damaged, the output will likely also be damaged.
*
* <p>Also see:
* <a href="http://www.pkware.com/documents/casestudies/APPNOTE.TXT">ZIP format</a>
*/
public class ZipCombiner implements AutoCloseable {
public static final Date DOS_EPOCH = new Date(ZipUtil.DOS_EPOCH);
/**
* Whether to compress or decompress entries.
*/
public enum OutputMode {
/**
* Output entries using any method.
*/
DONT_CARE,
/**
* Output all entries using DEFLATE method, except directory entries. It is always more
* efficient to store directory entries uncompressed.
*/
FORCE_DEFLATE,
/**
* Output all entries using STORED method.
*/
FORCE_STORED,
}
/**
* The type of action to take for a ZIP file entry.
*/
private enum ActionType {
/**
* Skip the entry.
*/
SKIP,
/**
* Copy the entry.
*/
COPY,
/**
* Rename the entry.
*/
RENAME,
/**
* Merge the entry.
*/
MERGE;
}
/**
* Encapsulates the action to take for a ZIP file entry along with optional details specific to
* the action type. The minimum requirements per type are:
* <ul>
* <li>SKIP: none.</li>
* <li>COPY: none.</li>
* <li>RENAME: newName.</li>
* <li>MERGE: strategy, mergeBuffer.</li>
* </ul>
*
* <p>An action can be easily changed from one type to another by using
* {@link EntryAction#EntryAction(ActionType, EntryAction)}.
*/
private static final class EntryAction {
private final ActionType type;
@Nullable private final Date date;
@Nullable private final String newName;
@Nullable private final CustomMergeStrategy strategy;
@Nullable private final ByteArrayOutputStream mergeBuffer;
/**
* Create an action of the specified type with no extra details.
*/
public EntryAction(ActionType type) {
this(type, null, null, null, null);
}
/**
* Create a duplicate action with a different {@link ActionType}.
*/
public EntryAction(ActionType type, EntryAction action) {
this(type, action.getDate(), action.getNewName(), action.getStrategy(),
action.getMergeBuffer());
}
/**
* Create an action of the specified type and details.
*
* @param type the type of action
* @param date the custom date to set on the entry
* @param newName the custom name to create the entry as
* @param strategy the {@link CustomMergeStrategy} to use for merging this entry
* @param mergeBuffer the output stream to use for merge results
*/
public EntryAction(ActionType type, Date date, String newName, CustomMergeStrategy strategy,
ByteArrayOutputStream mergeBuffer) {
checkArgument(type != ActionType.RENAME || newName != null,
"NewName must not be null if the ActionType is RENAME.");
checkArgument(type != ActionType.MERGE || strategy != null,
"Strategy must not be null if the ActionType is MERGE.");
checkArgument(type != ActionType.MERGE || mergeBuffer != null,
"MergeBuffer must not be null if the ActionType is MERGE.");
this.type = type;
this.date = date;
this.newName = newName;
this.strategy = strategy;
this.mergeBuffer = mergeBuffer;
}
/** Returns the type. */
public ActionType getType() {
return type;
}
/** Returns the date. */
public Date getDate() {
return date;
}
/** Returns the new name. */
public String getNewName() {
return newName;
}
/** Returns the strategy. */
public CustomMergeStrategy getStrategy() {
return strategy;
}
/** Returns the mergeBuffer. */
public ByteArrayOutputStream getMergeBuffer() {
return mergeBuffer;
}
}
private final class FilterCallback implements StrategyCallback {
private String filename;
private final AtomicBoolean called = new AtomicBoolean();
public void resetForFile(String filename) {
this.filename = filename;
this.called.set(false);
}
@Override public void skip() throws IOException {
checkCall();
actions.put(filename, new EntryAction(ActionType.SKIP));
}
@Override public void copy(Date date) throws IOException {
checkCall();
actions.put(filename, new EntryAction(ActionType.COPY, date, null, null, null));
}
@Override public void rename(String newName, Date date) throws IOException {
checkCall();
actions.put(filename, new EntryAction(ActionType.RENAME, date, newName, null, null));
}
@Override public void customMerge(Date date, CustomMergeStrategy strategy) throws IOException {
checkCall();
actions.put(filename, new EntryAction(ActionType.MERGE, date, null, strategy,
new ByteArrayOutputStream()));
}
private void checkCall() {
checkState(called.compareAndSet(false, true), "The callback was already called once.");
}
}
/** Returns a {@link Deflater} for performing ZIP compression. */
private static Deflater getDeflater() {
return new Deflater(Deflater.DEFAULT_COMPRESSION, true);
}
/** Returns a {@link Inflater} for performing ZIP decompression. */
private static Inflater getInflater() {
return new Inflater(true);
}
/** Copies all data from the input stream to the output stream. */
private static long copyStream(InputStream from, OutputStream to) throws IOException {
byte[] buf = new byte[0x1000];
long total = 0;
int r;
while ((r = from.read(buf)) != -1) {
to.write(buf, 0, r);
total += r;
}
return total;
}
private final OutputMode mode;
private final ZipEntryFilter entryFilter;
private final FilterCallback callback;
private final ZipWriter out;
private final Map<String, ZipFileEntry> entries;
private final Map<String, EntryAction> actions;
/**
* Creates a {@link ZipCombiner} for combining ZIP files using the specified {@link OutputMode},
* {@link ZipEntryFilter}, and destination {@link OutputStream}.
*
* @param mode the compression preference for the output ZIP file
* @param entryFilter the filter to use when adding ZIP files to the combined output
* @param out the {@link OutputStream} for writing the combined ZIP file
*/
public ZipCombiner(OutputMode mode, ZipEntryFilter entryFilter, OutputStream out) {
this.mode = mode;
this.entryFilter = entryFilter;
this.callback = new FilterCallback();
this.out = new ZipWriter(new BufferedOutputStream(out), UTF_8);
this.entries = new HashMap<>();
this.actions = new HashMap<>();
}
/**
* Creates a {@link ZipCombiner} for combining ZIP files using the specified
* {@link ZipEntryFilter}, and destination {@link OutputStream}. Uses the DONT_CARE
* {@link OutputMode}.
*
* @param entryFilter the filter to use when adding ZIP files to the combined output
* @param out the {@link OutputStream} for writing the combined ZIP file
*/
public ZipCombiner(ZipEntryFilter entryFilter, OutputStream out) {
this(OutputMode.DONT_CARE, entryFilter, out);
}
/**
* Creates a {@link ZipCombiner} for combining ZIP files using the specified {@link OutputMode},
* and destination {@link OutputStream}. Uses a {@link CopyEntryFilter} as the
* {@link ZipEntryFilter}.
*
* @param mode the compression preference for the output ZIP file
* @param out the {@link OutputStream} for writing the combined ZIP file
*/
public ZipCombiner(OutputMode mode, OutputStream out) {
this(mode, new CopyEntryFilter(), out);
}
/**
* Creates a {@link ZipCombiner} for combining ZIP files using the specified destination
* {@link OutputStream}. Uses the DONT_CARE {@link OutputMode} and a {@link CopyEntryFilter} as
* the {@link ZipEntryFilter}.
*
* @param out the {@link OutputStream} for writing the combined ZIP file
*/
public ZipCombiner(OutputStream out) {
this(OutputMode.DONT_CARE, new CopyEntryFilter(), out);
}
/**
* Write all contents from the {@link InputStream} as a prefix file for the combined ZIP file.
*
* @param in the {@link InputStream} containing the prefix file data
* @throws IOException if there is an error writing the prefix file
*/
public void prependExecutable(InputStream in) throws IOException {
out.startPrefixFile();
copyStream(in, out);
out.endPrefixFile();
}
/**
* Adds a directory entry to the combined ZIP file using the specified filename and date.
*
* @param filename the name of the directory to create
* @param date the modified time to assign to the directory
* @throws IOException if there is an error writing the directory entry
*/
public void addDirectory(String filename, Date date) throws IOException {
addDirectory(filename, date, new ExtraData[0]);
}
/**
* Adds a directory entry to the combined ZIP file using the specified filename, date, and extra
* data.
*
* @param filename the name of the directory to create
* @param date the modified time to assign to the directory
* @param extra the extra field data to add to the directory entry
* @throws IOException if there is an error writing the directory entry
*/
public void addDirectory(String filename, Date date, ExtraData[] extra) throws IOException {
checkArgument(filename.endsWith("/"), "Directory names must end with a /");
checkState(!entries.containsKey(filename),
"Zip already contains a directory named %s", filename);
ZipFileEntry entry = new ZipFileEntry(filename);
entry.setMethod(Compression.STORED);
entry.setCrc(0);
entry.setSize(0);
entry.setCompressedSize(0);
entry.setTime(date != null ? date.getTime() : new Date().getTime());
entry.setExtra(new ExtraDataList(extra));
out.putNextEntry(entry);
out.closeEntry();
entries.put(filename, entry);
}
/**
* Adds a file with the specified name to the combined ZIP file.
*
* @param filename the name of the file to create
* @param in the {@link InputStream} containing the file data
* @throws IOException if there is an error writing the file entry
* @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
* name.
*/
public void addFile(String filename, InputStream in) throws IOException {
addFile(filename, null, in);
}
/**
* Adds a file with the specified name and date to the combined ZIP file.
*
* @param filename the name of the file to create
* @param date the modified time to assign to the file
* @param in the {@link InputStream} containing the file data
* @throws IOException if there is an error writing the file entry
* @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
* name.
*/
public void addFile(String filename, Date date, InputStream in) throws IOException {
ZipFileEntry entry = new ZipFileEntry(filename);
entry.setTime(date != null ? date.getTime() : new Date().getTime());
addFile(entry, in);
}
/**
* Adds a file with attributes specified by the {@link ZipFileEntry} to the combined ZIP file.
*
* @param entry the {@link ZipFileEntry} containing the entry meta-data
* @param in the {@link InputStream} containing the file data
* @throws IOException if there is an error writing the file entry
* @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
* name.
*/
public void addFile(ZipFileEntry entry, InputStream in) throws IOException {
checkNotNull(entry, "Zip entry must not be null.");
checkNotNull(in, "Input stream must not be null.");
checkArgument(!entries.containsKey(entry.getName()), "Zip already contains a file named '%s'.",
entry.getName());
ByteArrayOutputStream uncompressed = new ByteArrayOutputStream();
copyStream(in, uncompressed);
writeEntryFromBuffer(new ZipFileEntry(entry), uncompressed.toByteArray());
}
/**
* Adds the contents of a ZIP file to the combined ZIP file using the specified
* {@link ZipEntryFilter} to determine the appropriate action for each file.
*
* @param zipFile the ZIP file to add to the combined ZIP file
* @throws IOException if there is an error reading the ZIP file or writing entries to the
* combined ZIP file
*/
public void addZip(File zipFile) throws IOException {
try (ZipReader zip = new ZipReader(zipFile)) {
for (ZipFileEntry entry : zip.entries()) {
String filename = entry.getName();
EntryAction action = getAction(filename);
switch (action.getType()) {
case SKIP:
break;
case COPY:
case RENAME:
writeEntry(zip, entry, action);
break;
case MERGE:
entries.put(filename, null);
InputStream in = zip.getRawInputStream(entry);
if (entry.getMethod() == Compression.DEFLATED) {
in = new InflaterInputStream(in, getInflater());
}
action.getStrategy().merge(in, action.getMergeBuffer());
break;
}
}
}
}
/** Returns the action to take for a file of the given filename. */
private EntryAction getAction(String filename) throws IOException {
// If this filename has not been encountered before (no entry for filename) or this filename
// has been renamed (RENAME entry for filename), the desired action should be recomputed.
if (!actions.containsKey(filename) || actions.get(filename).getType() == ActionType.RENAME) {
callback.resetForFile(filename);
entryFilter.accept(filename, callback);
}
checkState(actions.containsKey(filename),
"Action for file '%s' should have been set by ZipEntryFilter.", filename);
EntryAction action = actions.get(filename);
// Only copy if this is the first instance of filename.
if (action.getType() == ActionType.COPY && entries.containsKey(filename)) {
action = new EntryAction(ActionType.SKIP, action);
actions.put(filename, action);
}
// Only rename if there is not already an entry with filename or filename's action is SKIP.
if (action.getType() == ActionType.RENAME) {
if (actions.containsKey(action.getNewName())
&& actions.get(action.getNewName()).getType() == ActionType.SKIP) {
action = new EntryAction(ActionType.SKIP, action);
}
if (entries.containsKey(action.getNewName())) {
action = new EntryAction(ActionType.SKIP, action);
}
}
return action;
}
/** Writes an entry with the given name, date and external file attributes from the buffer. */
private void writeEntryFromBuffer(ZipFileEntry entry, byte[] uncompressed) throws IOException {
CRC32 crc = new CRC32();
crc.update(uncompressed);
entry.setCrc(crc.getValue());
entry.setSize(uncompressed.length);
if (mode == OutputMode.FORCE_STORED) {
entry.setMethod(Compression.STORED);
entry.setCompressedSize(uncompressed.length);
writeEntry(entry, new ByteArrayInputStream(uncompressed));
} else {
ByteArrayOutputStream compressed = new ByteArrayOutputStream();
copyStream(new DeflaterInputStream(new ByteArrayInputStream(uncompressed), getDeflater()),
compressed);
entry.setMethod(Compression.DEFLATED);
entry.setCompressedSize(compressed.size());
writeEntry(entry, new ByteArrayInputStream(compressed.toByteArray()));
}
}
/**
* Writes an entry from the specified source {@link ZipReader} and {@link ZipFileEntry} using the
* specified {@link EntryAction}.
*
* <p>Writes the output entry from the input entry performing inflation or deflation as needed
* and applies any values from the {@link EntryAction} as needed.
*/
private void writeEntry(ZipReader zip, ZipFileEntry entry, EntryAction action)
throws IOException {
checkArgument(action.getType() != ActionType.SKIP,
"Cannot write a zip entry whose action is of type SKIP.");
ZipFileEntry outEntry = new ZipFileEntry(entry);
if (action.getType() == ActionType.RENAME) {
checkNotNull(action.getNewName(),
"ZipEntryFilter actions of type RENAME must not have a null filename.");
outEntry.setName(action.getNewName());
}
if (action.getDate() != null) {
outEntry.setTime(action.getDate().getTime());
}
InputStream data;
if (mode == OutputMode.FORCE_DEFLATE && entry.getMethod() != Compression.DEFLATED) {
// The output mode is deflate, but the entry compression is not. Create a deflater stream
// from the raw file data and deflate to a temporary byte array to determine the deflated
// size. Then use this byte array as the input stream for writing the entry.
ByteArrayOutputStream tmp = new ByteArrayOutputStream();
copyStream(new DeflaterInputStream(zip.getRawInputStream(entry), getDeflater()), tmp);
data = new ByteArrayInputStream(tmp.toByteArray());
outEntry.setMethod(Compression.DEFLATED);
outEntry.setCompressedSize(tmp.size());
} else if (mode == OutputMode.FORCE_STORED && entry.getMethod() != Compression.STORED) {
// The output mode is stored, but the entry compression is not; create an inflater stream
// from the raw file data.
data = new InflaterInputStream(zip.getRawInputStream(entry), getInflater());
outEntry.setMethod(Compression.STORED);
outEntry.setCompressedSize(entry.getSize());
} else {
// Entry compression agrees with output mode; use the raw file data as is.
data = zip.getRawInputStream(entry);
}
writeEntry(outEntry, data);
}
/**
* Writes the specified {@link ZipFileEntry} using the data from the given {@link InputStream}.
*/
private void writeEntry(ZipFileEntry entry, InputStream data) throws IOException {
out.putNextEntry(entry);
copyStream(data, out);
out.closeEntry();
entries.put(entry.getName(), entry);
}
/**
* Returns true if the combined ZIP file already contains a file of the specified file name.
*
* @param filename the filename of the file whose presence in the combined ZIP file is to be
* tested
* @return true if the combined ZIP file contains the specified file
*/
public boolean containsFile(String filename) {
// TODO(apell): may be slightly different behavior because v1 returns true on skipped names.
return entries.containsKey(filename);
}
/**
* Writes any remaining output data to the output stream and also creates the merged entries by
* calling the {@link CustomMergeStrategy} implementations given back from the
* {@link ZipEntryFilter}.
*
* @throws IOException if the output stream or the filter throws an IOException
* @throws IllegalStateException if this method was already called earlier
*/
public void finish() throws IOException {
for (Map.Entry<String, EntryAction> entry : actions.entrySet()) {
String filename = entry.getKey();
EntryAction action = entry.getValue();
if (action.getType() == ActionType.MERGE) {
ByteArrayOutputStream uncompressed = action.getMergeBuffer();
action.getStrategy().finish(uncompressed);
if (uncompressed.size() == 0 && action.getStrategy().skipEmpty()) {
continue;
}
ZipFileEntry e = new ZipFileEntry(filename);
e.setTime(action.getDate() != null ? action.getDate().getTime() : new Date().getTime());
writeEntryFromBuffer(e, uncompressed.toByteArray());
}
}
out.finish();
}
/**
* Writes any remaining output data to the output stream and closes it.
*
* @throws IOException if the output stream or the filter throws an IOException
*/
@Override public void close() throws IOException {
finish();
out.close();
}
/** Ensures the truth of an expression involving one or more parameters to the calling method. */
private static void checkArgument(boolean expression,
@Nullable String errorMessageTemplate,
@Nullable Object... errorMessageArgs) {
if (!expression) {
throw new IllegalArgumentException(String.format(errorMessageTemplate, errorMessageArgs));
}
}
/** Ensures that an object reference passed as a parameter to the calling method is not null. */
public static <T> T checkNotNull(T reference,
@Nullable String errorMessageTemplate,
@Nullable Object... errorMessageArgs) {
if (reference == null) {
// If either of these parameters is null, the right thing happens anyway
throw new NullPointerException(String.format(errorMessageTemplate, errorMessageArgs));
}
return reference;
}
/** Ensures the truth of an expression involving state. */
private static void checkState(boolean expression,
@Nullable String errorMessageTemplate,
@Nullable Object... errorMessageArgs) {
if (!expression) {
throw new IllegalStateException(String.format(errorMessageTemplate, errorMessageArgs));
}
}
}