blob: cca5f4098416f8bba45e664ab05c06f182ddb3d1 [file] [log] [blame]
/*
* 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
}
}