Java tutorial
/* * Copyright (C) 2015 The Android Open Source Project * * 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.android.builder.internal.packaging.zip; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.builder.internal.packaging.zip.utils.ByteTracker; import com.android.builder.internal.packaging.zip.utils.CloseableByteSource; import com.android.builder.internal.packaging.zip.utils.LittleEndianUtils; import com.android.builder.internal.packaging.zip.utils.RandomAccessFileUtils; import com.android.builder.internal.utils.CachedFileContents; import com.android.builder.internal.utils.IOExceptionFunction; import com.android.builder.internal.utils.IOExceptionRunnable; import com.android.utils.FileUtils; import com.google.common.base.Preconditions; import com.google.common.base.Verify; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.hash.Hashing; import com.google.common.io.ByteSource; import com.google.common.io.Closer; import com.google.common.io.Files; import com.google.common.primitives.Ints; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.function.Function; import java.util.function.Predicate; /** * The {@code ZFile} provides the main interface for interacting with zip files. A {@code ZFile} * can be created on a new file or in an existing file. Once created, files can be added or removed * from the zip file. * * <p>Changes in the zip file are always deferred. Any change requested is made in memory and * written to disk only when {@link #update()} or {@link #close()} is invoked. * * <p>Zip files are open initially in read-only mode and will switch to read-write when needed. This * is done automatically. Because modifications to the file are done in-memory, the zip file can * be manipulated when closed. When invoking {@link #update()} or {@link #close()} the zip file * will be reopen and changes will be written. However, the zip file cannot be modified outside * the control of {@code ZFile}. So, if a {@code ZFile} is closed, modified outside and then a file * is added or removed from the zip file, when reopening the zip file, {@link ZFile} will detect * the outside modification and will fail. * * <p>In memory manipulation means that files added to the zip file are kept in memory until written * to disk. This provides much faster operation and allows better zip file allocation (see below). * It may, however, increase the memory footprint of the application. When adding large files, if * memory consumption is a concern, a call to {@link #update()} will actually write the file to * disk and discard the memory buffer. Information about allocation can be obtained from a * {@link ByteTracker} that can be given to the file on creation. * * <p>{@code ZFile} keeps track of allocation inside of the zip file. If a file is deleted, its * space is marked as freed and will be reused for an added file if it fits in the space. * Allocation of files to empty areas is done using a <em>best fit</em> algorithm. When adding a * file, if it doesn't fit in any free area, the zip file will be extended. * * <p>{@code ZFile} provides a fast way to merge data from another zip file * (see {@link #mergeFrom(ZFile, Predicate)}) avoiding recompression and copying of equal files. * When merging, patterns of files may be provided that are ignored. This allows handling special * files in the merging process, such as files in {@code META-INF}. * * <p>When adding files to the zip file, unless files are explicitly required to be stored, files * will be deflated. However, deflating will not occur if the deflated file is larger then the * stored file, <em>e.g.</em> if compression would yield a bigger file. See {@link Compressor} for * details on how compression works. * * <p>Because {@code ZFile} was designed to be used in a build system and not as general-purpose * zip utility, it is very strict (and unforgiving) about the zip format and unsupported features. * * <p>{@code ZFile} supports <em>alignment</em>. Alignment means that file data (not entries -- the * local header must be discounted) must start at offsets that are multiple of a number -- the * alignment. Alignment is defined by an alignment rules ({@link AlignmentRule} in the * {@link ZFileOptions} object used to create the {@link ZFile}. * * <p>When a file is added to the zip, the alignment rules will be checked and alignment will be * honored when positioning the file in the zip. This means that unused spaces in the zip may * be generated as a result. However, alignment of existing entries will not be changed. * * <p>Entries can be realigned individually (see {@link StoredEntry#realign()} or the full zip file * may be realigned (see {@link #realign()}). When realigning the full zip entries that are already * aligned will not be affected. * * <p>Because realignment may cause files to move in the zip, realignment is done in-memory meaning * that files that need to change location will moved to memory and will only be flushed when * either {@link #update()} or {@link #close()} are called. * * <p>Alignment only applies to filed that are forced to be uncompressed. This is because alignment * is used to allow mapping files in the archive directly into memory and compressing defeats the * purpose of alignment. * * <p>Manipulating zip files with {@link ZFile} may yield zip files with empty spaces between files. * This happens in two situations: (1) if alignment is required, files may be shifted to conform to * the request alignment leaving an empty space before the previous file, and (2) if a file is * removed or replaced with a file that does not fit the space it was in. By default, {@link ZFile} * does not do any special processing in these situations. Files are indexed by their offsets from * the central directory and empty spaces can exist in the zip file. * * <p>However, it is possible to tell {@link ZFile} to use the extra field in the local header * to do cover the empty spaces. This is done by setting * {@link ZFileOptions#setCoverEmptySpaceUsingExtraField(boolean)} to {@code true}. This has the * advantage of leaving no gaps between entries in the zip, as required by some tools like Oracle's * {code jar} tool. However, setting this option will destroy the contents of the file's extra * field. * * <p>Activating {@link ZFileOptions#setCoverEmptySpaceUsingExtraField(boolean)} may lead to * <i>virtual files</i> being added to the zip file. Since extra field is limited to 64k, it is not * possible to cover any space bigger than that using the extra field. In those cases, <i>virtual * files</i> are added to the file. A virtual file is a file that exists in the actual zip data, * but is not referenced from the central directory. A zip-compliant utility should ignore these * files. However, zip utilities that expect the zip to be a stream, such as Oracle's jar, will * find these files instead of considering the zip to be corrupt. * * <p>{@code ZFile} support sorting zip files. Sorting (done through the {@link #sortZipContents()} * method) is a process by which all files are re-read into memory, if not already in memory, * removed from the zip and re-added in alphabetical order, respecting alignment rules. So, in * general, file {@code b} will come after file {@code a} unless file {@code a} is subject to * alignment that forces an empty space before that can be occupied by {@code b}. Sorting can be * used to minimize the changes between two zips. * * <p>Sorting in {@code ZFile} can be done manually or automatically. Manual sorting is done by * invoking {@link #sortZipContents()}. Automatic sorting is done by setting the * {@link ZFileOptions#getAutoSortFiles()} option when creating the {@code ZFile}. Automatic * sorting invokes {@link #sortZipContents()} immediately when doing an {@link #update()} after * all extensions have processed the {@link ZFileExtension#beforeUpdate()}. This has the guarantee * that files added by extensions will be sorted, something that does not happen if the invocation * is sequential, <i>i.e.</i>, {@link #sortZipContents()} called before {@link #update()}. The * drawback of automatic sorting is that sorting will happen every time {@link #update()} is * called and the file is dirty having a possible penalty in performance. * * <p>To allow whole-apk signing, the {@code ZFile} allows the central directory location to be * offset by a fixed amount. This amount can be set using the {@link #setExtraDirectoryOffset(long)} * method. Setting a non-zero value will add extra (unused) space in the zip file before the * central directory. This value can be changed at any time and it will force the central directory * rewritten when the file is updated or closed. * * <p>{@code ZFile} provides an extension mechanism to allow objects to register with the file * and be notified when changes to the file happen. This should be used * to add extra features to the zip file while providing strong decoupling. See * {@link ZFileExtension}, {@link ZFile#addZFileExtension(ZFileExtension)} and * {@link ZFile#removeZFileExtension(ZFileExtension)}. * * <p>This class is <strong>not</strong> thread-safe. Neither are any of the classes associated with * it in this package, except when otherwise noticed. */ public class ZFile implements Closeable { /** * The file separator in paths in the zip file. This is fixed by the zip specification * (section 4.4.17). */ public static final char SEPARATOR = '/'; /** * Minimum size the EOCD can have. */ private static final int MIN_EOCD_SIZE = 22; /** * Number of bytes of the Zip64 EOCD locator record. */ private static final int ZIP64_EOCD_LOCATOR_SIZE = 20; /** * How many bytes to look back from the end of the file to look for the EOCD signature. */ private static final int LAST_BYTES_TO_READ = 65535 + MIN_EOCD_SIZE; /** * Signature of the Zip64 EOCD locator record. */ private static final int ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50; /** * Size of buffer for I/O operations. */ private static final int IO_BUFFER_SIZE = 1024 * 1024; /** * When extensions request re-runs, we do maximum number of cycles until we decide to stop and * flag a infinite recursion problem. */ private static final int MAXIMUM_EXTENSION_CYCLE_COUNT = 10; /** * Minimum size for the extra field. */ private static final int MINIMUM_EXTRA_FIELD_SIZE = 6; /** * Header ID for field with zip alignment. */ private static final int ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID = 0xd935; /** * Maximum size of the extra field. * * <p>Theoretically, this is (1 << 16) - 1 = 65535 and not (1 < 15) -1 = 32767. However, due to * http://b.android.com/221703, we need to keep this limited. */ private static final int MAX_LOCAL_EXTRA_FIELD_CONTENTS_SIZE = (1 << 15) - 1; /** * File zip file. */ @NonNull private final File mFile; /** * The random access file used to access the zip file. This will be {@code null} if and only * if {@link #mState} is {@link ZipFileState#CLOSED}. */ @Nullable private RandomAccessFile mRaf; /** * The map containing the in-memory contents of the zip file. It keeps track of which parts of * the zip file are used and which are not. */ @NonNull private final FileUseMap mMap; /** * The EOCD entry. Will be {@code null} if there is no EOCD (because the zip is new) or the * one that exists on disk is no longer valid (because the zip has been changed). */ @Nullable private FileUseMapEntry<Eocd> mEocdEntry; /** * The Central Directory entry. Will be {@code null} if there is no Central Directory (because * the zip is new) or because the one that exists on disk is no longer valid (because the zip * has been changed). */ @Nullable private FileUseMapEntry<CentralDirectory> mDirectoryEntry; /** * All entries in the zip file. It includes in-memory changes and may not reflect what is * written on disk. Only entries that have been compressed are in this list. */ @NonNull private final Map<String, FileUseMapEntry<StoredEntry>> mEntries; /** * Entries added to the zip file, but that are not yet compressed. When compression is done, * these entries are eventually moved to {@link #mEntries}. mUncompressedEntries is a list * because entries need to be kept in the order by which they were added. It allows adding * multiple files with the same name and getting the right notifications on which files replaced * which. * * <p>Files are placed in this list in {@link #add(StoredEntry)} method. This method will * keep files here temporarily and move then to {@link #mEntries} when the data is * available. * * <p>Moving files out of this list to {@link #mEntries} is done by * {@link #processAllReadyEntries()}. */ @NonNull private final List<StoredEntry> mUncompressedEntries; /** * Current state of the zip file. */ @NonNull private ZipFileState mState; /** * Are the in-memory changes that have not been written to the zip file? */ private boolean mDirty; /** * Non-{@code null} only if the file is currently closed. Used to detect if the zip is * modified outside this object's control. If the file has never been written, this will * be {@code null} even if it is closed. */ @Nullable private CachedFileContents<Object> mClosedControl; /** * The alignment rule. */ @NonNull private final AlignmentRule mAlignmentRule; /** * Extensions registered with the file. */ @NonNull private final List<ZFileExtension> mExtensions; /** * When notifying extensions, extensions may request that some runnables are executed. This * list collects all runnables by the order they were requested. Together with * {@link #mIsNotifying}, it is used to avoid reordering notifications. */ @NonNull private final List<IOExceptionRunnable> mToRun; /** * {@code true} when {@link #notify(IOExceptionFunction)} is notifying extensions. Used * to avoid reordering notifications. */ private boolean mIsNotifying; /** * An extra offset for the central directory location. {@code 0} if the central directory * should be written in its standard location. */ private long mExtraDirectoryOffset; /** * Should all timestamps be zeroed when reading / writing the zip? */ private boolean mNoTimestamps; /** * Compressor to use. */ @NonNull private Compressor mCompressor; /** * Byte tracker to use. */ @NonNull private final ByteTracker mTracker; /** * Use the zip entry's "extra field" field to cover empty space in the zip file? */ private boolean mCoverEmptySpaceUsingExtraField; /** * Should files be automatically sorted when updating? */ private boolean mAutoSortFiles; /** * Creates a new zip file. If the zip file does not exist, then no file is created at this * point and {@code ZFile} will contain an empty structure. However, an (empty) zip file will * be created if either {@link #update()} or {@link #close()} are used. If a zip file exists, * it will be parsed and read. * * @param file the zip file * @throws IOException some file exists but could not be read */ public ZFile(@NonNull File file) throws IOException { this(file, new ZFileOptions()); } /** * Creates a new zip file. If the zip file does not exist, then no file is created at this * point and {@code ZFile} will contain an empty structure. However, an (empty) zip file will * be created if either {@link #update()} or {@link #close()} are used. If a zip file exists, * it will be parsed and read. * * @param file the zip file * @param options configuration options * @throws IOException some file exists but could not be read */ public ZFile(@NonNull File file, @NonNull ZFileOptions options) throws IOException { mFile = file; mMap = new FileUseMap(0, options.getCoverEmptySpaceUsingExtraField() ? MINIMUM_EXTRA_FIELD_SIZE : 0); mDirty = false; mClosedControl = null; mAlignmentRule = options.getAlignmentRule(); mExtensions = Lists.newArrayList(); mToRun = Lists.newArrayList(); mNoTimestamps = options.getNoTimestamps(); mTracker = options.getTracker(); mCompressor = options.getCompressor(); mCoverEmptySpaceUsingExtraField = options.getCoverEmptySpaceUsingExtraField(); mAutoSortFiles = options.getAutoSortFiles(); /* * These two values will be overwritten by openReadOnly() below if the file exists. */ mState = ZipFileState.CLOSED; mRaf = null; if (file.exists()) { openReadOnly(); } else { mDirty = true; } mEntries = Maps.newHashMap(); mUncompressedEntries = Lists.newArrayList(); mExtraDirectoryOffset = 0; try { if (mState != ZipFileState.CLOSED) { long rafSize = mRaf.length(); if (rafSize > Integer.MAX_VALUE) { throw new IOException("File exceeds size limit of " + Integer.MAX_VALUE + "."); } mMap.extend(Ints.checkedCast(rafSize)); readData(); notify(ZFileExtension::open); } } catch (IOException e) { throw new IOException("Failed to read zip file '" + file.getAbsolutePath() + "'.", e); } } /** * Obtains all entries in the file. Entries themselves may be or not written in disk. However, * all of them can be open for reading. * * @return all entries in the zip */ @NonNull public Set<StoredEntry> entries() { Map<String, StoredEntry> entries = Maps.newHashMap(); for (FileUseMapEntry<StoredEntry> mapEntry : mEntries.values()) { StoredEntry entry = mapEntry.getStore(); assert entry != null; entries.put(entry.getCentralDirectoryHeader().getName(), entry); } /* * mUncompressed may override mEntriesReady as we may not have yet processed all * entries. */ for (StoredEntry uncompressed : mUncompressedEntries) { entries.put(uncompressed.getCentralDirectoryHeader().getName(), uncompressed); } return Sets.newHashSet(entries.values()); } /** * Obtains an entry at a given path in the zip. * * @param path the path * @return the entry at the path or {@code null} if none exists */ @Nullable public StoredEntry get(@NonNull String path) { /* * The latest entries are the last ones in uncompressed and they may eventually override * files in mEntries. */ for (StoredEntry stillUncompressed : Lists.reverse(mUncompressedEntries)) { if (stillUncompressed.getCentralDirectoryHeader().getName().equals(path)) { return stillUncompressed; } } FileUseMapEntry<StoredEntry> found = mEntries.get(path); if (found == null) { return null; } return found.getStore(); } /** * Reads all the data in the zip file, except the contents of the entries themselves. This * method will populate the directory and maps in the instance variables. * * @throws IOException failed to read the zip file */ private void readData() throws IOException { Preconditions.checkState(mState != ZipFileState.CLOSED, "mState == ZipFileState.CLOSED"); Preconditions.checkState(mRaf != null, "mRaf == null"); readEocd(); readCentralDirectory(); /* * Compute where the last file ends. We will need this to compute thee extra offset. */ long entryEndOffset; long directoryStartOffset; if (mDirectoryEntry != null) { CentralDirectory directory = mDirectoryEntry.getStore(); assert directory != null; entryEndOffset = 0; for (StoredEntry entry : directory.getEntries().values()) { long start = entry.getCentralDirectoryHeader().getOffset(); long end = start + entry.getInFileSize(); /* * If isExtraAlignmentBlock(entry.getLocalExtra()) is true, we know the entry * has an extra field that is solely used for alignment. This means the * actual entry could start at start + extra.length and leave space before. * * But, if we did this here, we would be modifying the zip file and that is * weird because we're just opening it for reading. * * The downside is that we will never reuse that space. Maybe one day ZFile * can be clever enough to remove the local extra when we start modifying the zip * file. */ FileUseMapEntry<StoredEntry> mapEntry = mMap.add(start, end, entry); mEntries.put(entry.getCentralDirectoryHeader().getName(), mapEntry); if (end > entryEndOffset) { entryEndOffset = end; } } directoryStartOffset = mDirectoryEntry.getStart(); } else { /* * No directory means an empty zip file. Use the start of the EOCD to compute * an existing offset. */ Verify.verifyNotNull(mEocdEntry); assert mEocdEntry != null; directoryStartOffset = mEocdEntry.getStart(); entryEndOffset = 0; } long extraOffset = directoryStartOffset - entryEndOffset; Verify.verify(extraOffset >= 0, "extraOffset (%s) < 0", extraOffset); setExtraDirectoryOffset(extraOffset); } /** * Finds the EOCD marker and reads it. It will populate the {@link #mEocdEntry} variable. * * @throws IOException failed to read the EOCD */ private void readEocd() throws IOException { Preconditions.checkState(mState != ZipFileState.CLOSED, "mState == ZipFileState.CLOSED"); Preconditions.checkState(mRaf != null, "mRaf == null"); /* * Read the last part of the zip into memory. If we don't find the EOCD signature by then, * the file is corrupt. */ int lastToRead = LAST_BYTES_TO_READ; if (lastToRead > mRaf.length()) { lastToRead = Ints.checkedCast(mRaf.length()); } byte[] last = new byte[lastToRead]; directFullyRead(mRaf.length() - lastToRead, last); byte[] eocdSignature = new byte[] { 0x06, 0x05, 0x4b, 0x50 }; /* * Start endIdx at the first possible location where the signature can be located and then * move backwards. Because the EOCD must have at least MIN_EOCD size, the first byte of the * signature (and first byte of the EOCD) must be located at last.length - MIN_EOCD_SIZE. * * Because the EOCD signature may exist in the file comment, when we find a signature we * will try to read the Eocd. If we fail, we continue searching for the signature. However, * we will keep the last exception in case we don't find any signature. */ Eocd eocd = null; int foundEocdSignature = -1; IOException errorFindingSignature = null; int eocdStart = -1; for (int endIdx = last.length - MIN_EOCD_SIZE; endIdx >= 0 && foundEocdSignature == -1; endIdx--) { /* * Remember: little endian... */ if (last[endIdx] == eocdSignature[3] && last[endIdx + 1] == eocdSignature[2] && last[endIdx + 2] == eocdSignature[1] && last[endIdx + 3] == eocdSignature[0]) { /* * We found a signature. Try to read the EOCD record. */ foundEocdSignature = endIdx; ByteBuffer eocdBytes = ByteBuffer.wrap(last, foundEocdSignature, last.length - foundEocdSignature); try { eocd = new Eocd(eocdBytes); eocdStart = Ints.checkedCast(mRaf.length() - lastToRead + foundEocdSignature); /* * Make sure the EOCD takes the whole file up to the end. */ if (eocdStart + eocd.getEocdSize() != mRaf.length()) { throw new IOException("EOCD starts at " + eocdStart + " and has " + eocd.getEocdSize() + " bytes, but file ends at " + mRaf.length() + "."); } } catch (IOException e) { if (errorFindingSignature != null) { e.addSuppressed(errorFindingSignature); } errorFindingSignature = e; foundEocdSignature = -1; eocd = null; } } } if (foundEocdSignature == -1) { throw new IOException("EOCD signature not found in the last " + lastToRead + " bytes of the file.", errorFindingSignature); } Verify.verify(eocdStart >= 0); /* * Look for the Zip64 central directory locator. If we find it, then this file is a Zip64 * file and we do not support it. */ int zip64LocatorStart = eocdStart - ZIP64_EOCD_LOCATOR_SIZE; if (zip64LocatorStart >= 0) { byte possibleZip64Locator[] = new byte[4]; directFullyRead(zip64LocatorStart, possibleZip64Locator); if (LittleEndianUtils .readUnsigned4Le(ByteBuffer.wrap(possibleZip64Locator)) == ZIP64_EOCD_LOCATOR_SIGNATURE) { throw new IOException("Zip64 EOCD locator found but Zip64 format is not " + "supported."); } } mEocdEntry = mMap.add(eocdStart, eocdStart + eocd.getEocdSize(), eocd); } /** * Reads the zip's central directory and populates the {@link #mDirectoryEntry} variable. This * method can only be called after the EOCD has been read. If the central directory is empty * (if there are no files on the zip archive), then {@link #mDirectoryEntry} will be set to * {@code null}. * * @throws IOException failed to read the central directory */ private void readCentralDirectory() throws IOException { Preconditions.checkNotNull(mEocdEntry, "mEocdEntry == null"); Preconditions.checkNotNull(mEocdEntry.getStore(), "mEocdEntry.getStore() == null"); Preconditions.checkState(mState != ZipFileState.CLOSED, "mState == ZipFileState.CLOSED"); Preconditions.checkState(mRaf != null, "mRaf == null"); Preconditions.checkState(mDirectoryEntry == null, "mDirectoryEntry != null"); Eocd eocd = mEocdEntry.getStore(); long dirSize = eocd.getDirectorySize(); if (dirSize > Integer.MAX_VALUE) { throw new IOException("Cannot read central directory with size " + dirSize + "."); } if (eocd.getDirectoryOffset() + dirSize != mEocdEntry.getStart()) { throw new IOException("Central directory is stored in [" + eocd.getDirectoryOffset() + " - " + (eocd.getDirectoryOffset() + dirSize) + "] and EOCD starts at " + mEocdEntry.getStart() + "."); } byte[] directoryData = new byte[Ints.checkedCast(dirSize)]; directFullyRead(eocd.getDirectoryOffset(), directoryData); CentralDirectory directory = CentralDirectory.makeFromData(ByteBuffer.wrap(directoryData), eocd.getTotalRecords(), this); if (eocd.getDirectorySize() > 0) { mDirectoryEntry = mMap.add(eocd.getDirectoryOffset(), eocd.getDirectoryOffset() + eocd.getDirectorySize(), directory); } } /** * Opens a portion of the zip for reading. The zip must be open for this method to be invoked. * Note that if the zip has not been updated, the individual zip entries may not have been * written yet. * * @param start the index within the zip file to start reading * @param end the index within the zip file to end reading (the actual byte pointed by * <em>end</em> will not be read) * @return a stream that will read the portion of the file; no decompression is done, data is * returned <em>as is</em> * @throws IOException failed to open the zip file */ @NonNull public InputStream directOpen(final long start, final long end) throws IOException { Preconditions.checkState(mState != ZipFileState.CLOSED, "mState == ZipFileState.CLOSED"); Preconditions.checkState(mRaf != null, "mRaf == null"); Preconditions.checkArgument(start >= 0, "start < 0"); Preconditions.checkArgument(end >= start, "end < start"); Preconditions.checkArgument(end <= mRaf.length(), "end > mRaf.length()"); return new InputStream() { private long mCurr = start; @Override public int read() throws IOException { if (mCurr == end) { return -1; } byte b[] = new byte[1]; int r = directRead(mCurr, b); if (r > 0) { mCurr++; return b[0]; } else { return -1; } } @Override public int read(@NonNull byte[] b, int off, int len) throws IOException { Preconditions.checkNotNull(b, "b == null"); Preconditions.checkArgument(off >= 0, "off < 0"); Preconditions.checkArgument(off <= b.length, "off > b.length"); Preconditions.checkArgument(len >= 0, "len < 0"); Preconditions.checkArgument(off + len <= b.length, "off + len > b.length"); long availableToRead = end - mCurr; long toRead = Math.min(len, availableToRead); if (toRead == 0) { return -1; } if (toRead > Integer.MAX_VALUE) { throw new IOException("Cannot read " + toRead + " bytes."); } int r = directRead(mCurr, b, off, Ints.checkedCast(toRead)); if (r > 0) { mCurr += r; } return r; } }; } /** * Deletes an entry from the zip. This method does not actually delete anything on disk. It * just changes in-memory structures. Use {@link #update()} to update the contents on disk. * * @param entry the entry to delete * @param notify should listeners be notified of the deletion? This will only be * {@code false} if the entry is being removed as part of a replacement * @throws IOException failed to delete the entry */ void delete(@NonNull final StoredEntry entry, boolean notify) throws IOException { String path = entry.getCentralDirectoryHeader().getName(); FileUseMapEntry<StoredEntry> mapEntry = mEntries.get(path); Preconditions.checkNotNull(mapEntry, "mapEntry == null"); Preconditions.checkArgument(entry == mapEntry.getStore(), "entry != mapEntry.getStore()"); mDirty = true; mMap.remove(mapEntry); mEntries.remove(path); if (notify) { notify(ext -> ext.removed(entry)); } } /** * Updates the file writing new entries and removing deleted entries. This will force * reopening the file as read/write if the file wasn't open in read/write mode. * * @throws IOException failed to update the file; this exception may have been thrown by * the compressor but only reported here */ public void update() throws IOException { /* * Process all background stuff before calling in the extensions. */ processAllReadyEntriesWithWait(); notify(ZFileExtension::beforeUpdate); /* * Process all background stuff that may be leftover by the extensions. */ processAllReadyEntriesWithWait(); if (!mDirty) { return; } reopenRw(); /* * At this point, no more files can be added. We may need to repack to remove extra * empty spaces or sort. If we sort, we don't need to repack as sorting forces the * zip file to be as compact as possible. */ if (mAutoSortFiles) { sortZipContents(); } else { packIfNecessary(); } /* * We're going to change the file so delete the central directory and the EOCD as they * will have to be rewritten. */ deleteDirectoryAndEocd(); mMap.truncate(); /* * If we need to use the extra field to cover empty spaces, we do the processing here. */ if (mCoverEmptySpaceUsingExtraField) { /* We will go over all files in the zip and check whether there is empty space before * them. If there is, then we will move the entry to the beginning of the empty space * (covering it) and extend the extra field with the size of the empty space. */ for (FileUseMapEntry<StoredEntry> entry : new HashSet<>(mEntries.values())) { StoredEntry storedEntry = entry.getStore(); assert storedEntry != null; FileUseMapEntry<?> before = mMap.before(entry); if (before == null || !before.isFree()) { continue; } int localExtraSize = storedEntry.getLocalExtra().length + Ints.checkedCast(before.getSize()); /* * Sorting or packing should have ensured this never happens. */ Verify.verify(localExtraSize <= MAX_LOCAL_EXTRA_FIELD_CONTENTS_SIZE); /* * Move file back in the zip. */ storedEntry.loadSourceIntoMemory(); long newStart = before.getStart(); long newSize = entry.getSize() + before.getSize(); String name = storedEntry.getCentralDirectoryHeader().getName(); mMap.remove(entry); Verify.verify(entry == mEntries.remove(name)); storedEntry.setLocalExtra(makeExtraAlignmentBlock(localExtraSize, chooseAlignment(storedEntry))); mEntries.put(name, mMap.add(newStart, newStart + newSize, storedEntry)); /* * Reset the offset to force the file to be rewritten. */ storedEntry.getCentralDirectoryHeader().setOffset(-1); } } /* * Write new files in the zip. We identify new files because they don't have an offset * in the zip where they are written although we already know, by their location in the * file map, where they will be written to. * * Before writing the files, we sort them in the order they are written in the file so that * writes are made in order on disk. * This is, however, unlikely to optimize anything relevant given the way the Operating * System does caching, but it certainly won't hurt :) */ TreeMap<FileUseMapEntry<?>, StoredEntry> toWriteToStore = new TreeMap<>(FileUseMapEntry.COMPARE_BY_START); for (FileUseMapEntry<StoredEntry> entry : mEntries.values()) { StoredEntry entryStore = entry.getStore(); assert entryStore != null; if (entryStore.getCentralDirectoryHeader().getOffset() == -1) { toWriteToStore.put(entry, entryStore); } } /* * Add all free entries to the set. */ for (FileUseMapEntry<?> freeArea : mMap.getFreeAreas()) { toWriteToStore.put(freeArea, null); } /* * Write everything to file. */ for (FileUseMapEntry<?> fileUseMapEntry : toWriteToStore.keySet()) { StoredEntry entry = toWriteToStore.get(fileUseMapEntry); if (entry == null) { int size = Ints.checkedCast(fileUseMapEntry.getSize()); directWrite(fileUseMapEntry.getStart(), new byte[size]); } else { writeEntry(entry, fileUseMapEntry.getStart()); } } boolean hasCentralDirectory; int extensionBugDetector = MAXIMUM_EXTENSION_CYCLE_COUNT; do { computeCentralDirectory(); computeEocd(); hasCentralDirectory = (mDirectoryEntry != null); notify(ext -> { ext.entriesWritten(); return null; }); if ((--extensionBugDetector) == 0) { throw new IOException( "Extensions keep resetting the central directory. This is " + "probably a bug."); } } while (hasCentralDirectory && mDirectoryEntry == null); appendCentralDirectory(); appendEocd(); Verify.verifyNotNull(mRaf); mRaf.setLength(mMap.size()); mDirty = false; notify(ext -> { ext.updated(); return null; }); } /** * Reorganizes the zip so that there are no gaps between files bigger than * {@link #MAX_LOCAL_EXTRA_FIELD_CONTENTS_SIZE} if {@link #mCoverEmptySpaceUsingExtraField} * is set to {@code true}. * * @throws IOException failed to repack */ private void packIfNecessary() throws IOException { if (!mCoverEmptySpaceUsingExtraField) { return; } SortedSet<FileUseMapEntry<StoredEntry>> entriesByLocation = new TreeSet<>(FileUseMapEntry.COMPARE_BY_START); entriesByLocation.addAll(mEntries.values()); for (FileUseMapEntry<StoredEntry> entry : entriesByLocation) { StoredEntry storedEntry = entry.getStore(); assert storedEntry != null; FileUseMapEntry<?> before = mMap.before(entry); if (before == null || !before.isFree()) { continue; } int localExtraSize = storedEntry.getLocalExtra().length + Ints.checkedCast(before.getSize()); if (localExtraSize > MAX_LOCAL_EXTRA_FIELD_CONTENTS_SIZE) { /* * This entry is too far from the previous one. Remove it and re-add it to the * zip file. */ reAdd(storedEntry, PositionHint.LOWEST_OFFSET); } } } /** * Removes a stored entry from the zip and adds it back again. This will force the entry to be * loaded into memory and repositioned in the zip file. It will also mark the archive as * being dirty. * * @param entry the entry * @param positionHint hint to where the file should be positioned when re-adding * @throws IOException failed to load the entry into memory */ private void reAdd(@NonNull StoredEntry entry, @NonNull PositionHint positionHint) throws IOException { String name = entry.getCentralDirectoryHeader().getName(); FileUseMapEntry<StoredEntry> mapEntry = mEntries.get(name); Preconditions.checkNotNull(mapEntry); Preconditions.checkState(mapEntry.getStore() == entry); entry.loadSourceIntoMemory(); mMap.remove(mapEntry); mEntries.remove(name); FileUseMapEntry<StoredEntry> positioned = positionInFile(entry, positionHint); mEntries.put(name, positioned); mDirty = true; } /** * Invoked from {@link StoredEntry} when entry has changed in a way that forces the local * header to be rewritten * * @param entry the entry that changed * @param resized was the local header resized? * @throws IOException failed to load the entry into memory */ void localHeaderChanged(@NonNull StoredEntry entry, boolean resized) throws IOException { mDirty = true; if (resized) { reAdd(entry, PositionHint.ANYWHERE); } } /** * Invoked when the central directory has changed and needs to be rewritten. */ void centralDirectoryChanged() { mDirty = true; deleteDirectoryAndEocd(); } /** * Updates the file and closes it. */ @Override public void close() throws IOException { // We need to make sure to release mRaf, otherwise we end up locking the file on // Windows. Use try-with-resources to handle exception suppressing. try (Closeable ignored = this::innerClose) { update(); } notify(ext -> { ext.closed(); return null; }); } /** * Removes the Central Directory and EOCD from the file. This will free space for new entries * as well as allowing the zip file to be truncated if files have been removed. */ private void deleteDirectoryAndEocd() { if (mDirectoryEntry != null) { mMap.remove(mDirectoryEntry); mDirectoryEntry = null; } if (mEocdEntry != null) { mMap.remove(mEocdEntry); mEocdEntry = null; } } /** * Writes an entry's data in the zip file. This includes everything: the local header and * the data itself. After writing, the entry is updated with the offset and its source replaced * with a source that reads from the zip file. * * @param entry the entry to write * @param offset the offset at which the entry should be written * @throws IOException failed to write the entry */ private void writeEntry(@NonNull StoredEntry entry, long offset) throws IOException { Preconditions.checkArgument(entry.getDataDescriptorType() == DataDescriptorType.NO_DATA_DESCRIPTOR, "Cannot write entries with a data " + "descriptor."); Preconditions.checkNotNull(mRaf, "mRaf == null"); Preconditions.checkState(mState == ZipFileState.OPEN_RW, "mState != ZipFileState.OPEN_RW"); /* * Place the cursor and write the local header. */ byte[] headerData = entry.toHeaderData(); directWrite(offset, headerData); /* * Get the raw source data to write. */ ProcessedAndRawByteSources source = entry.getSource(); ByteSource rawContents = source.getRawByteSource(); /* * Write the source data. */ byte[] chunk = new byte[IO_BUFFER_SIZE]; int r; long writeOffset = offset + headerData.length; InputStream is = rawContents.openStream(); while ((r = is.read(chunk)) >= 0) { directWrite(writeOffset, chunk, 0, r); writeOffset += r; } is.close(); /* * Set the entry's offset and create the entry source. */ entry.replaceSourceFromZip(offset); } /** * Computes the central directory. The central directory must not have been computed yet. When * this method finishes, the central directory has been computed {@link #mDirectoryEntry}, * unless the directory is empty in which case {@link #mDirectoryEntry} * is left as {@code null}. Nothing is written to disk as a result of this method's invocation. * * @throws IOException failed to append the central directory */ private void computeCentralDirectory() throws IOException { Preconditions.checkState(mState == ZipFileState.OPEN_RW, "mState != ZipFileState.OPEN_RW"); Preconditions.checkNotNull(mRaf, "mRaf == null"); Preconditions.checkState(mDirectoryEntry == null, "mDirectoryEntry == null"); Set<StoredEntry> newStored = Sets.newHashSet(); for (FileUseMapEntry<StoredEntry> mapEntry : mEntries.values()) { newStored.add(mapEntry.getStore()); } /* * Make sure we truncate the map before computing the central directory's location since * the central directory is the last part of the file. */ mMap.truncate(); CentralDirectory newDirectory = CentralDirectory.makeFromEntries(newStored, this); byte[] newDirectoryBytes = newDirectory.toBytes(); long directoryOffset = mMap.size() + mExtraDirectoryOffset; mMap.extend(directoryOffset + newDirectoryBytes.length); if (newDirectoryBytes.length > 0) { mDirectoryEntry = mMap.add(directoryOffset, directoryOffset + newDirectoryBytes.length, newDirectory); } } /** * Writes the central directory to the end of the zip file. {@link #mDirectoryEntry} may be * {@code null} only if there are no files in the archive. * * @throws IOException failed to append the central directory */ private void appendCentralDirectory() throws IOException { Preconditions.checkState(mState == ZipFileState.OPEN_RW, "mState != ZipFileState.OPEN_RW"); Preconditions.checkNotNull(mRaf, "mRaf == null"); if (mEntries.isEmpty()) { Preconditions.checkState(mDirectoryEntry == null, "mDirectoryEntry != null"); return; } Preconditions.checkNotNull(mDirectoryEntry, "mDirectoryEntry != null"); CentralDirectory newDirectory = mDirectoryEntry.getStore(); Preconditions.checkNotNull(newDirectory, "newDirectory != null"); byte[] newDirectoryBytes = newDirectory.toBytes(); long directoryOffset = mDirectoryEntry.getStart(); /* * It is fine to seek beyond the end of file. Seeking beyond the end of file will not extend * the file. Even if we do not have any directory data to write, the extend() call below * will force the file to be extended leaving exactly mExtraDirectoryOffset bytes empty at * the beginning. */ directWrite(directoryOffset, newDirectoryBytes); } /** * Obtains the byte array representation of the central directory. The central directory must * have been already computed. If there are no entries in the zip, the central directory will be * empty. * * @return the byte representation, or an empty array if there are no entries in the zip * @throws IOException failed to compute the central directory byte representation */ @NonNull public byte[] getCentralDirectoryBytes() throws IOException { if (mEntries.isEmpty()) { Preconditions.checkState(mDirectoryEntry == null, "mDirectoryEntry != null"); return new byte[0]; } Preconditions.checkNotNull(mDirectoryEntry, "mDirectoryEntry == null"); CentralDirectory cd = mDirectoryEntry.getStore(); Preconditions.checkNotNull(cd, "cd == null"); return cd.toBytes(); } /** * Computes the EOCD. This creates a new {@link #mEocdEntry}. The * central directory must already be written. If {@link #mDirectoryEntry} is {@code null}, then * the zip file must not have any entries. * * @throws IOException failed to write the EOCD */ private void computeEocd() throws IOException { Preconditions.checkState(mState == ZipFileState.OPEN_RW, "mState != ZipFileState.OPEN_RW"); Preconditions.checkNotNull(mRaf, "mRaf == null"); if (mDirectoryEntry == null) { Preconditions.checkState(mEntries.isEmpty(), "mDirectoryEntry == null && !mEntries.isEmpty()"); } long dirStart; long dirSize = 0; if (mDirectoryEntry != null) { CentralDirectory directory = mDirectoryEntry.getStore(); assert directory != null; dirStart = mDirectoryEntry.getStart(); dirSize = mDirectoryEntry.getSize(); Verify.verify(directory.getEntries().size() == mEntries.size()); } else { /* * If we do not have a directory, then we must leave any requested offset empty. */ dirStart = mExtraDirectoryOffset; } Eocd eocd = new Eocd(mEntries.size(), dirStart, dirSize); byte[] eocdBytes = eocd.toBytes(); long eocdOffset = mMap.size(); mMap.extend(eocdOffset + eocdBytes.length); mEocdEntry = mMap.add(eocdOffset, eocdOffset + eocdBytes.length, eocd); } /** * Writes the EOCD to the end of the zip file. This creates a new {@link #mEocdEntry}. The * central directory must already be written. If {@link #mDirectoryEntry} is {@code null}, then * the zip file must not have any entries. * * @throws IOException failed to write the EOCD */ private void appendEocd() throws IOException { Preconditions.checkState(mState == ZipFileState.OPEN_RW, "mState != ZipFileState.OPEN_RW"); Preconditions.checkNotNull(mRaf, "mRaf == null"); Preconditions.checkNotNull(mEocdEntry, "mEocdEntry == null"); Eocd eocd = mEocdEntry.getStore(); Preconditions.checkNotNull(eocd, "eocd == null"); byte[] eocdBytes = eocd.toBytes(); long eocdOffset = mEocdEntry.getStart(); directWrite(eocdOffset, eocdBytes); } /** * Obtains the byte array representation of the EOCD. The EOCD must have already been computed * for this method to be invoked. * * @return the byte representation of the EOCD * @throws IOException failed to obtain the byte representation of the EOCD */ @NonNull public byte[] getEocdBytes() throws IOException { Preconditions.checkNotNull(mEocdEntry, "mEocdEntry == null"); Eocd eocd = mEocdEntry.getStore(); Preconditions.checkNotNull(eocd, "eocd == null"); return eocd.toBytes(); } /** * Closes the file, if it is open. * * @throws IOException failed to close the file */ private void innerClose() throws IOException { if (mState == ZipFileState.CLOSED) { return; } Verify.verifyNotNull(mRaf, "mRaf == null"); mRaf.close(); mRaf = null; mState = ZipFileState.CLOSED; if (mClosedControl == null) { mClosedControl = new CachedFileContents<>(mFile); } mClosedControl.closed(null); } /** * If the zip file is closed, opens it in read-only mode. If it is already open, does nothing. * In general, it is not necessary to directly invoke this method. However, if directly * reading the zip file using, for example {@link #directRead(long, byte[])}, then this * method needs to be called. * @throws IOException failed to open the file */ public void openReadOnly() throws IOException { if (mState != ZipFileState.CLOSED) { return; } mState = ZipFileState.OPEN_RO; mRaf = new RandomAccessFile(mFile, "r"); } /** * Opens (or reopens) the zip file as read-write. This method will ensure that * {@link #mRaf} is not null and open for writing. * * @throws IOException failed to open the file, failed to close it or the file was closed and * has been modified outside the control of this object */ private void reopenRw() throws IOException { if (mState == ZipFileState.OPEN_RW) { return; } boolean wasClosed; if (mState == ZipFileState.OPEN_RO) { /* * ReadAccessFile does not have a way to reopen as RW so we have to close it and * open it again. */ innerClose(); wasClosed = false; } else { wasClosed = true; } Verify.verify(mState == ZipFileState.CLOSED, "mState != ZpiFileState.CLOSED"); Verify.verify(mRaf == null, "mRaf != null"); if (mClosedControl != null && !mClosedControl.isValid()) { throw new IOException( "File '" + mFile.getAbsolutePath() + "' has been modified " + "by an external application."); } mRaf = new RandomAccessFile(mFile, "rw"); mState = ZipFileState.OPEN_RW; if (wasClosed) { notify(ZFileExtension::open); } } /** * Equivalent to call {@link #add(String, InputStream, boolean)} using * {@code true} as {@code mayCompress}. * * @param name the file name (<em>i.e.</em>, path); paths should be defined using slashes * and the name should not end in slash * @param stream the source for the file's data * @throws IOException failed to read the source data */ public void add(@NonNull String name, @NonNull InputStream stream) throws IOException { add(name, stream, true); } /** * Creates a stored entry. This does not add the entry to the zip file, it just creates the * {@link StoredEntry} object. * * @param name the name of the entry * @param stream the input stream with the entry's data * @param mayCompress can the entry be compressed? * @return the created entry * @throws IOException failed to create the entry */ @NonNull private StoredEntry makeStoredEntry(@NonNull String name, @NonNull InputStream stream, boolean mayCompress) throws IOException { CloseableByteSource source = mTracker.fromStream(stream); long crc32 = source.hash(Hashing.crc32()).padToLong(); boolean encodeWithUtf8 = !EncodeUtils.canAsciiEncode(name); SettableFuture<CentralDirectoryHeaderCompressInfo> compressInfo = SettableFuture.create(); CentralDirectoryHeader newFileData = new CentralDirectoryHeader(name, source.size(), compressInfo, GPFlags.make(encodeWithUtf8)); newFileData.setCrc32(crc32); /* * Create the new entry and sets its data source. Offset should be set to -1 automatically * because this is a new file. With offset set to -1, StoredEntry does not try to verify the * local header. Since this is a new file, there is no local header and not checking it is * what we want to happen. */ Verify.verify(newFileData.getOffset() == -1); return new StoredEntry(newFileData, this, createSources(mayCompress, source, compressInfo, newFileData)); } /** * Creates the processed and raw sources for an entry. * * @param mayCompress can the entry be compressed? * @param source the entry's data (uncompressed) * @param compressInfo the compression info future that will be set when the raw entry is * created and the {@link CentralDirectoryHeaderCompressInfo} object can be created * @param newFileData the central directory header for the new file * @return the sources whose data may or may not be already defined * @throws IOException failed to create the raw sources */ @NonNull private ProcessedAndRawByteSources createSources(boolean mayCompress, @NonNull CloseableByteSource source, @NonNull SettableFuture<CentralDirectoryHeaderCompressInfo> compressInfo, @NonNull CentralDirectoryHeader newFileData) throws IOException { if (mayCompress) { ListenableFuture<CompressionResult> result = mCompressor.compress(source); Futures.addCallback(result, new FutureCallback<CompressionResult>() { @Override public void onSuccess(CompressionResult result) { compressInfo.set(new CentralDirectoryHeaderCompressInfo(newFileData, result.getCompressionMethod(), result.getSize())); } @Override public void onFailure(@NonNull Throwable t) { compressInfo.setException(t); } }); ListenableFuture<CloseableByteSource> compressedByteSourceFuture = Futures.transform(result, CompressionResult::getSource); LazyDelegateByteSource compressedByteSource = new LazyDelegateByteSource(compressedByteSourceFuture); return new ProcessedAndRawByteSources(source, compressedByteSource); } else { compressInfo.set( new CentralDirectoryHeaderCompressInfo(newFileData, CompressionMethod.STORE, source.size())); return new ProcessedAndRawByteSources(source, source); } } /** * Adds a file to the archive. * * <p>Adding the file will not update the archive immediately. Updating will only happen * when the {@link #update()} method is invoked. * * <p>Adding a file with the same name as an existing file will replace that file in the * archive. * * @param name the file name (<em>i.e.</em>, path); paths should be defined using slashes * and the name should not end in slash * @param stream the source for the file's data * @param mayCompress can the file be compressed? This flag will be ignored if the alignment * rules force the file to be aligned, in which case the file will not be compressed. * @throws IOException failed to read the source data */ public void add(@NonNull String name, @NonNull InputStream stream, boolean mayCompress) throws IOException { /* * Clean pending background work, if needed. */ processAllReadyEntries(); add(makeStoredEntry(name, stream, mayCompress)); } /** * Adds a {@link StoredEntry} to the zip. The entry is not immediately added to * {@link #mEntries} because data may not yet be available. Instead, it is placed under * {@link #mUncompressedEntries} and later moved to {@link #processAllReadyEntries()} when * done. * * <p>This method invokes {@link #processAllReadyEntries()} to move the entry if it has already * been computed so, if there is no delay in compression, and no more files are in waiting * queue, then the entry is added to {@link #mEntries} immediately. * * @param newEntry the entry to add * @throws IOException failed to process this entry (or a previous one whose future only * completed now) */ private void add(@NonNull final StoredEntry newEntry) throws IOException { mUncompressedEntries.add(newEntry); processAllReadyEntries(); } /** * Moves all ready entries from {@link #mUncompressedEntries} to {@link #mEntries}. It will * stop as soon as entry whose future has not been completed is found. * * @throws IOException the exception reported in the future computation, if any, or failed * to add a file to the archive */ private void processAllReadyEntries() throws IOException { /* * Many things can happen during addToEntries(). Because addToEntries() fires * notifications to extensions, other files can be added, removed, etc. Ee are *not* * guaranteed that new stuff does not get into mUncompressedEntries: add() will still work * and will add new entries in there. * * However -- important -- processReadyEntries() may be invoked during addToEntries() * because of the extension mechanism. This means that stuff *can* be removed from * mUncompressedEntries and moved to mEntries during addToEntries(). */ while (!mUncompressedEntries.isEmpty()) { StoredEntry next = mUncompressedEntries.get(0); CentralDirectoryHeader cdh = next.getCentralDirectoryHeader(); Future<CentralDirectoryHeaderCompressInfo> compressionInfo = cdh.getCompressionInfo(); if (!compressionInfo.isDone()) { /* * First entry in queue is not yet complete. We can't do anything else. */ return; } mUncompressedEntries.remove(0); try { compressionInfo.get(); } catch (InterruptedException e) { throw new IOException("Impossible I/O exception: get for already computed " + "future throws InterruptedException", e); } catch (ExecutionException e) { throw new IOException("Failed to obtain compression information for entry", e); } addToEntries(next); } } /** * Waits until {@link #mUncompressedEntries} is empty. * * @throws IOException the exception reported in the future computation, if any, or failed * to add a file to the archive */ private void processAllReadyEntriesWithWait() throws IOException { processAllReadyEntries(); while (!mUncompressedEntries.isEmpty()) { /* * Wait for the first future to complete and then try again. Keep looping until we're * done. */ StoredEntry first = mUncompressedEntries.get(0); CentralDirectoryHeader cdh = first.getCentralDirectoryHeader(); cdh.getCompressionInfoWithWait(); processAllReadyEntries(); } } /** * Adds a new file to {@link #mEntries}. This is actually added to the zip and its space * allocated in the {@link #mMap}. * * @param newEntry the new entry to add * @throws IOException failed to add the file */ private void addToEntries(@NonNull final StoredEntry newEntry) throws IOException { Preconditions.checkArgument(newEntry.getDataDescriptorType() == DataDescriptorType.NO_DATA_DESCRIPTOR, "newEntry has data descriptor"); /* * If there is a file with the same name in the archive, remove it. We remove it by * calling delete() on the entry (this is the public API to remove a file from the archive). * StoredEntry.delete() will call {@link ZFile#delete(StoredEntry, boolean)} to perform * data structure cleanup. */ FileUseMapEntry<StoredEntry> toReplace = mEntries.get(newEntry.getCentralDirectoryHeader().getName()); final StoredEntry replaceStore; if (toReplace != null) { replaceStore = toReplace.getStore(); assert replaceStore != null; replaceStore.delete(false); } else { replaceStore = null; } FileUseMapEntry<StoredEntry> fileUseMapEntry = positionInFile(newEntry, PositionHint.ANYWHERE); mEntries.put(newEntry.getCentralDirectoryHeader().getName(), fileUseMapEntry); mDirty = true; notify(ext -> ext.added(newEntry, replaceStore)); } /** * Finds a location in the zip where this entry will be added to and create the map entry. * This method cannot be called if there is already a map entry for the given entry (if you * do that, then you're doing something wrong somewhere). * * <p>This may delete the central directory and EOCD (if it deletes one, it deletes the other) * if there is no space before the central directory. Otherwise, the file would be added * after the central directory. This would force a new central directory to be written * when updating the file and would create a hole in the zip. Me no like holes. Holes are evil. * * @param entry the entry to place in the zip * @param positionHint hint to where the file should be positioned * @return the position in the file where the entry should be placed */ @NonNull private FileUseMapEntry<StoredEntry> positionInFile(@NonNull StoredEntry entry, @NonNull PositionHint positionHint) throws IOException { deleteDirectoryAndEocd(); long size = entry.getInFileSize(); int localHeaderSize = entry.getLocalHeaderSize(); int alignment = chooseAlignment(entry); FileUseMap.PositionAlgorithm algorithm; switch (positionHint) { case LOWEST_OFFSET: algorithm = FileUseMap.PositionAlgorithm.FIRST_FIT; break; case ANYWHERE: algorithm = FileUseMap.PositionAlgorithm.BEST_FIT; break; default: throw new AssertionError(); } long newOffset = mMap.locateFree(size, localHeaderSize, alignment, algorithm); long newEnd = newOffset + entry.getInFileSize(); if (newEnd > mMap.size()) { mMap.extend(newEnd); } return mMap.add(newOffset, newEnd, entry); } /** * Determines what is the alignment value of an entry. * * @param entry the entry * @return the alignment value, {@link AlignmentRule#NO_ALIGNMENT} if there is no alignment * required for the entry * @throws IOException failed to determine the alignment */ private int chooseAlignment(@NonNull StoredEntry entry) throws IOException { CentralDirectoryHeader cdh = entry.getCentralDirectoryHeader(); CentralDirectoryHeaderCompressInfo compressionInfo = cdh.getCompressionInfoWithWait(); boolean isCompressed = compressionInfo.getMethod() != CompressionMethod.STORE; if (isCompressed) { return AlignmentRule.NO_ALIGNMENT; } else { return mAlignmentRule.alignment(cdh.getName()); } } /** * Adds all files from another zip file, maintaining their compression. Files specified in * <em>src</em> that are already on this file will replace the ones in this file. However, if * their sizes and checksums are equal, they will be ignored. * * <p> This method will not perform any changes in itself, it will only update in-memory data * structures. To actually write the zip file, invoke either {@link #update()} or * {@link #close()}. * * @param src the source archive * @param ignoreFilter predicate that, if {@code true}, identifies files in <em>src</em> that * should be ignored by merging; merging will behave as if these files were not there * @throws IOException failed to read from <em>src</em> or write on the output */ public void mergeFrom(@NonNull ZFile src, @NonNull Predicate<String> ignoreFilter) throws IOException { for (StoredEntry fromEntry : src.entries()) { if (ignoreFilter.test(fromEntry.getCentralDirectoryHeader().getName())) { continue; } boolean replaceCurrent = true; String path = fromEntry.getCentralDirectoryHeader().getName(); FileUseMapEntry<StoredEntry> currentEntry = mEntries.get(path); if (currentEntry != null) { long fromSize = fromEntry.getCentralDirectoryHeader().getUncompressedSize(); long fromCrc = fromEntry.getCentralDirectoryHeader().getCrc32(); StoredEntry currentStore = currentEntry.getStore(); assert currentStore != null; long currentSize = currentStore.getCentralDirectoryHeader().getUncompressedSize(); long currentCrc = currentStore.getCentralDirectoryHeader().getCrc32(); if (fromSize == currentSize && fromCrc == currentCrc) { replaceCurrent = false; } } if (replaceCurrent) { CentralDirectoryHeader fromCdr = fromEntry.getCentralDirectoryHeader(); CentralDirectoryHeaderCompressInfo fromCompressInfo = fromCdr.getCompressionInfoWithWait(); CentralDirectoryHeader newFileData; try { /* * We make two changes in the central directory from the file to merge: * we reset the offset to force the entry to be written and we reset the * deferred CRC bit as we don't need the extra stuff after the file. It takes * space and is totally useless. */ newFileData = fromCdr.clone(); newFileData.setOffset(-1); newFileData.resetDeferredCrc(); } catch (CloneNotSupportedException e) { throw new IOException("Failed to clone CDR.", e); } /* * Read the data (read directly the compressed source if there is one). */ ProcessedAndRawByteSources fromSource = fromEntry.getSource(); InputStream fromInput = fromSource.getRawByteSource().openStream(); long sourceSize = fromSource.getRawByteSource().size(); if (sourceSize > Integer.MAX_VALUE) { throw new IOException("Cannot read source with " + sourceSize + " bytes."); } byte data[] = new byte[Ints.checkedCast(sourceSize)]; int read = 0; while (read < data.length) { int r = fromInput.read(data, read, data.length - read); Verify.verify(r >= 0, "There should be at least 'size' bytes in the stream."); read += r; } /* * Build the new source and wrap it around an inflater source if data came from * a compressed source. */ CloseableByteSource rawContents = mTracker.fromSource(fromSource.getRawByteSource()); CloseableByteSource processedContents; if (fromCompressInfo.getMethod() == CompressionMethod.DEFLATE) { //noinspection IOResourceOpenedButNotSafelyClosed processedContents = new InflaterByteSource(rawContents); } else { processedContents = rawContents; } ProcessedAndRawByteSources newSource = new ProcessedAndRawByteSources(processedContents, rawContents); /* * Add will replace any current entry with the same name. */ StoredEntry newEntry = new StoredEntry(newFileData, this, newSource); add(newEntry); } } } /** * Forcibly marks this zip file as touched, forcing it to be updated when {@link #update()} * or {@link #close()} are invoked. */ public void touch() { mDirty = true; } /** * Wait for any background tasks to finish and report any errors. In general this method does * not need to be invoked directly as errors from background tasks are reported during * {@link #add(String, InputStream, boolean)}, {@link #update()} and {@link #close()}. * However, if required for some purposes, <em>e.g.</em>, ensuring all notifications have been * done to extensions, then this method may be called. It will wait for all background tasks * to complete. * @throws IOException some background work failed */ public void finishAllBackgroundTasks() throws IOException { processAllReadyEntriesWithWait(); } /** * Realigns all entries in the zip. This is equivalent to call {@link StoredEntry#realign()} * for all entries in the zip file. * * @return has any entry been changed? Note that for entries that have not yet been written on * the file, realignment does not count as a change as nothing needs to be updated in the file; * entries that have been updated may have been recreated and the existing references outside * of {@code ZFile} may refer to {@link StoredEntry}s that are no longer valid * @throws IOException failed to realign the zip; some entries in the zip may have been lost * due to the I/O error */ public boolean realign() throws IOException { boolean anyChanges = false; for (StoredEntry entry : entries()) { anyChanges |= entry.realign(); } return anyChanges; } /** * Realigns a stored entry, if necessary. Realignment is done by removing and re-adding the file * if it was not aligned. * * @param entry the entry to realign * @return has the entry been changed? Note that if the entry has not yet been written on the * file, realignment does not count as a change as nothing needs to be updated in the file * @throws IOException failed to read/write an entry; the entry may no longer exist in the * file */ boolean realign(@NonNull StoredEntry entry) throws IOException { FileUseMapEntry<StoredEntry> mapEntry = mEntries.get(entry.getCentralDirectoryHeader().getName()); Verify.verify(entry == mapEntry.getStore()); long currentDataOffset = mapEntry.getStart() + entry.getLocalHeaderSize(); int expectedAlignment = chooseAlignment(entry); long misalignment = currentDataOffset % expectedAlignment; if (misalignment == 0) { /* * Good. File is aligned properly. */ return false; } if (entry.getCentralDirectoryHeader().getOffset() == -1) { /* * File is not aligned but it is not written. We do not really need to do much other * than find another place in the map. */ mMap.remove(mapEntry); long newStart = mMap.locateFree(mapEntry.getSize(), entry.getLocalHeaderSize(), expectedAlignment, FileUseMap.PositionAlgorithm.BEST_FIT); mapEntry = mMap.add(newStart, newStart + entry.getInFileSize(), entry); mEntries.put(entry.getCentralDirectoryHeader().getName(), mapEntry); /* * Just for safety. We're modifying the in-memory structures but the file should * already be marked as dirty. */ Verify.verify(mDirty); return false; } /* * Get the entry data source, but check if we have a compressed one (we don't want to * inflate and deflate). */ CentralDirectoryHeaderCompressInfo compressInfo = entry.getCentralDirectoryHeader() .getCompressionInfoWithWait(); ProcessedAndRawByteSources source = entry.getSource(); CentralDirectoryHeader clonedCdh; try { clonedCdh = entry.getCentralDirectoryHeader().clone(); } catch (CloneNotSupportedException e) { Verify.verify(false); return false; } /* * We make two changes in the central directory when realigning: * we reset the offset to force the entry to be written and we reset the * deferred CRC bit as we don't need the extra stuff after the file. It takes * space and is totally useless and we may need the extra space to realign the entry... */ clonedCdh.setOffset(-1); clonedCdh.resetDeferredCrc(); CloseableByteSource rawContents = mTracker.fromSource(source.getRawByteSource()); CloseableByteSource processedContents; if (compressInfo.getMethod() == CompressionMethod.DEFLATE) { //noinspection IOResourceOpenedButNotSafelyClosed processedContents = new InflaterByteSource(rawContents); } else { processedContents = rawContents; } ProcessedAndRawByteSources newSource = new ProcessedAndRawByteSources(processedContents, rawContents); /* * Add the new file. This will replace the existing one. */ StoredEntry newEntry = new StoredEntry(clonedCdh, this, newSource); add(newEntry); return true; } /** * Adds an extension to this zip file. * * @param extension the listener to add */ public void addZFileExtension(@NonNull ZFileExtension extension) { mExtensions.add(extension); } /** * Removes an extension from this zip file. * * @param extension the listener to remove */ public void removeZFileExtension(@NonNull ZFileExtension extension) { mExtensions.remove(extension); } /** * Notifies all extensions, collecting their execution requests and running them. * * @param function the function to apply to all listeners, it will generally invoke the * notification method on the listener and return the result of that invocation * @throws IOException failed to process some extensions */ private void notify(@NonNull IOExceptionFunction<ZFileExtension, IOExceptionRunnable> function) throws IOException { for (ZFileExtension fl : Lists.newArrayList(mExtensions)) { IOExceptionRunnable r = function.apply(fl); if (r != null) { mToRun.add(r); } } if (!mIsNotifying) { mIsNotifying = true; try { while (!mToRun.isEmpty()) { IOExceptionRunnable r = mToRun.remove(0); r.run(); } } finally { mIsNotifying = false; } } } /** * Directly writes data in the zip file. <strong>Incorrect use of this method may corrupt the * zip file</strong>. Invoking this method may force the zip to be reopened in read/write * mode. * * @param offset the offset at which data should be written * @param data the data to write, may be an empty array * @param start start offset in {@code data} where data to write is located * @param count number of bytes of data to write * @throws IOException failed to write the data */ public void directWrite(long offset, @NonNull byte[] data, int start, int count) throws IOException { Preconditions.checkArgument(offset >= 0, "offset < 0"); Preconditions.checkArgument(start >= 0, "start >= 0"); Preconditions.checkArgument(count >= 0, "count >= 0"); if (data.length == 0) { return; } Preconditions.checkArgument(start <= data.length, "start > data.length"); Preconditions.checkArgument(start + count <= data.length, "start + count > data.length"); reopenRw(); assert mRaf != null; mRaf.seek(offset); mRaf.write(data, start, count); } /** * Same as {@code directWrite(offset, data, 0, data.length)}. * * @param offset the offset at which data should be written * @param data the data to write, may be an empty array * @throws IOException failed to write the data */ public void directWrite(long offset, @NonNull byte[] data) throws IOException { directWrite(offset, data, 0, data.length); } /** * Directly reads data from the zip file. Invoking this method may force the zip to be reopened * in read/write mode. * * @param offset the offset at which data should be written * @param data the array where read data should be stored * @param start start position in the array where to write data to * @param count how many bytes of data can be written * @return how many bytes of data have been written or {@code -1} if there are no more bytes * to be read * @throws IOException failed to write the data */ public int directRead(long offset, @NonNull byte[] data, int start, int count) throws IOException { Preconditions.checkArgument(offset >= 0, "offset < 0"); Preconditions.checkArgument(start >= 0, "start >= 0"); Preconditions.checkArgument(count >= 0, "count >= 0"); if (data.length == 0) { return 0; } Preconditions.checkArgument(start <= data.length, "start > data.length"); Preconditions.checkArgument(start + count <= data.length, "start + count > data.length"); /* * Only force a reopen if the file is closed. */ if (mRaf == null) { reopenRw(); assert mRaf != null; } mRaf.seek(offset); return mRaf.read(data, start, count); } /** * Same as {@code directRead(offset, data, 0, data.length)}. * * @param offset the offset at which data should be read * @param data receives the read data, may be an empty array * @throws IOException failed to read the data */ public int directRead(long offset, @NonNull byte[] data) throws IOException { return directRead(offset, data, 0, data.length); } /** * Reads exactly @code data.length} bytes of data, failing if it was not possible to read all * the requested data. * * @param offset the offset at which to start reading * @param data the array that receives the data read * @throws IOException failed to read some data or there is not enough data to read */ public void directFullyRead(long offset, @NonNull byte[] data) throws IOException { Preconditions.checkArgument(offset >= 0, "offset < 0"); Preconditions.checkNotNull(mRaf, "File is closed"); mRaf.seek(offset); RandomAccessFileUtils.fullyRead(mRaf, data); } /** * Adds all files and directories recursively. * <p> * Equivalent to calling {@link #addAllRecursively(File, Function)} using a function that * always returns {@code true} * * @param file a file or directory; if it is a directory, all files and directories will be * added recursively * @throws IOException failed to some (or all ) of the files */ public void addAllRecursively(@NonNull File file) throws IOException { addAllRecursively(file, f -> true); } /** * Adds all files and directories recursively. * * @param file a file or directory; if it is a directory, all files and directories will be * added recursively * @param mayCompress a function that decides whether files may be compressed * @throws IOException failed to some (or all ) of the files */ public void addAllRecursively(@NonNull File file, @NonNull Function<? super File, Boolean> mayCompress) throws IOException { /* * The case of file.isFile() is different because if file.isFile() we will add it to the * zip in the root. However, if file.isDirectory() we won't add it and add its children. */ if (file.isFile()) { boolean mayCompressFile = Verify.verifyNotNull(mayCompress.apply(file), "mayCompress.apply() returned null"); try (Closer closer = Closer.create()) { FileInputStream fileInput = closer.register(new FileInputStream(file)); add(file.getName(), fileInput, mayCompressFile); } return; } for (File f : Files.fileTreeTraverser().preOrderTraversal(file).skip(1)) { String path = FileUtils.relativePath(f, file); path = FileUtils.toSystemIndependentPath(path); InputStream stream; try (Closer closer = Closer.create()) { boolean mayCompressFile; if (f.isDirectory()) { stream = closer.register(new ByteArrayInputStream(new byte[0])); mayCompressFile = false; } else { stream = closer.register(new FileInputStream(f)); mayCompressFile = Verify.verifyNotNull(mayCompress.apply(f), "mayCompress.apply() returned null"); } add(path, stream, mayCompressFile); } } } /** * Obtains the offset at which the central directory exists, or at which it will be written * if the zip file were to be flushed immediately. * * @return the offset, in bytes, where the central directory is or will be written; this value * includes any extra offset for the central directory */ public long getCentralDirectoryOffset() { if (mDirectoryEntry != null) { return mDirectoryEntry.getStart(); } /* * If there are no entries, the central directory is written at the start of the file. */ if (mEntries.isEmpty()) { return mExtraDirectoryOffset; } /* * The Central Directory is written after all entries. This will be at the end of the file * if the */ return mMap.usedSize() + mExtraDirectoryOffset; } /** * Obtains the size of the central directory, if the central directory is written in the zip * file. * * @return the size of the central directory or {@code -1} if the central directory has not * been computed */ public long getCentralDirectorySize() { if (mDirectoryEntry != null) { return mDirectoryEntry.getSize(); } if (mEntries.isEmpty()) { return 0; } return 1; } /** * Obtains the offset of the EOCD record, if the EOCD has been written to the file. * * @return the offset of the EOCD or {@code -1} if none exists yet */ public long getEocdOffset() { if (mEocdEntry == null) { return -1; } return mEocdEntry.getStart(); } /** * Obtains the size of the EOCD record, if the EOCD has been written to the file. * * @return the size of the EOCD of {@code -1} it none exists yet */ public long getEocdSize() { if (mEocdEntry == null) { return -1; } return mEocdEntry.getSize(); } /** * Sets an extra offset for the central directory. See class description for details. Changing * this value will mark the file as dirty and force a rewrite of the central directory when * updated. * * @param offset the offset or {@code 0} to write the central directory at its current location */ public void setExtraDirectoryOffset(long offset) { Preconditions.checkArgument(offset >= 0, "offset < 0"); if (mExtraDirectoryOffset != offset) { mExtraDirectoryOffset = offset; deleteDirectoryAndEocd(); mDirty = true; } } /** * Obtains the extra offset for the central directory. See class description for details. * * @return the offset or {@code 0} if no offset is set */ public long getExtraDirectoryOffset() { return mExtraDirectoryOffset; } /** * Obtains whether this {@code ZFile} is ignoring timestamps. * * @return are the timestamps being ignored? */ public boolean areTimestampsIgnored() { return mNoTimestamps; } /** * Sorts all files in the zip. This will force all files to be loaded and will wait for all * background tasks to complete. Sorting files is never done implicitly and will operate in * memory only (maybe reading files from the zip disk into memory, if needed). It will leave * the zip in dirty state, requiring a call to {@link #update()} to force the entries to be * written to disk. * * @throws IOException failed to load or move a file in the zip */ public void sortZipContents() throws IOException { reopenRw(); processAllReadyEntriesWithWait(); Verify.verify(mUncompressedEntries.isEmpty()); SortedSet<StoredEntry> sortedEntries = Sets.newTreeSet(StoredEntry.COMPARE_BY_NAME); for (FileUseMapEntry<StoredEntry> fmEntry : mEntries.values()) { StoredEntry entry = fmEntry.getStore(); Preconditions.checkNotNull(entry); sortedEntries.add(entry); entry.loadSourceIntoMemory(); mMap.remove(fmEntry); } mEntries.clear(); for (StoredEntry entry : sortedEntries) { String name = entry.getCentralDirectoryHeader().getName(); FileUseMapEntry<StoredEntry> positioned = positionInFile(entry, PositionHint.LOWEST_OFFSET); mEntries.put(name, positioned); } mDirty = true; } /** * Obtains the filesystem path to the zip file. * * @return the file that may or may not exist (depending on whether something existed there * before the zip was created and on whether the zip has been updated or not) */ @NonNull public File getFile() { return mFile; } /** * Creates the extra field block to fill in {@code blockSize} bytes. * * @param blockSize the block size to fill as an extra field * @param alignment the alignment that is being used for the file * @return the extra field block * @throws IOException failed to write the extra block */ @NonNull private static byte[] makeExtraAlignmentBlock(int blockSize, int alignment) throws IOException { Preconditions.checkArgument(blockSize >= MINIMUM_EXTRA_FIELD_SIZE, "blockSize (" + blockSize + ") < MINIMUM_EXTRA_FIELD_SIZE"); byte[] data = new byte[blockSize]; ByteBuffer buffer = ByteBuffer.wrap(data); LittleEndianUtils.writeUnsigned2Le(buffer, ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID); LittleEndianUtils.writeUnsigned2Le(buffer, blockSize - 4); LittleEndianUtils.writeUnsigned2Le(buffer, alignment); /* * The rest is left filled with zeroes. */ return data; } /** * Hint to where files should be positioned. */ enum PositionHint { /** * File may be positioned anywhere, caller doesn't care. */ ANYWHERE, /** * File should be positioned at the lowest offset possible. */ LOWEST_OFFSET } }