diff options
Diffstat (limited to 'src/main/java/com/android/apkzlib')
13 files changed, 473 insertions, 79 deletions
diff --git a/src/main/java/com/android/apkzlib/sign/SignatureAlgorithm.java b/src/main/java/com/android/apkzlib/sign/SignatureAlgorithm.java index 4166767..0667252 100644 --- a/src/main/java/com/android/apkzlib/sign/SignatureAlgorithm.java +++ b/src/main/java/com/android/apkzlib/sign/SignatureAlgorithm.java @@ -23,26 +23,17 @@ import javax.annotation.Nonnull; * Signature algorithm. */ public enum SignatureAlgorithm { - /** - * RSA algorithm. - */ - RSA("RSA", 0, "withRSA"), + /** RSA algorithm. */ + RSA("RSA", 1, "withRSA"), - /** - * ECDSA algorithm. - */ + /** ECDSA algorithm. */ ECDSA("EC", 18, "withECDSA"), - /** - * DSA algorithm. - */ - DSA("DSA", 0, "withDSA"); + /** DSA algorithm. */ + DSA("DSA", 1, "withDSA"); - /** - * Name of the private key as reported by {@code PrivateKey}. - */ - @Nonnull - public final String keyAlgorithm; + /** Name of the private key as reported by {@code PrivateKey}. */ + @Nonnull public final String keyAlgorithm; /** * Minimum SDK version that allows this signature. diff --git a/src/main/java/com/android/apkzlib/zfile/ApkCreator.java b/src/main/java/com/android/apkzlib/zfile/ApkCreator.java index d602202..3cac7dc 100644 --- a/src/main/java/com/android/apkzlib/zfile/ApkCreator.java +++ b/src/main/java/com/android/apkzlib/zfile/ApkCreator.java @@ -65,4 +65,7 @@ public interface ApkCreator extends Closeable { * @throws IOException failed to remove the entry */ void deleteFile(@Nonnull String apkPath) throws IOException; + + /** Returns true if the APK will be rewritten on close. */ + boolean hasPendingChangesWithWait() throws IOException; } diff --git a/src/main/java/com/android/apkzlib/zfile/ApkZFileCreator.java b/src/main/java/com/android/apkzlib/zfile/ApkZFileCreator.java index b96901c..c85ad44 100644 --- a/src/main/java/com/android/apkzlib/zfile/ApkZFileCreator.java +++ b/src/main/java/com/android/apkzlib/zfile/ApkZFileCreator.java @@ -26,6 +26,7 @@ import com.google.common.io.Closer; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.function.Function; import java.util.function.Predicate; import javax.annotation.Nonnull; @@ -114,14 +115,30 @@ class ApkZFileCreator implements ApkCreator { try { ZFile toMerge = closer.register(new ZFile(zip)); - Predicate<String> predicate; + Predicate<String> ignorePredicate; if (isIgnored == null) { - predicate = s -> false; + ignorePredicate = s -> false; } else { - predicate = isIgnored; + ignorePredicate = isIgnored; } - this.zip.mergeFrom(toMerge, predicate); + // Files that *must* be uncompressed in the result should not be merged and should be + // added after. This is just very slightly less efficient than ignoring just the ones + // that were compressed and must be uncompressed, but it is a lot simpler :) + Predicate<String> noMergePredicate = ignorePredicate.or(noCompressPredicate); + + this.zip.mergeFrom(toMerge, noMergePredicate); + + for (StoredEntry toMergeEntry : toMerge.entries()) { + String path = toMergeEntry.getCentralDirectoryHeader().getName(); + if (noCompressPredicate.test(path) && !ignorePredicate.test(path)) { + // This entry *must* be uncompressed so it was ignored in the merge and should + // now be added to the apk. + try (InputStream ignoredData = toMergeEntry.open()) { + this.zip.add(path, ignoredData, false); + } + } + } } catch (Throwable t) { throw closer.rethrow(t); } finally { @@ -159,6 +176,11 @@ class ApkZFileCreator implements ApkCreator { } @Override + public boolean hasPendingChangesWithWait() throws IOException { + return zip.hasPendingChangesWithWait(); + } + + @Override public void close() throws IOException { if (closed) { return; diff --git a/src/main/java/com/android/apkzlib/zip/CentralDirectory.java b/src/main/java/com/android/apkzlib/zip/CentralDirectory.java index 4305d80..44389c1 100644 --- a/src/main/java/com/android/apkzlib/zip/CentralDirectory.java +++ b/src/main/java/com/android/apkzlib/zip/CentralDirectory.java @@ -341,7 +341,9 @@ class CentralDirectory { + "."); } - String fileName = EncodeUtils.decode(bytes, fileNameLength, flags); + byte[] encodedFileName = new byte[fileNameLength]; + bytes.get(encodedFileName); + String fileName = EncodeUtils.decode(encodedFileName, flags); byte[] extraField = new byte[extraFieldLength]; bytes.get(extraField); @@ -362,11 +364,7 @@ class CentralDirectory { versionNeededToExtract)); CentralDirectoryHeader centralDirectoryHeader = new CentralDirectoryHeader( - fileName, - uncompressedSize, - compressInfo, - flags, - file); + fileName, encodedFileName, uncompressedSize, compressInfo, flags, file); centralDirectoryHeader.setMadeBy(madeBy); centralDirectoryHeader.setLastModTime(lastModTime); centralDirectoryHeader.setLastModDate(lastModDate); diff --git a/src/main/java/com/android/apkzlib/zip/CentralDirectoryHeader.java b/src/main/java/com/android/apkzlib/zip/CentralDirectoryHeader.java index 7e7e50b..f10477f 100644 --- a/src/main/java/com/android/apkzlib/zip/CentralDirectoryHeader.java +++ b/src/main/java/com/android/apkzlib/zip/CentralDirectoryHeader.java @@ -128,6 +128,7 @@ public class CentralDirectoryHeader implements Cloneable { * Creates data for a file. * * @param name the file name + * @param encodedFileName the encoded file name, this array will be owned by the header * @param uncompressedSize the uncompressed file size * @param compressInfo computation that defines the compression information * @param flags flags used in the entry @@ -135,6 +136,7 @@ public class CentralDirectoryHeader implements Cloneable { */ CentralDirectoryHeader( @Nonnull String name, + @Nonnull byte[] encodedFileName, long uncompressedSize, @Nonnull Future<CentralDirectoryHeaderCompressInfo> compressInfo, @Nonnull GPFlags flags, @@ -156,7 +158,7 @@ public class CentralDirectoryHeader implements Cloneable { internalAttributes = 0; externalAttributes = 0; offset = -1; - encodedFileName = EncodeUtils.encode(name, gpBit); + this.encodedFileName = encodedFileName; this.compressInfo = compressInfo; file = zFile; } diff --git a/src/main/java/com/android/apkzlib/zip/EncodeUtils.java b/src/main/java/com/android/apkzlib/zip/EncodeUtils.java index 6579d1c..259f64e 100644 --- a/src/main/java/com/android/apkzlib/zip/EncodeUtils.java +++ b/src/main/java/com/android/apkzlib/zip/EncodeUtils.java @@ -19,7 +19,9 @@ package com.android.apkzlib.zip; import com.google.common.base.Charsets; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; +import java.nio.charset.CodingErrorAction; import javax.annotation.Nonnull; /** @@ -52,10 +54,9 @@ public class EncodeUtils { + "length is " + length + "."); } - Charset charset = flagsCharset(flags); byte[] stringBytes = new byte[length]; bytes.get(stringBytes); - return charset.decode(ByteBuffer.wrap(stringBytes)).toString(); + return decode(stringBytes, flags); } /** @@ -67,8 +68,32 @@ public class EncodeUtils { */ @Nonnull public static String decode(@Nonnull byte[] data, @Nonnull GPFlags flags) { - Charset charset = flagsCharset(flags); - return charset.decode(ByteBuffer.wrap(data)).toString(); + return decode(data, flagsCharset(flags)); + } + + /** + * Decodes a file name. + * + * @param data the raw data + * @param charset the charset to use + * @return the decode file name + */ + @Nonnull + private static String decode(@Nonnull byte[] data, @Nonnull Charset charset) { + try { + return charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(data)) + .toString(); + } catch (CharacterCodingException e) { + // If we're trying to decode ASCII, try UTF-8. Otherwise, revert to the default + // behavior (usually replacing invalid characters). + if (charset.equals(Charsets.US_ASCII)) { + return decode(data, Charsets.UTF_8); + } else { + return charset.decode(ByteBuffer.wrap(data)).toString(); + } + } } /** diff --git a/src/main/java/com/android/apkzlib/zip/Eocd.java b/src/main/java/com/android/apkzlib/zip/Eocd.java index 36a0a6e..1568840 100644 --- a/src/main/java/com/android/apkzlib/zip/Eocd.java +++ b/src/main/java/com/android/apkzlib/zip/Eocd.java @@ -167,8 +167,9 @@ class Eocd { * @param directoryOffset offset, since beginning of archive, where the Central Directory is * located * @param directorySize number of bytes of the Central Directory + * @param comment the EOCD comment */ - Eocd(int totalRecords, long directoryOffset, long directorySize) { + Eocd(int totalRecords, long directoryOffset, long directorySize, @Nonnull byte[] comment) { Preconditions.checkArgument(totalRecords >= 0, "totalRecords < 0"); Preconditions.checkArgument(directoryOffset >= 0, "directoryOffset < 0"); Preconditions.checkArgument(directorySize >= 0, "directorySize < 0"); @@ -176,8 +177,8 @@ class Eocd { this.totalRecords = totalRecords; this.directoryOffset = directoryOffset; this.directorySize = directorySize; - comment = new byte[0]; - byteSupplier = new CachedSupplier<byte[]>(this::computeByteRepresentation); + this.comment = comment; + byteSupplier = new CachedSupplier<>(this::computeByteRepresentation); } /** @@ -214,7 +215,7 @@ class Eocd { * @return the size, in bytes, of the EOCD */ long getEocdSize() { - return F_COMMENT_SIZE.endOffset() + comment.length; + return (long) F_COMMENT_SIZE.endOffset() + comment.length; } /** @@ -228,6 +229,19 @@ class Eocd { return byteSupplier.get(); } + /* + * Obtains the comment in the EOCD. + * + * @return the comment exactly as it is represented in the file (no encoding conversion is + * done) + */ + @Nonnull + byte[] getComment() { + byte[] commentCopy = new byte[comment.length]; + System.arraycopy(comment, 0, commentCopy, 0, comment.length); + return commentCopy; + } + /** * Computes the byte representation of the EOCD. * diff --git a/src/main/java/com/android/apkzlib/zip/ExtraField.java b/src/main/java/com/android/apkzlib/zip/ExtraField.java index 4e11519..d70fa7f 100644 --- a/src/main/java/com/android/apkzlib/zip/ExtraField.java +++ b/src/main/java/com/android/apkzlib/zip/ExtraField.java @@ -158,6 +158,16 @@ public class ExtraField { } byte[] data = new byte[dataSize]; + if (buffer.remaining() < dataSize) { + throw new IOException( + "Invalid data size for extra field segment with header ID " + + headerId + + ": " + + dataSize + + " (only " + + buffer.remaining() + + " bytes are available)"); + } buffer.get(data); SegmentFactory factory = identifySegmentFactory(headerId); @@ -324,6 +334,11 @@ public class ExtraField { public static class AlignmentSegment implements Segment { /** + * Minimum size for an alignment segment. + */ + public static final int MINIMUM_SIZE = 6; + + /** * The alignment value. */ private int alignment; @@ -341,14 +356,14 @@ public class ExtraField { */ public AlignmentSegment(int alignment, int totalSize) { Preconditions.checkArgument(alignment > 0, "alignment <= 0"); - Preconditions.checkArgument(totalSize >= 6, "totalSize < 6"); + Preconditions.checkArgument(totalSize >= MINIMUM_SIZE, "totalSize < MINIMUM_SIZE"); /* * We have 6 bytes of fixed data: header ID (2 bytes), data size (2 bytes), alignment * value (2 bytes). */ this.alignment = alignment; - padding = totalSize - 6; + padding = totalSize - MINIMUM_SIZE; } /** diff --git a/src/main/java/com/android/apkzlib/zip/FileUseMap.java b/src/main/java/com/android/apkzlib/zip/FileUseMap.java index a72a956..8a76878 100644 --- a/src/main/java/com/android/apkzlib/zip/FileUseMap.java +++ b/src/main/java/com/android/apkzlib/zip/FileUseMap.java @@ -538,6 +538,43 @@ class FileUseMap { return map.lower(entry); } + /** + * Obtains the entry that is located after the one provided. + * + * @param entry the map entry to get the next one for; must belong to the map + * @return the entry after the provided one, {@code null} if {@code entry} is the last entry in + * the map + */ + @Nullable + FileUseMapEntry<?> after(@Nonnull FileUseMapEntry<?> entry) { + Preconditions.checkNotNull(entry, "entry == null"); + + return map.higher(entry); + } + + /** + * Obtains the entry at the given offset. + * + * @param offset the offset to look for + * @return the entry found or {@code null} if there is no entry (not even a free one) at the + * given offset + */ + @Nullable + FileUseMapEntry<?> at(long offset) { + Preconditions.checkArgument(offset >= 0, "offset < 0"); + Preconditions.checkArgument(offset < size, "offset >= size"); + + FileUseMapEntry<?> entry = map.floor(FileUseMapEntry.makeFree(offset, offset + 1)); + if (entry == null) { + return null; + } + + Verify.verify(entry.getStart() <= offset); + Verify.verify(entry.getEnd() > offset); + + return entry; + } + @Override public String toString() { StringJoiner j = new StringJoiner(", "); diff --git a/src/main/java/com/android/apkzlib/zip/StoredEntry.java b/src/main/java/com/android/apkzlib/zip/StoredEntry.java index 664734e..854bf3a 100644 --- a/src/main/java/com/android/apkzlib/zip/StoredEntry.java +++ b/src/main/java/com/android/apkzlib/zip/StoredEntry.java @@ -24,6 +24,7 @@ import com.google.common.base.Verify; import com.google.common.io.ByteSource; import com.google.common.io.ByteStreams; import com.google.common.primitives.Ints; +import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -347,6 +348,23 @@ public class StoredEntry { } /** + * Obtains the contents of the file in an existing buffer. + * + * @param bytes buffer to read the file contents in. + * @return the number of bytes read + * @throws IOException failed to read the file. + */ + public int read(byte[] bytes) throws IOException { + if (bytes.length < getCentralDirectoryHeader().getUncompressedSize()) { + throw new RuntimeException( + "Buffer to small while reading {}" + getCentralDirectoryHeader().getName()); + } + try (InputStream is = new BufferedInputStream(open())) { + return ByteStreams.read(is, bytes, 0, bytes.length); + } + } + + /** * Obtains the type of entry. * * @return the type of entry @@ -362,6 +380,7 @@ public class StoredEntry { * To eventually write updates to disk, {@link ZFile#update()} must be called. * * @throws IOException failed to delete the entry + * @throws IllegalStateException if the zip file was open in read-only mode */ public void delete() throws IOException { delete(true); @@ -374,6 +393,7 @@ public class StoredEntry { * @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 the zip file was open in read-only mode */ void delete(boolean notify) throws IOException { Preconditions.checkState(!deleted, "deleted"); @@ -481,7 +501,7 @@ public class StoredEntry { long ddStart = cdh.getOffset() + FIXED_LOCAL_FILE_HEADER_SIZE + cdh.getName().length() + localExtra.size() + compressInfo.getCompressedSize(); - byte ddData[] = new byte[DataDescriptorType.DATA_DESCRIPTOR_WITH_SIGNATURE.size]; + byte[] ddData = new byte[DataDescriptorType.DATA_DESCRIPTOR_WITH_SIGNATURE.size]; file.directFullyRead(ddStart, ddData); ByteBuffer ddBytes = ByteBuffer.wrap(ddData); diff --git a/src/main/java/com/android/apkzlib/zip/ZFile.java b/src/main/java/com/android/apkzlib/zip/ZFile.java index c92e99b..9034f4c 100644 --- a/src/main/java/com/android/apkzlib/zip/ZFile.java +++ b/src/main/java/com/android/apkzlib/zip/ZFile.java @@ -25,6 +25,7 @@ import com.android.apkzlib.zip.utils.CloseableByteSource; import com.android.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.Lists; import com.google.common.collect.Maps; @@ -37,6 +38,7 @@ 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; @@ -199,9 +201,14 @@ public class ZFile implements Closeable { 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 = 65535 + MIN_EOCD_SIZE; + private static final int LAST_BYTES_TO_READ = MIN_EOCD_SIZE + MAX_EOCD_COMMENT_SIZE; /** * Signature of the Zip64 EOCD locator record. @@ -209,6 +216,11 @@ public class ZFile implements Closeable { 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; @@ -220,9 +232,11 @@ public class ZFile implements Closeable { private static final int MAXIMUM_EXTENSION_CYCLE_COUNT = 10; /** - * Minimum size for the extra field. + * 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 = 6; + private static final int MINIMUM_EXTRA_FIELD_SIZE = ExtraField.AlignmentSegment.MINIMUM_SIZE; /** * Maximum size of the extra field. @@ -255,6 +269,9 @@ public class ZFile implements Closeable { /** * 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; @@ -299,6 +316,9 @@ public class ZFile implements Closeable { /** * 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; @@ -331,8 +351,8 @@ public class ZFile implements Closeable { private final List<IOExceptionRunnable> toRun; /** - * {@code true} when {@link #notify(com.android.apkzlib.utils.IOExceptionFunction)} is notifying extensions. Used - * to avoid reordering notifications. + * {@code true} when {@link #notify(com.android.apkzlib.utils.IOExceptionFunction)} is + * notifying extensions. Used to avoid reordering notifications. */ private boolean isNotifying; @@ -381,6 +401,24 @@ public class ZFile implements Closeable { @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 @@ -406,12 +444,30 @@ public class ZFile implements Closeable { * @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(); @@ -433,6 +489,8 @@ public class ZFile implements Closeable { if (file.exists()) { openReadOnly(); + } else if (readOnly) { + throw new IOException("File does not exist but read-only mode requested"); } else { dirty = true; } @@ -450,13 +508,25 @@ public class ZFile implements Closeable { 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); } } @@ -527,7 +597,7 @@ public class ZFile implements Closeable { readCentralDirectory(); /* - * Compute where the last file ends. We will need this to compute thee extra offset. + * Go over all files and create the usage map, verifying there is no overlap in the files. */ long entryEndOffset; long directoryStartOffset; @@ -555,6 +625,51 @@ public class ZFile implements Closeable { * 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); @@ -575,9 +690,13 @@ public class ZFile implements Closeable { 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); - setExtraDirectoryOffset(extraOffset); + extraDirectoryOffset = extraOffset; } /** @@ -601,7 +720,6 @@ public class ZFile implements Closeable { byte[] last = new byte[lastToRead]; directFullyRead(raf.length() - lastToRead, last); - byte[] eocdSignature = new byte[] { 0x06, 0x05, 0x4b, 0x50 }; /* * Start endIdx at the first possible location where the signature can be located and then @@ -622,10 +740,10 @@ public class ZFile implements Closeable { /* * Remember: little endian... */ - if (last[endIdx] == eocdSignature[3] - && last[endIdx + 1] == eocdSignature[2] - && last[endIdx + 2] == eocdSignature[1] - && last[endIdx + 3] == eocdSignature[0]) { + 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. @@ -677,7 +795,7 @@ public class ZFile implements Closeable { */ int zip64LocatorStart = eocdStart - ZIP64_EOCD_LOCATOR_SIZE; if (zip64LocatorStart >= 0) { - byte possibleZip64Locator[] = new byte[4]; + byte[] possibleZip64Locator = new byte[4]; directFullyRead(zip64LocatorStart, possibleZip64Locator); if (LittleEndianUtils.readUnsigned4Le(ByteBuffer.wrap(possibleZip64Locator)) == ZIP64_EOCD_LOCATOR_SIGNATURE) { @@ -778,7 +896,7 @@ public class ZFile implements Closeable { return -1; } - byte b[] = new byte[1]; + byte[] b = new byte[1]; int r = directRead(mCurr, b); if (r > 0) { mCurr++; @@ -825,8 +943,11 @@ public class ZFile implements Closeable { * @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"); @@ -843,6 +964,17 @@ public class ZFile implements Closeable { } /** + * 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. * @@ -850,6 +982,8 @@ public class ZFile implements Closeable { * the compressor but only reported here */ public void update() throws IOException { + checkNotInReadOnlyMode(); + /* * Process all background stuff before calling in the extensions. */ @@ -1145,7 +1279,9 @@ public class ZFile implements Closeable { // 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) { - update(); + if (!readOnly) { + update(); + } } notify(ext -> { @@ -1157,6 +1293,8 @@ public class ZFile implements Closeable { /** * 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) { @@ -1166,6 +1304,10 @@ public class ZFile implements Closeable { if (eocdEntry != null) { map.remove(eocdEntry); + + Eocd eocd = eocdEntry.getStore(); + Verify.verify(eocd != null); + eocdComment = eocd.getComment(); eocdEntry = null; } } @@ -1340,7 +1482,9 @@ public class ZFile implements Closeable { dirStart = extraDirectoryOffset; } - Eocd eocd = new Eocd(entries.size(), dirStart, dirSize); + Verify.verify(eocdComment != null); + Eocd eocd = new Eocd(entries.size(), dirStart, dirSize, eocdComment); + eocdComment = null; byte[] eocdBytes = eocd.toBytes(); long eocdOffset = map.size(); @@ -1433,6 +1577,9 @@ public class ZFile implements Closeable { * 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; } @@ -1481,8 +1628,10 @@ public class ZFile implements Closeable { * 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); } @@ -1509,12 +1658,14 @@ public class ZFile implements Closeable { SettableFuture<CentralDirectoryHeaderCompressInfo> compressInfo = SettableFuture.create(); + GPFlags flags = GPFlags.make(encodeWithUtf8); CentralDirectoryHeader newFileData = new CentralDirectoryHeader( name, + EncodeUtils.encode(name, flags), source.size(), compressInfo, - GPFlags.make(encodeWithUtf8), + flags, this); newFileData.setCrc32(crc32); @@ -1551,21 +1702,28 @@ public class ZFile implements Closeable { 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); - } - }); + 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); + Futures.transform( + result, CompressionResult::getSource, MoreExecutors.directExecutor()); LazyDelegateByteSource compressedByteSource = new LazyDelegateByteSource( compressedByteSourceFuture); return new ProcessedAndRawByteSources(source, compressedByteSource); @@ -1591,9 +1749,11 @@ public class ZFile implements Closeable { * @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. @@ -1803,9 +1963,12 @@ public class ZFile implements Closeable { * @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; @@ -1859,7 +2022,7 @@ public class ZFile implements Closeable { throw new IOException("Cannot read source with " + sourceSize + " bytes."); } - byte data[] = new byte[Ints.checkedCast(sourceSize)]; + byte[] data = new byte[Ints.checkedCast(sourceSize)]; int read = 0; while (read < data.length) { int r = fromInput.read(data, read, data.length - read); @@ -1895,8 +2058,11 @@ public class ZFile implements Closeable { /** * 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; } @@ -1923,8 +2089,11 @@ public class ZFile implements Closeable { * 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(); @@ -2038,8 +2207,10 @@ public class ZFile implements Closeable { * 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); } @@ -2047,8 +2218,10 @@ public class ZFile implements Closeable { * 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); } @@ -2092,9 +2265,12 @@ public class ZFile implements Closeable { * @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"); @@ -2119,8 +2295,10 @@ public class ZFile implements Closeable { * @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); } @@ -2257,8 +2435,10 @@ public class ZFile implements Closeable { * @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); } @@ -2269,10 +2449,13 @@ public class ZFile implements Closeable { * 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. @@ -2381,13 +2564,81 @@ public class ZFile implements Closeable { } /** + * 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) { @@ -2423,8 +2674,10 @@ public class ZFile implements Closeable { * 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(); @@ -2487,8 +2740,16 @@ public class ZFile implements Closeable { } /** - * Hint to where files should be positioned. + * 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. diff --git a/src/main/java/com/android/apkzlib/zip/ZFileOptions.java b/src/main/java/com/android/apkzlib/zip/ZFileOptions.java index 3d28d81..08a1d83 100644 --- a/src/main/java/com/android/apkzlib/zip/ZFileOptions.java +++ b/src/main/java/com/android/apkzlib/zip/ZFileOptions.java @@ -105,8 +105,9 @@ public class ZFileOptions { * * @param compressor the compressor */ - public void setCompressor(@Nonnull Compressor compressor) { + public ZFileOptions setCompressor(@Nonnull Compressor compressor) { this.compressor = compressor; + return this; } /** @@ -123,8 +124,9 @@ public class ZFileOptions { * * @param noTimestamps should timestamps be zeroed? */ - public void setNoTimestamps(boolean noTimestamps) { + public ZFileOptions setNoTimestamps(boolean noTimestamps) { this.noTimestamps = noTimestamps; + return this; } /** @@ -142,8 +144,9 @@ public class ZFileOptions { * * @param alignmentRule the alignment rule */ - public void setAlignmentRule(@Nonnull AlignmentRule alignmentRule) { + public ZFileOptions setAlignmentRule(@Nonnull AlignmentRule alignmentRule) { this.alignmentRule = alignmentRule; + return this; } /** @@ -162,8 +165,9 @@ public class ZFileOptions { * * @param coverEmptySpaceUsingExtraField should the extra field be used to cover empty spaces? */ - public void setCoverEmptySpaceUsingExtraField(boolean coverEmptySpaceUsingExtraField) { + public ZFileOptions setCoverEmptySpaceUsingExtraField(boolean coverEmptySpaceUsingExtraField) { this.coverEmptySpaceUsingExtraField = coverEmptySpaceUsingExtraField; + return this; } /** @@ -177,13 +181,14 @@ public class ZFileOptions { } /** - * Sets whether files should be automatically sorted before updating the zip file. See - * {@link ZFile} for an explanation on automatic sorting. + * Sets whether files should be automatically sorted before updating the zip file. See {@link + * ZFile} for an explanation on automatic sorting. * * @param autoSortFiles should the file be automatically sorted? */ - public void setAutoSortFiles(boolean autoSortFiles) { + public ZFileOptions setAutoSortFiles(boolean autoSortFiles) { this.autoSortFiles = autoSortFiles; + return this; } /** @@ -191,8 +196,9 @@ public class ZFileOptions { * * @param verifyLogFactory verification log factory */ - public void setVerifyLogFactory(@Nonnull Supplier<VerifyLog> verifyLogFactory) { + public ZFileOptions setVerifyLogFactory(@Nonnull Supplier<VerifyLog> verifyLogFactory) { this.verifyLogFactory = verifyLogFactory; + return this; } /** diff --git a/src/main/java/com/android/apkzlib/zip/compress/ExecutorCompressor.java b/src/main/java/com/android/apkzlib/zip/compress/ExecutorCompressor.java index 96ad281..54be20c 100644 --- a/src/main/java/com/android/apkzlib/zip/compress/ExecutorCompressor.java +++ b/src/main/java/com/android/apkzlib/zip/compress/ExecutorCompressor.java @@ -52,7 +52,7 @@ public abstract class ExecutorCompressor implements Compressor { executor.execute(() -> { try { future.set(immediateCompress(source)); - } catch (Exception e) { + } catch (Throwable e) { future.setException(e); } }); |