| /* |
| * Copyright (C) 2016 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.tools.build.apkzlib.zip; |
| |
| import com.android.tools.build.apkzlib.utils.CachedFileContents; |
| import com.android.tools.build.apkzlib.utils.IOExceptionFunction; |
| import com.android.tools.build.apkzlib.utils.IOExceptionRunnable; |
| import com.android.tools.build.apkzlib.zip.compress.Zip64NotSupportedException; |
| import com.android.tools.build.apkzlib.zip.utils.ByteTracker; |
| import com.android.tools.build.apkzlib.zip.utils.CloseableByteSource; |
| import com.android.tools.build.apkzlib.zip.utils.LittleEndianUtils; |
| import com.google.common.base.Preconditions; |
| import com.google.common.base.Verify; |
| import com.google.common.base.VerifyException; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Iterables; |
| 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.MoreExecutors; |
| import com.google.common.util.concurrent.SettableFuture; |
| import java.io.ByteArrayInputStream; |
| import java.io.Closeable; |
| import java.io.EOFException; |
| 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.nio.channels.FileChannel; |
| import java.util.ArrayList; |
| 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; |
| import java.util.function.Supplier; |
| import javax.annotation.Nonnull; |
| import javax.annotation.Nullable; |
| |
| /** |
| * 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; |
| |
| /** |
| * Maximum size for the EOCD. |
| */ |
| private static final int MAX_EOCD_COMMENT_SIZE = 65535; |
| |
| /** |
| * 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 = MIN_EOCD_SIZE + MAX_EOCD_COMMENT_SIZE; |
| |
| /** |
| * Signature of the Zip64 EOCD locator record. |
| */ |
| private static final int ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50; |
| |
| /** |
| * Signature of the EOCD record. |
| */ |
| private static final byte[] EOCD_SIGNATURE = new byte[] { 0x06, 0x05, 0x4b, 0x50 }; |
| |
| /** |
| * 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 when we have to add one. We rely on the alignment segment |
| * to do that so the minimum size for the extra field is the minimum size of an alignment |
| * segment. |
| */ |
| private static final int MINIMUM_EXTRA_FIELD_SIZE = ExtraField.AlignmentSegment.MINIMUM_SIZE; |
| |
| /** |
| * 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 file; |
| |
| /** |
| * The random access file used to access the zip file. This will be {@code null} if and only |
| * if {@link #state} is {@link ZipFileState#CLOSED}. |
| */ |
| @Nullable |
| private RandomAccessFile raf; |
| |
| /** |
| * 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 map; |
| |
| /** |
| * 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). |
| * |
| * <p>If the EOCD is deleted because the zip has been changed and the old EOCD was no longer |
| * valid, then {@link #eocdComment} will contain the comment saved from the EOCD. |
| */ |
| @Nullable |
| private FileUseMapEntry<Eocd> eocdEntry; |
| |
| /** |
| * 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> directoryEntry; |
| |
| /** |
| * 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>> entries; |
| |
| /** |
| * Entries added to the zip file, but that are not yet compressed. When compression is done, |
| * these entries are eventually moved to {@link #entries}. uncompressedEntries 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 #entries} when the data is |
| * available. |
| * |
| * <p>Moving files out of this list to {@link #entries} is done by |
| * {@link #processAllReadyEntries()}. |
| */ |
| @Nonnull |
| private final List<StoredEntry> uncompressedEntries; |
| |
| /** |
| * Current state of the zip file. |
| */ |
| @Nonnull |
| private ZipFileState state; |
| |
| /** |
| * Are the in-memory changes that have not been written to the zip file? |
| * |
| * <p>This might be false, but will become true after {@link #processAllReadyEntriesWithWait()} |
| * is called if there are {@link #uncompressedEntries} compressing in the background. |
| */ |
| private boolean dirty; |
| |
| /** |
| * 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> closedControl; |
| |
| /** |
| * The alignment rule. |
| */ |
| @Nonnull |
| private final AlignmentRule alignmentRule; |
| |
| /** |
| * Extensions registered with the file. |
| */ |
| @Nonnull |
| private final List<ZFileExtension> extensions; |
| |
| /** |
| * 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 #isNotifying}, it is used to avoid reordering notifications. |
| */ |
| @Nonnull |
| private final List<IOExceptionRunnable> toRun; |
| |
| /** |
| * {@code true} when {@link #notify(com.android.tools.build.apkzlib.utils.IOExceptionFunction)} |
| * is notifying extensions. Used to avoid reordering notifications. |
| */ |
| private boolean isNotifying; |
| |
| /** |
| * An extra offset for the central directory location. {@code 0} if the central directory |
| * should be written in its standard location. |
| */ |
| private long extraDirectoryOffset; |
| |
| /** |
| * Should all timestamps be zeroed when reading / writing the zip? |
| */ |
| private boolean noTimestamps; |
| |
| /** |
| * Compressor to use. |
| */ |
| @Nonnull |
| private Compressor compressor; |
| |
| /** |
| * Byte tracker to use. |
| */ |
| @Nonnull |
| private final ByteTracker tracker; |
| |
| /** |
| * Use the zip entry's "extra field" field to cover empty space in the zip file? |
| */ |
| private boolean coverEmptySpaceUsingExtraField; |
| |
| /** |
| * Should files be automatically sorted when updating? |
| */ |
| private boolean autoSortFiles; |
| |
| /** |
| * Verify log factory to use. |
| */ |
| @Nonnull |
| private final Supplier<VerifyLog> verifyLogFactory; |
| |
| /** |
| * Verify log to use. |
| */ |
| @Nonnull |
| private final VerifyLog verifyLog; |
| |
| /** |
| * This field contains the comment in the zip's EOCD if there is no in-memory EOCD structure. |
| * This may happen, for example, if the zip has been changed and the Central Directory and |
| * EOCD have been deleted (in-memory). In that case, this field will save the comment to place |
| * on the EOCD once it is created. |
| * |
| * <p>This field will only be non-{@code null} if there is no in-memory EOCD structure |
| * (<i>i.e.</i>, {@link #eocdEntry} is {@code null}). If there is an {@link #eocdEntry}, then |
| * the comment will be there instead of being in this field. |
| */ |
| @Nullable |
| private byte[] eocdComment; |
| |
| /** |
| * Is the file in read-only mode? In read-only mode no changes are allowed. |
| */ |
| private boolean readOnly; |
| |
| |
| /** |
| * 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 { |
| this(file, options, false); |
| } |
| |
| /** |
| * 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 |
| * @param readOnly should the file be open in read-only mode? If {@code true} then the file must |
| * exist and no methods can be invoked that could potentially change the file |
| * @throws IOException some file exists but could not be read |
| */ |
| public ZFile(@Nonnull File file, @Nonnull ZFileOptions options, boolean readOnly) |
| throws IOException { |
| this.file = file; |
| map = new FileUseMap( |
| 0, |
| options.getCoverEmptySpaceUsingExtraField() |
| ? MINIMUM_EXTRA_FIELD_SIZE |
| : 0); |
| this.readOnly = readOnly; |
| dirty = false; |
| closedControl = null; |
| alignmentRule = options.getAlignmentRule(); |
| extensions = Lists.newArrayList(); |
| toRun = Lists.newArrayList(); |
| noTimestamps = options.getNoTimestamps(); |
| tracker = options.getTracker(); |
| compressor = options.getCompressor(); |
| coverEmptySpaceUsingExtraField = options.getCoverEmptySpaceUsingExtraField(); |
| autoSortFiles = options.getAutoSortFiles(); |
| verifyLogFactory = options.getVerifyLogFactory(); |
| verifyLog = verifyLogFactory.get(); |
| |
| /* |
| * These two values will be overwritten by openReadOnly() below if the file exists. |
| */ |
| state = ZipFileState.CLOSED; |
| raf = null; |
| |
| if (file.exists()) { |
| openReadOnly(); |
| } else if (readOnly) { |
| throw new IOException("File does not exist but read-only mode requested"); |
| } else { |
| dirty = true; |
| } |
| |
| entries = Maps.newHashMap(); |
| uncompressedEntries = Lists.newArrayList(); |
| extraDirectoryOffset = 0; |
| |
| try { |
| if (state != ZipFileState.CLOSED) { |
| long rafSize = raf.length(); |
| if (rafSize > Integer.MAX_VALUE) { |
| throw new IOException("File exceeds size limit of " + Integer.MAX_VALUE + "."); |
| } |
| |
| map.extend(Ints.checkedCast(rafSize)); |
| readData(); |
| } |
| |
| // If we don't have an EOCD entry, set the comment to empty. |
| if (eocdEntry == null) { |
| eocdComment = new byte[0]; |
| } |
| |
| // Notify the extensions if the zip file has been open. |
| if (state != ZipFileState.CLOSED) { |
| notify(ZFileExtension::open); |
| } |
| } catch (Zip64NotSupportedException e) { |
| throw e; |
| } catch (IOException e) { |
| throw new IOException("Failed to read zip file '" + file.getAbsolutePath() + "'.", e); |
| } catch (IllegalStateException | IllegalArgumentException | VerifyException e) { |
| throw new RuntimeException( |
| "Internal error when trying 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 : this.entries.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 : uncompressedEntries) { |
| 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 entries. |
| */ |
| for (StoredEntry stillUncompressed : Lists.reverse(uncompressedEntries)) { |
| if (stillUncompressed.getCentralDirectoryHeader().getName().equals(path)) { |
| return stillUncompressed; |
| } |
| } |
| |
| FileUseMapEntry<StoredEntry> found = entries.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(state != ZipFileState.CLOSED, "state == ZipFileState.CLOSED"); |
| Preconditions.checkState(raf != null, "raf == null"); |
| |
| readEocd(); |
| readCentralDirectory(); |
| |
| /* |
| * Go over all files and create the usage map, verifying there is no overlap in the files. |
| */ |
| long entryEndOffset; |
| long directoryStartOffset; |
| |
| if (directoryEntry != null) { |
| CentralDirectory directory = directoryEntry.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. |
| */ |
| |
| Verify.verify(start >= 0, "start < 0"); |
| Verify.verify(end < map.size(), "end >= map.size()"); |
| |
| FileUseMapEntry<?> found = map.at(start); |
| Verify.verifyNotNull(found); |
| |
| // We've got a problem if the found entry is not free or is a free entry but |
| // doesn't cover the whole file. |
| if (!found.isFree() || found.getEnd() < end) { |
| if (found.isFree()) { |
| found = map.after(found); |
| Verify.verify(found != null && !found.isFree()); |
| } |
| |
| Object foundEntry = found.getStore(); |
| Verify.verify(foundEntry != null); |
| |
| // Obtains a custom description of an entry. |
| IOExceptionFunction<StoredEntry, String> describe = |
| e -> |
| String.format( |
| "'%s' (offset: %d, size: %d)", |
| e.getCentralDirectoryHeader().getName(), |
| e.getCentralDirectoryHeader().getOffset(), |
| e.getInFileSize()); |
| |
| String overlappingEntryDescription; |
| if (foundEntry instanceof StoredEntry) { |
| StoredEntry foundStored = (StoredEntry) foundEntry; |
| overlappingEntryDescription = describe.apply((StoredEntry) foundEntry); |
| } else { |
| overlappingEntryDescription = |
| "Central Directory / EOCD: " |
| + found.getStart() |
| + " - " |
| + found.getEnd(); |
| } |
| |
| throw new IOException( |
| "Cannot read entry " |
| + describe.apply(entry) |
| + " because it overlaps with " |
| + overlappingEntryDescription); |
| } |
| |
| FileUseMapEntry<StoredEntry> mapEntry = map.add(start, end, entry); |
| entries.put(entry.getCentralDirectoryHeader().getName(), mapEntry); |
| |
| if (end > entryEndOffset) { |
| entryEndOffset = end; |
| } |
| } |
| |
| directoryStartOffset = directoryEntry.getStart(); |
| } else { |
| /* |
| * No directory means an empty zip file. Use the start of the EOCD to compute |
| * an existing offset. |
| */ |
| Verify.verifyNotNull(eocdEntry); |
| assert eocdEntry != null; |
| directoryStartOffset = eocdEntry.getStart(); |
| entryEndOffset = 0; |
| } |
| |
| /* |
| * Check if there is an extra central directory offset. If there is, save it. Note that |
| * we can't call extraDirectoryOffset() because that would mark the file as dirty. |
| */ |
| long extraOffset = directoryStartOffset - entryEndOffset; |
| Verify.verify(extraOffset >= 0, "extraOffset (%s) < 0", extraOffset); |
| extraDirectoryOffset = extraOffset; |
| } |
| |
| /** |
| * Finds the EOCD marker and reads it. It will populate the {@link #eocdEntry} variable. |
| * |
| * @throws IOException failed to read the EOCD |
| */ |
| private void readEocd() throws IOException { |
| Preconditions.checkState(state != ZipFileState.CLOSED, "state == ZipFileState.CLOSED"); |
| Preconditions.checkState(raf != null, "raf == 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 > raf.length()) { |
| lastToRead = Ints.checkedCast(raf.length()); |
| } |
| |
| byte[] last = new byte[lastToRead]; |
| directFullyRead(raf.length() - lastToRead, last); |
| |
| |
| /* |
| * 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] == EOCD_SIGNATURE[3] |
| && last[endIdx + 1] == EOCD_SIGNATURE[2] |
| && last[endIdx + 2] == EOCD_SIGNATURE[1] |
| && last[endIdx + 3] == EOCD_SIGNATURE[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(raf.length() - lastToRead + foundEocdSignature); |
| |
| /* |
| * Make sure the EOCD takes the whole file up to the end. Log an error if it |
| * doesn't. |
| */ |
| if (eocdStart + eocd.getEocdSize() != raf.length()) { |
| verifyLog.log("EOCD starts at " |
| + eocdStart |
| + " and has " |
| + eocd.getEocdSize() |
| + " bytes, but file ends at " |
| + raf.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 Zip64NotSupportedException( |
| "Zip64 EOCD locator found but Zip64 format is not supported."); |
| } |
| } |
| |
| eocdEntry = map.add(eocdStart, eocdStart + eocd.getEocdSize(), eocd); |
| } |
| |
| /** |
| * Reads the zip's central directory and populates the {@link #directoryEntry} 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 #directoryEntry} will be set to |
| * {@code null}. |
| * |
| * @throws IOException failed to read the central directory |
| */ |
| private void readCentralDirectory() throws IOException { |
| Preconditions.checkNotNull(eocdEntry, "eocdEntry == null"); |
| Preconditions.checkNotNull(eocdEntry.getStore(), "eocdEntry.getStore() == null"); |
| Preconditions.checkState(state != ZipFileState.CLOSED, "state == ZipFileState.CLOSED"); |
| Preconditions.checkState(raf != null, "raf == null"); |
| Preconditions.checkState(directoryEntry == null, "directoryEntry != null"); |
| |
| Eocd eocd = eocdEntry.getStore(); |
| |
| long dirSize = eocd.getDirectorySize(); |
| if (dirSize > Integer.MAX_VALUE) { |
| throw new IOException("Cannot read central directory with size " + dirSize + "."); |
| } |
| |
| long centralDirectoryEnd = eocd.getDirectoryOffset() + dirSize; |
| if (centralDirectoryEnd != eocdEntry.getStart()) { |
| String msg = "Central directory is stored in [" |
| + eocd.getDirectoryOffset() |
| + " - " |
| + (centralDirectoryEnd - 1) |
| + "] and EOCD starts at " |
| + eocdEntry.getStart() |
| + "."; |
| |
| /* |
| * If there is an empty space between the central directory and the EOCD, we proceed |
| * logging an error. If the central directory ends after the start of the EOCD (and |
| * therefore, they overlap), throw an exception. |
| */ |
| if (centralDirectoryEnd > eocdEntry.getSize()) { |
| throw new IOException(msg); |
| } else { |
| verifyLog.log(msg); |
| } |
| } |
| |
| 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) { |
| directoryEntry = map.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(state != ZipFileState.CLOSED, "state == ZipFileState.CLOSED"); |
| Preconditions.checkState(raf != null, "raf == null"); |
| Preconditions.checkArgument(start >= 0, "start < 0"); |
| Preconditions.checkArgument(end >= start, "end < start"); |
| Preconditions.checkArgument(end <= raf.length(), "end > raf.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 |
| * @throws IllegalStateException if open in read-only mode |
| */ |
| void delete(@Nonnull final StoredEntry entry, boolean notify) throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| String path = entry.getCentralDirectoryHeader().getName(); |
| FileUseMapEntry<StoredEntry> mapEntry = entries.get(path); |
| Preconditions.checkNotNull(mapEntry, "mapEntry == null"); |
| Preconditions.checkArgument(entry == mapEntry.getStore(), "entry != mapEntry.getStore()"); |
| |
| dirty = true; |
| |
| map.remove(mapEntry); |
| entries.remove(path); |
| |
| if (notify) { |
| notify(ext -> ext.removed(entry)); |
| } |
| } |
| |
| /** |
| * Checks that the file is not in read-only mode. |
| * |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| private void checkNotInReadOnlyMode() { |
| if (readOnly) { |
| throw new IllegalStateException("Illegal operation in read only model"); |
| } |
| } |
| |
| /** |
| * 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 { |
| checkNotInReadOnlyMode(); |
| |
| /* |
| * 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 (!dirty) { |
| 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 (autoSortFiles) { |
| 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(); |
| map.truncate(); |
| |
| /* |
| * If we need to use the extra field to cover empty spaces, we do the processing here. |
| */ |
| if (coverEmptySpaceUsingExtraField) { |
| |
| /* 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<>(entries.values())) { |
| StoredEntry storedEntry = entry.getStore(); |
| assert storedEntry != null; |
| |
| FileUseMapEntry<?> before = map.before(entry); |
| if (before == null || !before.isFree()) { |
| continue; |
| } |
| |
| /* |
| * We have free space before the current entry. However, we do know that it can |
| * be covered by the extra field, because both sortZipContents() and |
| * packIfNecessary() guarantee it. |
| */ |
| int localExtraSize = |
| storedEntry.getLocalExtra().size() + Ints.checkedCast(before.getSize()); |
| 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(); |
| |
| /* |
| * Remove the entry. |
| */ |
| String name = storedEntry.getCentralDirectoryHeader().getName(); |
| map.remove(entry); |
| Verify.verify(entry == entries.remove(name)); |
| |
| /* |
| * Make a list will all existing segments in the entry's extra field, but remove |
| * the alignment field, if it exists. Also, sum the size of all kept extra field |
| * segments. |
| */ |
| ImmutableList<ExtraField.Segment> currentSegments; |
| try { |
| currentSegments = storedEntry.getLocalExtra().getSegments(); |
| } catch (IOException e) { |
| /* |
| * Parsing current segments has failed. This means the contents of the extra |
| * field are not valid. We'll continue discarding the existing segments. |
| */ |
| currentSegments = ImmutableList.of(); |
| } |
| |
| List<ExtraField.Segment> extraFieldSegments = new ArrayList<>(); |
| int newExtraFieldSize = currentSegments.stream() |
| .filter(s -> s.getHeaderId() |
| != ExtraField.ALIGNMENT_ZIP_EXTRA_DATA_FIELD_HEADER_ID) |
| .peek(extraFieldSegments::add) |
| .map(ExtraField.Segment::size) |
| .reduce(0, Integer::sum); |
| |
| int spaceToFill = |
| Ints.checkedCast( |
| before.getSize() |
| + storedEntry.getLocalExtra().size() |
| - newExtraFieldSize); |
| |
| extraFieldSegments.add( |
| new ExtraField.AlignmentSegment(chooseAlignment(storedEntry),spaceToFill)); |
| |
| storedEntry.setLocalExtraNoNotify( |
| new ExtraField(ImmutableList.copyOf(extraFieldSegments))); |
| entries.put(name, map.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 : entries.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 : map.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 = (directoryEntry != 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 && directoryEntry == null); |
| |
| appendCentralDirectory(); |
| appendEocd(); |
| |
| Verify.verifyNotNull(raf); |
| raf.setLength(map.size()); |
| |
| dirty = 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 #coverEmptySpaceUsingExtraField} |
| * is set to {@code true}. |
| * |
| * <p>Essentially, this makes sure we can cover any empty space with the extra field, given |
| * that the local extra field is limited to {@link #MAX_LOCAL_EXTRA_FIELD_CONTENTS_SIZE}. If |
| * an entry is too far from the previous one, it is removed and re-added. |
| * |
| * @throws IOException failed to repack |
| */ |
| private void packIfNecessary() throws IOException { |
| if (!coverEmptySpaceUsingExtraField) { |
| return; |
| } |
| |
| SortedSet<FileUseMapEntry<StoredEntry>> entriesByLocation = |
| new TreeSet<>(FileUseMapEntry.COMPARE_BY_START); |
| entriesByLocation.addAll(entries.values()); |
| |
| for (FileUseMapEntry<StoredEntry> entry : entriesByLocation) { |
| StoredEntry storedEntry = entry.getStore(); |
| assert storedEntry != null; |
| |
| FileUseMapEntry<?> before = map.before(entry); |
| if (before == null || !before.isFree()) { |
| continue; |
| } |
| |
| int localExtraSize = |
| storedEntry.getLocalExtra().size() + 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 = entries.get(name); |
| Preconditions.checkNotNull(mapEntry); |
| Preconditions.checkState(mapEntry.getStore() == entry); |
| |
| entry.loadSourceIntoMemory(); |
| |
| map.remove(mapEntry); |
| entries.remove(name); |
| FileUseMapEntry<StoredEntry> positioned = positionInFile(entry, positionHint); |
| entries.put(name, positioned); |
| dirty = 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 { |
| dirty = true; |
| |
| if (resized) { |
| reAdd(entry, PositionHint.ANYWHERE); |
| } |
| } |
| |
| /** |
| * Invoked when the central directory has changed and needs to be rewritten. |
| */ |
| void centralDirectoryChanged() { |
| dirty = true; |
| deleteDirectoryAndEocd(); |
| } |
| |
| /** |
| * Updates the file and closes it. |
| */ |
| @Override |
| public void close() throws IOException { |
| // We need to make sure to release raf, otherwise we end up locking the file on |
| // Windows. Use try-with-resources to handle exception suppressing. |
| try (Closeable ignored = this::innerClose) { |
| if (!readOnly) { |
| 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. |
| * |
| * <p>This method does not mark the zip as dirty. |
| */ |
| private void deleteDirectoryAndEocd() { |
| if (directoryEntry != null) { |
| map.remove(directoryEntry); |
| directoryEntry = null; |
| } |
| |
| if (eocdEntry != null) { |
| map.remove(eocdEntry); |
| |
| Eocd eocd = eocdEntry.getStore(); |
| Verify.verify(eocd != null); |
| eocdComment = eocd.getComment(); |
| eocdEntry = 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(raf, "raf == null"); |
| Preconditions.checkState(state == ZipFileState.OPEN_RW, "state != 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 #directoryEntry}, |
| * unless the directory is empty in which case {@link #directoryEntry} |
| * 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(state == ZipFileState.OPEN_RW, "state != ZipFileState.OPEN_RW"); |
| Preconditions.checkNotNull(raf, "raf == null"); |
| Preconditions.checkState(directoryEntry == null, "directoryEntry == null"); |
| |
| Set<StoredEntry> newStored = Sets.newHashSet(); |
| for (FileUseMapEntry<StoredEntry> mapEntry : entries.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. |
| */ |
| map.truncate(); |
| |
| CentralDirectory newDirectory = CentralDirectory.makeFromEntries(newStored, this); |
| byte[] newDirectoryBytes = newDirectory.toBytes(); |
| long directoryOffset = map.size() + extraDirectoryOffset; |
| |
| map.extend(directoryOffset + newDirectoryBytes.length); |
| |
| if (newDirectoryBytes.length > 0) { |
| directoryEntry = map.add(directoryOffset, directoryOffset + newDirectoryBytes.length, |
| newDirectory); |
| } |
| } |
| |
| /** |
| * Writes the central directory to the end of the zip file. {@link #directoryEntry} 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(state == ZipFileState.OPEN_RW, "state != ZipFileState.OPEN_RW"); |
| Preconditions.checkNotNull(raf, "raf == null"); |
| |
| if (entries.isEmpty()) { |
| Preconditions.checkState(directoryEntry == null, "directoryEntry != null"); |
| return; |
| } |
| |
| Preconditions.checkNotNull(directoryEntry, "directoryEntry != null"); |
| |
| CentralDirectory newDirectory = directoryEntry.getStore(); |
| Preconditions.checkNotNull(newDirectory, "newDirectory != null"); |
| |
| byte[] newDirectoryBytes = newDirectory.toBytes(); |
| long directoryOffset = directoryEntry.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 extraDirectoryOffset 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 (entries.isEmpty()) { |
| Preconditions.checkState(directoryEntry == null, "directoryEntry != null"); |
| return new byte[0]; |
| } |
| |
| Preconditions.checkNotNull(directoryEntry, "directoryEntry == null"); |
| |
| CentralDirectory cd = directoryEntry.getStore(); |
| Preconditions.checkNotNull(cd, "cd == null"); |
| return cd.toBytes(); |
| } |
| |
| /** |
| * Computes the EOCD. This creates a new {@link #eocdEntry}. The |
| * central directory must already be written. If {@link #directoryEntry} 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(state == ZipFileState.OPEN_RW, "state != ZipFileState.OPEN_RW"); |
| Preconditions.checkNotNull(raf, "raf == null"); |
| if (directoryEntry == null) { |
| Preconditions.checkState(entries.isEmpty(), |
| "directoryEntry == null && !entries.isEmpty()"); |
| } |
| |
| long dirStart; |
| long dirSize = 0; |
| |
| if (directoryEntry != null) { |
| CentralDirectory directory = directoryEntry.getStore(); |
| assert directory != null; |
| |
| dirStart = directoryEntry.getStart(); |
| dirSize = directoryEntry.getSize(); |
| Verify.verify(directory.getEntries().size() == entries.size()); |
| } else { |
| /* |
| * If we do not have a directory, then we must leave any requested offset empty. |
| */ |
| dirStart = extraDirectoryOffset; |
| } |
| |
| Verify.verify(eocdComment != null); |
| Eocd eocd = new Eocd(entries.size(), dirStart, dirSize, eocdComment); |
| eocdComment = null; |
| |
| byte[] eocdBytes = eocd.toBytes(); |
| long eocdOffset = map.size(); |
| |
| map.extend(eocdOffset + eocdBytes.length); |
| |
| eocdEntry = map.add(eocdOffset, eocdOffset + eocdBytes.length, eocd); |
| } |
| |
| /** |
| * Writes the EOCD to the end of the zip file. This creates a new {@link #eocdEntry}. The |
| * central directory must already be written. If {@link #directoryEntry} 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(state == ZipFileState.OPEN_RW, "state != ZipFileState.OPEN_RW"); |
| Preconditions.checkNotNull(raf, "raf == null"); |
| Preconditions.checkNotNull(eocdEntry, "eocdEntry == null"); |
| |
| Eocd eocd = eocdEntry.getStore(); |
| Preconditions.checkNotNull(eocd, "eocd == null"); |
| |
| byte[] eocdBytes = eocd.toBytes(); |
| long eocdOffset = eocdEntry.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(eocdEntry, "eocdEntry == null"); |
| |
| Eocd eocd = eocdEntry.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 (state == ZipFileState.CLOSED) { |
| return; |
| } |
| |
| Verify.verifyNotNull(raf, "raf == null"); |
| |
| raf.close(); |
| raf = null; |
| state = ZipFileState.CLOSED; |
| if (closedControl == null) { |
| closedControl = new CachedFileContents<>(file); |
| } |
| |
| closedControl.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 (state != ZipFileState.CLOSED) { |
| return; |
| } |
| |
| state = ZipFileState.OPEN_RO; |
| raf = new RandomAccessFile(file, "r"); |
| } |
| |
| /** |
| * Opens (or reopens) the zip file as read-write. This method will ensure that |
| * {@link #raf} 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 { |
| // We an never open a file RW in read-only mode. We should never get this far, though. |
| Verify.verify(!readOnly); |
| |
| if (state == ZipFileState.OPEN_RW) { |
| return; |
| } |
| |
| boolean wasClosed; |
| if (state == 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(state == ZipFileState.CLOSED, "state != ZpiFileState.CLOSED"); |
| Verify.verify(raf == null, "raf != null"); |
| |
| if (closedControl != null && !closedControl.isValid()) { |
| throw new IOException("File '" + file.getAbsolutePath() + "' has been modified " |
| + "by an external application."); |
| } |
| |
| raf = new RandomAccessFile(file, "rw"); |
| state = ZipFileState.OPEN_RW; |
| |
| /* |
| * Now that we've open the zip and are ready to write, clear out any data descriptors |
| * in the zip since we don't need them and they take space in the archive. |
| */ |
| for (StoredEntry entry : entries()) { |
| dirty |= entry.removeDataDescriptor(); |
| } |
| |
| 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void add(@Nonnull String name, @Nonnull InputStream stream) throws IOException { |
| checkNotInReadOnlyMode(); |
| 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 = tracker.fromStream(stream); |
| long crc32 = source.hash(Hashing.crc32()).padToLong(); |
| |
| boolean encodeWithUtf8 = !EncodeUtils.canAsciiEncode(name); |
| |
| SettableFuture<CentralDirectoryHeaderCompressInfo> compressInfo = |
| SettableFuture.create(); |
| GPFlags flags = GPFlags.make(encodeWithUtf8); |
| CentralDirectoryHeader newFileData = |
| new CentralDirectoryHeader( |
| name, |
| EncodeUtils.encode(name, flags), |
| source.size(), |
| compressInfo, |
| flags, |
| this); |
| 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 = compressor.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); |
| } |
| }, |
| MoreExecutors.directExecutor()); |
| |
| ListenableFuture<CloseableByteSource> compressedByteSourceFuture = |
| Futures.transform( |
| result, CompressionResult::getSource, MoreExecutors.directExecutor()); |
| 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void add(@Nonnull String name, @Nonnull InputStream stream, boolean mayCompress) |
| throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| /* |
| * 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 #entries} because data may not yet be available. Instead, it is placed under |
| * {@link #uncompressedEntries} 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 #entries} 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 { |
| uncompressedEntries.add(newEntry); |
| processAllReadyEntries(); |
| } |
| |
| /** |
| * Moves all ready entries from {@link #uncompressedEntries} to {@link #entries}. 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 uncompressedEntries: 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 |
| * uncompressedEntries and moved to entries during addToEntries(). |
| */ |
| while (!uncompressedEntries.isEmpty()) { |
| StoredEntry next = uncompressedEntries.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; |
| } |
| |
| uncompressedEntries.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 #uncompressedEntries} 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 (!uncompressedEntries.isEmpty()) { |
| /* |
| * Wait for the first future to complete and then try again. Keep looping until we're |
| * done. |
| */ |
| StoredEntry first = uncompressedEntries.get(0); |
| CentralDirectoryHeader cdh = first.getCentralDirectoryHeader(); |
| cdh.getCompressionInfoWithWait(); |
| |
| processAllReadyEntries(); |
| } |
| } |
| |
| /** |
| * Adds a new file to {@link #entries}. This is actually added to the zip and its space |
| * allocated in the {@link #map}. |
| * |
| * @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 = entries.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); |
| entries.put(newEntry.getCentralDirectoryHeader().getName(), fileUseMapEntry); |
| |
| dirty = 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 = map.locateFree(size, localHeaderSize, alignment, algorithm); |
| long newEnd = newOffset + entry.getInFileSize(); |
| if (newEnd > map.size()) { |
| map.extend(newEnd); |
| } |
| |
| return map.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 alignmentRule.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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void mergeFrom(@Nonnull ZFile src, @Nonnull Predicate<String> ignoreFilter) |
| throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| for (StoredEntry fromEntry : src.entries()) { |
| if (ignoreFilter.test(fromEntry.getCentralDirectoryHeader().getName())) { |
| continue; |
| } |
| |
| boolean replaceCurrent = true; |
| String path = fromEntry.getCentralDirectoryHeader().getName(); |
| FileUseMapEntry<StoredEntry> currentEntry = entries.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 = tracker.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. |
| * |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void touch() { |
| checkNotInReadOnlyMode(); |
| dirty = 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public boolean realign() throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| boolean anyChanges = false; |
| for (StoredEntry entry : entries()) { |
| anyChanges |= entry.realign(); |
| } |
| |
| if (anyChanges) { |
| dirty = true; |
| } |
| |
| 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 = |
| entries.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. |
| */ |
| map.remove(mapEntry); |
| long newStart = |
| map.locateFree( |
| mapEntry.getSize(), |
| entry.getLocalHeaderSize(), |
| expectedAlignment, |
| FileUseMap.PositionAlgorithm.BEST_FIT); |
| mapEntry = map.add(newStart, newStart + entry.getInFileSize(), entry); |
| entries.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(dirty); |
| |
| 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 = tracker.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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void addZFileExtension(@Nonnull ZFileExtension extension) { |
| checkNotInReadOnlyMode(); |
| extensions.add(extension); |
| } |
| |
| /** |
| * Removes an extension from this zip file. |
| * |
| * @param extension the listener to remove |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void removeZFileExtension(@Nonnull ZFileExtension extension) { |
| checkNotInReadOnlyMode(); |
| extensions.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(extensions)) { |
| IOExceptionRunnable r = function.apply(fl); |
| if (r != null) { |
| toRun.add(r); |
| } |
| } |
| |
| if (!isNotifying) { |
| isNotifying = true; |
| |
| try { |
| while (!toRun.isEmpty()) { |
| IOExceptionRunnable r = toRun.remove(0); |
| r.run(); |
| } |
| } finally { |
| isNotifying = 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void directWrite(long offset, @Nonnull byte[] data, int start, int count) |
| throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| 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 raf != null; |
| |
| raf.seek(offset); |
| raf.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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void directWrite(long offset, @Nonnull byte[] data) throws IOException { |
| checkNotInReadOnlyMode(); |
| directWrite(offset, data, 0, data.length); |
| } |
| |
| /** |
| * Returns the current size (in bytes) of the underlying file. |
| * |
| * @throws IOException if an I/O error occurs |
| */ |
| public long directSize() throws IOException { |
| /* |
| * Only force a reopen if the file is closed. |
| */ |
| if (raf == null) { |
| reopenRw(); |
| assert raf != null; |
| } |
| return raf.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(start >= 0, "start >= 0"); |
| Preconditions.checkArgument(count >= 0, "count >= 0"); |
| Preconditions.checkArgument(start <= data.length, "start > data.length"); |
| Preconditions.checkArgument(start + count <= data.length, "start + count > data.length"); |
| return directRead(offset, ByteBuffer.wrap(data, start, count)); |
| } |
| |
| /** |
| * 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 from which data should be read |
| * @param dest the output buffer to fill with data from the {@code offset}. |
| * @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 ByteBuffer dest) throws IOException { |
| Preconditions.checkArgument(offset >= 0, "offset < 0"); |
| |
| if (!dest.hasRemaining()) { |
| return 0; |
| } |
| |
| /* |
| * Only force a reopen if the file is closed. |
| */ |
| if (raf == null) { |
| reopenRw(); |
| assert raf != null; |
| } |
| |
| raf.seek(offset); |
| return raf.getChannel().read(dest); |
| } |
| |
| /** |
| * 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 { |
| directFullyRead(offset, ByteBuffer.wrap(data)); |
| } |
| |
| /** |
| * Reads exactly {@code dest.remaining()} 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 dest the output buffer to fill with data |
| * @throws IOException failed to read some data or there is not enough data to read |
| */ |
| public void directFullyRead(long offset, @Nonnull ByteBuffer dest) throws IOException { |
| Preconditions.checkArgument(offset >= 0, "offset < 0"); |
| |
| if (!dest.hasRemaining()) { |
| return; |
| } |
| |
| /* |
| * Only force a reopen if the file is closed. |
| */ |
| if (raf == null) { |
| reopenRw(); |
| assert raf != null; |
| } |
| |
| FileChannel fileChannel = raf.getChannel(); |
| while (dest.hasRemaining()) { |
| fileChannel.position(offset); |
| int chunkSize = fileChannel.read(dest); |
| if (chunkSize == -1) { |
| throw new EOFException( |
| "Failed to read " + dest.remaining() + " more bytes: premature EOF"); |
| } |
| offset += chunkSize; |
| } |
| } |
| |
| /** |
| * 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void addAllRecursively(@Nonnull File file) throws IOException { |
| checkNotInReadOnlyMode(); |
| 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 |
| * @throws IllegalStateException if the file is in read-only mode |
| */ |
| public void addAllRecursively( |
| @Nonnull File file, |
| @Nonnull Function<? super File, Boolean> mayCompress) throws IOException { |
| checkNotInReadOnlyMode(); |
| |
| /* |
| * 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 : Iterables.skip(Files.fileTraverser().depthFirstPreOrder(file), 1)) { |
| String path = file.toURI().relativize(f.toURI()).getPath(); |
| |
| 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 (directoryEntry != null) { |
| return directoryEntry.getStart(); |
| } |
| |
| /* |
| * If there are no entries, the central directory is written at the start of the file. |
| */ |
| if (entries.isEmpty()) { |
| return extraDirectoryOffset; |
| } |
| |
| /* |
| * The Central Directory is written after all entries. This will be at the end of the file |
| * if the |
| */ |
| return map.usedSize() + extraDirectoryOffset; |
| } |
| |
| /** |
| * 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 (directoryEntry != null) { |
| return directoryEntry.getSize(); |
| } |
| |
| if (entries.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 (eocdEntry == null) { |
| return -1; |
| } |
| |
| return eocdEntry.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 (eocdEntry == null) { |
| return -1; |
| } |
| |
| return eocdEntry.getSize(); |
| } |
| |
| /** |
| * Obtains the comment in the EOCD. |
| * |
| * @return the comment exactly as it was encoded in the EOCD, no encoding conversion is done |
| */ |
| @Nonnull |
| public byte[] getEocdComment() { |
| if (eocdEntry == null) { |
| Verify.verify(eocdComment != null); |
| byte[] eocdCommentCopy = new byte[eocdComment.length]; |
| System.arraycopy(eocdComment, 0, eocdCommentCopy, 0, eocdComment.length); |
| return eocdCommentCopy; |
| } |
| |
| Eocd eocd = eocdEntry.getStore(); |
| Verify.verify(eocd != null); |
| return eocd.getComment(); |
| } |
| |
| /** |
| * Sets the comment in the EOCD. |
| * |
| * @param comment the new comment; no conversion is done, these exact bytes will be placed in |
| * the EOCD comment |
| * @throws IllegalStateException if file is in read-only mode |
| */ |
| public void setEocdComment(@Nonnull byte[] comment) { |
| checkNotInReadOnlyMode(); |
| |
| if (comment.length > MAX_EOCD_COMMENT_SIZE) { |
| throw new IllegalArgumentException( |
| "EOCD comment size (" |
| + comment.length |
| + ") is larger than the maximum allowed (" |
| + MAX_EOCD_COMMENT_SIZE |
| + ")"); |
| } |
| |
| // Check if the EOCD signature appears anywhere in the comment we need to check if it |
| // is valid. |
| for (int i = 0; i < comment.length - MIN_EOCD_SIZE; i++) { |
| // Remember: little endian... |
| if (comment[i] == EOCD_SIGNATURE[3] |
| && comment[i + 1] == EOCD_SIGNATURE[2] |
| && comment[i + 2] == EOCD_SIGNATURE[1] |
| && comment[i + 3] == EOCD_SIGNATURE[0]) { |
| // We found a possible EOCD signature at position i. Try to read it. |
| ByteBuffer bytes = ByteBuffer.wrap(comment, i, comment.length - i); |
| try { |
| new Eocd(bytes); |
| throw new IllegalArgumentException( |
| "Position " |
| + i |
| + " of the comment contains a valid EOCD record."); |
| } catch (IOException e) { |
| // Fine, this is an invalid record. Move along... |
| } |
| } |
| } |
| |
| deleteDirectoryAndEocd(); |
| eocdComment = new byte[comment.length]; |
| System.arraycopy(comment, 0, eocdComment, 0, comment.length); |
| dirty = true; |
| } |
| |
| /** |
| * 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 |
| * @throws IllegalStateException if file is in read-only mode |
| */ |
| public void setExtraDirectoryOffset(long offset) { |
| checkNotInReadOnlyMode(); |
| Preconditions.checkArgument(offset >= 0, "offset < 0"); |
| |
| if (extraDirectoryOffset != offset) { |
| extraDirectoryOffset = offset; |
| deleteDirectoryAndEocd(); |
| dirty = 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 extraDirectoryOffset; |
| } |
| |
| /** |
| * Obtains whether this {@code ZFile} is ignoring timestamps. |
| * |
| * @return are the timestamps being ignored? |
| */ |
| public boolean areTimestampsIgnored() { |
| return noTimestamps; |
| } |
| |
| /** |
| * 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 |
| * @throws IllegalStateException if file is in read-only mode |
| */ |
| public void sortZipContents() throws IOException { |
| checkNotInReadOnlyMode(); |
| reopenRw(); |
| |
| processAllReadyEntriesWithWait(); |
| |
| Verify.verify(uncompressedEntries.isEmpty()); |
| |
| SortedSet<StoredEntry> sortedEntries = Sets.newTreeSet(StoredEntry.COMPARE_BY_NAME); |
| for (FileUseMapEntry<StoredEntry> fmEntry : entries.values()) { |
| StoredEntry entry = fmEntry.getStore(); |
| Preconditions.checkNotNull(entry); |
| sortedEntries.add(entry); |
| entry.loadSourceIntoMemory(); |
| |
| map.remove(fmEntry); |
| } |
| |
| entries.clear(); |
| for (StoredEntry entry : sortedEntries) { |
| String name = entry.getCentralDirectoryHeader().getName(); |
| FileUseMapEntry<StoredEntry> positioned = |
| positionInFile(entry, PositionHint.LOWEST_OFFSET); |
| |
| entries.put(name, positioned); |
| } |
| |
| dirty = 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 file; |
| } |
| |
| /** |
| * Creates a new verify log. |
| * |
| * @return the new verify log |
| */ |
| @Nonnull |
| VerifyLog makeVerifyLog() { |
| VerifyLog log = verifyLogFactory.get(); |
| assert log != null; |
| return log; |
| } |
| |
| /** |
| * Obtains the zip file's verify log. |
| * |
| * @return the verify log |
| */ |
| @Nonnull |
| VerifyLog getVerifyLog() { |
| return verifyLog; |
| } |
| |
| /** |
| * Are there in-memory changes that have not been written to the zip file? |
| * |
| * <p>Waits for all pending processing which may make changes. |
| */ |
| public boolean hasPendingChangesWithWait() throws IOException { |
| processAllReadyEntriesWithWait(); |
| return dirty; |
| } |
| |
| /** 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 |
| } |
| } |