| // 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)); |
| } |
| } |
| } |