diff options
31 files changed, 1283 insertions, 103 deletions
@@ -1,21 +1,13 @@ licenses(["notice"]) # Apache License 2.0 -load("//tools/base/bazel:utils.bzl", "srcjar") - -srcjar( - name = "srcjar", - java_library = ":apkzlib", - visibility = ["//tools/base/build-system/builder:__pkg__"], -) - java_library( name = "apkzlib", srcs = glob([ "src/main/java/**/*.java", ]), - visibility = ["//visibility:private"], # These sources are compiled into builder. + visibility = ["//tools/base/build-system/builder:__pkg__"], deps = [ - "//tools/base/build-system:tools.apksig", + "//tools/apksig", "//tools/base/third_party:com.google.code.findbugs_jsr305", "//tools/base/third_party:com.google.guava_guava", "//tools/base/third_party:org.bouncycastle_bcpkix-jdk15on", diff --git a/apkzlib.iml b/apkzlib.iml index b94d559..999fffa 100644 --- a/apkzlib.iml +++ b/apkzlib.iml @@ -9,12 +9,13 @@ </content> <orderEntry type="inheritedJdk" /> <orderEntry type="sourceFolder" forTests="false" /> - <orderEntry type="library" name="jsr-305" level="project" /> + <orderEntry type="library" name="jsr305" level="project" /> <orderEntry type="library" name="guava-tools" level="project" /> <orderEntry type="library" scope="TEST" name="JUnit4" level="project" /> <orderEntry type="library" scope="TEST" name="mockito" level="project" /> <orderEntry type="library" name="bouncy-castle" level="project" /> <orderEntry type="module" module-name="testutils" scope="TEST" /> <orderEntry type="module" module-name="apksig" /> + <orderEntry type="library" name="KotlinJavaRuntime" level="project" /> </component> </module>
\ No newline at end of file diff --git a/build.gradle b/build.gradle index f49541a..771d1b8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,4 @@ -apply plugin: 'java' +apply from: "$rootDir/buildSrc/base/baseJava.gradle" dependencies { compile 'com.google.code.findbugs:jsr305:1.3.9' 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); } }); diff --git a/src/test/java/com/android/apkzlib/sign/JarSigningTest.java b/src/test/java/com/android/apkzlib/sign/JarSigningTest.java index ea48cfa..35aeeaf 100644 --- a/src/test/java/com/android/apkzlib/sign/JarSigningTest.java +++ b/src/test/java/com/android/apkzlib/sign/JarSigningTest.java @@ -20,10 +20,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; -import com.android.apkzlib.zip.StoredEntry; -import com.android.apkzlib.zip.ZFile; import com.android.apkzlib.utils.ApkZFileTestUtils; import com.android.apkzlib.utils.ApkZLibPair; +import com.android.apkzlib.zip.StoredEntry; +import com.android.apkzlib.zip.ZFile; import com.google.common.base.Charsets; import com.google.common.hash.Hashing; import java.io.ByteArrayInputStream; @@ -48,6 +48,7 @@ public class JarSigningTest { File zipFile = new File(mTemporaryFolder.getRoot(), "a.zip"); try (ZFile zf = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf); ManifestGenerationExtension manifestExtension = new ManifestGenerationExtension("Me", "Me"); manifestExtension.register(zf); @@ -76,6 +77,7 @@ public class JarSigningTest { ApkZLibPair<PrivateKey, X509Certificate> p = SignatureTestUtils.generateSignaturePre18(); try (ZFile zf1 = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf1); zf1.add("directory/file", new ByteArrayInputStream("useless text".getBytes(Charsets.US_ASCII))); } @@ -130,6 +132,7 @@ public class JarSigningTest { public void signJarWithPrexistingSimpleTextFilePos18() throws Exception { File zipFile = new File(mTemporaryFolder.getRoot(), "a.zip"); try (ZFile zf1 = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf1); zf1.add("directory/file", new ByteArrayInputStream("useless text".getBytes( Charsets.US_ASCII))); } @@ -188,6 +191,7 @@ public class JarSigningTest { public void v2SignAddsApkSigningBlock() throws Exception { File zipFile = new File(mTemporaryFolder.getRoot(), "a.zip"); try (ZFile zf = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf); ManifestGenerationExtension manifestExtension = new ManifestGenerationExtension("Me", "Me"); manifestExtension.register(zf); @@ -220,6 +224,7 @@ public class JarSigningTest { String createdBy = "Uses Android"; try (ZFile zf1 = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf1); zf1.add(file1Name, new ByteArrayInputStream(file1Contents)); ManifestGenerationExtension me = new ManifestGenerationExtension(builtBy, createdBy); me.register(zf1); @@ -233,7 +238,7 @@ public class JarSigningTest { try (InputStream manifestIs = manifestEntry.open()) { Manifest manifest = new Manifest(manifestIs); - assertEquals(1, manifest.getEntries().size()); + assertEquals(2, manifest.getEntries().size()); Attributes file1Attrs = manifest.getEntries().get(file1Name); assertNotNull(file1Attrs); @@ -257,7 +262,7 @@ public class JarSigningTest { try (InputStream manifestIs = manifestEntry.open()) { Manifest manifest = new Manifest(manifestIs); - assertEquals(1, manifest.getEntries().size()); + assertEquals(2, manifest.getEntries().size()); Attributes file1Attrs = manifest.getEntries().get(file1Name); assertNotNull(file1Attrs); @@ -273,6 +278,7 @@ public class JarSigningTest { file1ShaTxt = Base64.getEncoder().encodeToString(file1Sha); try (ZFile zf2 = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf2); ManifestGenerationExtension me = new ManifestGenerationExtension(builtBy, createdBy); me.register(zf2); new SigningExtension(21, p.v2, p.v1, true, false).register(zf2); @@ -287,7 +293,7 @@ public class JarSigningTest { try (InputStream manifestIs = manifestEntry.open()) { Manifest manifest = new Manifest(manifestIs); - assertEquals(1, manifest.getEntries().size()); + assertEquals(2, manifest.getEntries().size()); Attributes file1Attrs = manifest.getEntries().get(file1Name); assertNotNull(file1Attrs); @@ -297,7 +303,7 @@ public class JarSigningTest { } @Test - public void openSignedJarDoesNotForcesWriteifSignatureIsNotCorrect() throws Exception { + public void openSignedJarDoesNotForcesWriteIfSignatureIsNotCorrect() throws Exception { File zipFile = new File(mTemporaryFolder.getRoot(), "a.zip"); ApkZLibPair<PrivateKey, X509Certificate> p = SignatureTestUtils.generateSignaturePos18(); @@ -306,6 +312,7 @@ public class JarSigningTest { byte[] fileContents = "Very interesting contents".getBytes(Charsets.US_ASCII); try (ZFile zf = new ZFile(zipFile)) { + ApkZFileTestUtils.addAndroidManifest(zf); ManifestGenerationExtension me = new ManifestGenerationExtension("I", "Android"); me.register(zf); new SigningExtension(21, p.v2, p.v1, true, false).register(zf); diff --git a/src/test/java/com/android/apkzlib/utils/ApkZFileTestUtils.java b/src/test/java/com/android/apkzlib/utils/ApkZFileTestUtils.java index 916ef46..1ef087f 100644 --- a/src/test/java/com/android/apkzlib/utils/ApkZFileTestUtils.java +++ b/src/test/java/com/android/apkzlib/utils/ApkZFileTestUtils.java @@ -18,10 +18,12 @@ package com.android.apkzlib.utils; import static org.junit.Assert.assertTrue; +import com.android.apkzlib.zip.ZFile; import com.android.testutils.TestResources; import com.google.common.base.Preconditions; import com.google.common.io.ByteSource; import com.google.common.io.Resources; +import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.File; import java.io.IOException; @@ -106,6 +108,22 @@ public final class ApkZFileTestUtils { } } + /* + * Adds a basic compiled AndroidManifest to the given ZFile containing minSdkVersion equal 15 + * and targetSdkVersion equal 25. + */ + public static void addAndroidManifest(ZFile zf) throws IOException { + zf.add("AndroidManifest.xml", new ByteArrayInputStream(getAndroidManifest())); + } + + /* + * Provides a basic compiled AndroidManifest containing minSdkVersion equal 15 and + * targetSdkVersion equal 25. + */ + public static byte[] getAndroidManifest() throws IOException { + return ApkZFileTestUtils.getResourceBytes("/testData/packaging/AndroidManifest.xml").read(); + } + /** * Obtains the timestamp of a newly-created file. * diff --git a/src/test/java/com/android/apkzlib/zfile/ApkAlignmentTest.java b/src/test/java/com/android/apkzlib/zfile/ApkAlignmentTest.java new file mode 100644 index 0000000..1731ba9 --- /dev/null +++ b/src/test/java/com/android/apkzlib/zfile/ApkAlignmentTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2017 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.apkzlib.zfile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.android.apkzlib.zip.CompressionMethod; +import com.android.apkzlib.zip.StoredEntry; +import com.android.apkzlib.zip.ZFile; +import com.android.apkzlib.zip.ZFileOptions; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ApkAlignmentTest { + @Rule public TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + @Test + public void soFilesUncompressedAndAligned() throws Exception { + File apk = new File(mTemporaryFolder.getRoot(), "a.apk"); + + File soFile = new File(mTemporaryFolder.getRoot(), "doesnt_work.so"); + Files.write(soFile.toPath(), new byte[500]); + + ApkZFileCreatorFactory cf = new ApkZFileCreatorFactory(new ZFileOptions()); + ApkCreatorFactory.CreationData creationData = + new ApkCreatorFactory.CreationData( + apk, + null, + null, + false, + false, + null, + null, + 20, + NativeLibrariesPackagingMode.UNCOMPRESSED_AND_ALIGNED, + path -> false); + + ApkCreator creator = cf.make(creationData); + + creator.writeFile(soFile, "/doesnt_work.so"); + creator.close(); + + try (ZFile zf = new ZFile(apk)) { + StoredEntry soEntry = zf.get("/doesnt_work.so"); + assertNotNull(soEntry); + assertEquals( + CompressionMethod.STORE, + soEntry.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + soEntry.getCentralDirectoryHeader().getOffset() + soEntry.getLocalHeaderSize(); + assertTrue(offset % 4096 == 0); + } + } + + @Test + public void soFilesMergedFromZipsCanBeUncompressedAndAligned() throws Exception { + + // Create a zip file with a compressed, unaligned so file. + File zipToMerge = new File(mTemporaryFolder.getRoot(), "a.zip"); + try (ZFile zf = new ZFile(zipToMerge)) { + zf.add("/zero.so", new ByteArrayInputStream(new byte[500])); + } + + try (ZFile zf = new ZFile(zipToMerge)) { + StoredEntry zeroSo = zf.get("/zero.so"); + assertNotNull(zeroSo); + assertEquals( + CompressionMethod.DEFLATE, + zeroSo.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + zeroSo.getCentralDirectoryHeader().getOffset() + zeroSo.getLocalHeaderSize(); + assertFalse(offset % 4096 == 0); + } + + // Create an APK and merge the zip file. + File apk = new File(mTemporaryFolder.getRoot(), "b.apk"); + ApkZFileCreatorFactory cf = new ApkZFileCreatorFactory(new ZFileOptions()); + ApkCreatorFactory.CreationData creationData = + new ApkCreatorFactory.CreationData( + apk, + null, + null, + false, + false, + null, + null, + 20, + NativeLibrariesPackagingMode.UNCOMPRESSED_AND_ALIGNED, + path -> false); + + try (ApkCreator creator = cf.make(creationData)) { + creator.writeZip(zipToMerge, null, null); + } + + // Make sure the file is uncompressed and aligned. + try (ZFile zf = new ZFile(apk)) { + StoredEntry soEntry = zf.get("/zero.so"); + assertNotNull(soEntry); + assertEquals( + CompressionMethod.STORE, + soEntry.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + soEntry.getCentralDirectoryHeader().getOffset() + soEntry.getLocalHeaderSize(); + assertTrue(offset % 4096 == 0); + + byte[] data = soEntry.read(); + assertEquals(500, data.length); + for (int i = 0; i < data.length; i++) { + assertEquals(0, data[i]); + } + } + } + + @Test + public void soFilesUncompressedAndNotAligned() throws Exception { + File apk = new File(mTemporaryFolder.getRoot(), "a.apk"); + + File soFile = new File(mTemporaryFolder.getRoot(), "doesnt_work.so"); + Files.write(soFile.toPath(), new byte[500]); + + ApkZFileCreatorFactory cf = new ApkZFileCreatorFactory(new ZFileOptions()); + ApkCreatorFactory.CreationData creationData = + new ApkCreatorFactory.CreationData( + apk, + null, + null, + false, + false, + null, + null, + 20, + NativeLibrariesPackagingMode.COMPRESSED, + path -> false); + + ApkCreator creator = cf.make(creationData); + + creator.writeFile(soFile, "/doesnt_work.so"); + creator.close(); + + try (ZFile zf = new ZFile(apk)) { + StoredEntry soEntry = zf.get("/doesnt_work.so"); + assertNotNull(soEntry); + assertEquals( + CompressionMethod.DEFLATE, + soEntry.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + soEntry.getCentralDirectoryHeader().getOffset() + soEntry.getLocalHeaderSize(); + assertTrue(offset % 4096 != 0); + } + } + + @Test + public void soFilesMergedFromZipsCanBeUncompressedAndNotAligned() throws Exception { + + // Create a zip file with a compressed, unaligned so file. + File zipToMerge = new File(mTemporaryFolder.getRoot(), "a.zip"); + try (ZFile zf = new ZFile(zipToMerge)) { + zf.add("/zero.so", new ByteArrayInputStream(new byte[500])); + } + + try (ZFile zf = new ZFile(zipToMerge)) { + StoredEntry zeroSo = zf.get("/zero.so"); + assertNotNull(zeroSo); + assertEquals( + CompressionMethod.DEFLATE, + zeroSo.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + zeroSo.getCentralDirectoryHeader().getOffset() + zeroSo.getLocalHeaderSize(); + assertFalse(offset % 4096 == 0); + } + + // Create an APK and merge the zip file. + File apk = new File(mTemporaryFolder.getRoot(), "b.apk"); + ApkZFileCreatorFactory cf = new ApkZFileCreatorFactory(new ZFileOptions()); + ApkCreatorFactory.CreationData creationData = + new ApkCreatorFactory.CreationData( + apk, + null, + null, + false, + false, + null, + null, + 20, + NativeLibrariesPackagingMode.COMPRESSED, + path -> false); + + try (ApkCreator creator = cf.make(creationData)) { + creator.writeZip(zipToMerge, null, null); + } + + // Make sure the file is uncompressed and aligned. + try (ZFile zf = new ZFile(apk)) { + StoredEntry soEntry = zf.get("/zero.so"); + assertNotNull(soEntry); + assertEquals( + CompressionMethod.DEFLATE, + soEntry.getCentralDirectoryHeader().getCompressionInfoWithWait().getMethod()); + long offset = + soEntry.getCentralDirectoryHeader().getOffset() + soEntry.getLocalHeaderSize(); + assertTrue(offset % 4096 != 0); + + byte[] data = soEntry.read(); + assertEquals(500, data.length); + for (int i = 0; i < data.length; i++) { + assertEquals(0, data[i]); + } + } + } +} diff --git a/src/test/java/com/android/apkzlib/zip/AlignmentTest.java b/src/test/java/com/android/apkzlib/zip/AlignmentTest.java index 0b825a0..e94a876 100644 --- a/src/test/java/com/android/apkzlib/zip/AlignmentTest.java +++ b/src/test/java/com/android/apkzlib/zip/AlignmentTest.java @@ -785,4 +785,72 @@ public class AlignmentTest { zf.add("bar", new ByteArrayInputStream(new byte[] { 5, 6, 7, 8 })); } } + + @Test + public void fourByteAlignment() throws Exception { + // When aligning with 4 bytes, there are are only 3 possible cases: + // - We're 2 bytes short and so need to add +6 bytes (6 bytes for header + no zeroes) + // - We're 3 bytes short and so need to add +7 bytes (6 bytes for header + 1 zero) + // - We're 1 byte short and so need to add +9 bytes (6 bytes for header + 3 zeroes) + + File zipFile = new File(mTemporaryFolder.getRoot(), "a.zip"); + ZFileOptions options = new ZFileOptions(); + options.setCoverEmptySpaceUsingExtraField(true); + options.setAlignmentRule(AlignmentRules.constant(4)); + try (ZFile zf = new ZFile(zipFile, options)) { + // File header starts at 0. + // File name starts at 30 (LOCAL_HEADER_SIZE). + // If unaligned we would have data starting at 33, but with aligned we have data + // starting at 40 (36 isn't enough for the extra data header). + String fooName = "foo"; + byte[] fooData = new byte[] { 1, 2, 3, 4, 5 }; + zf.add(fooName, new ByteArrayInputStream(fooData), false); + zf.update(); + StoredEntry foo = zf.get(fooName); + long fooOffset = ZFileTestConstants.LOCAL_HEADER_SIZE + fooName.length() + 7; + assertEquals(fooOffset, foo.getLocalHeaderSize()); + + // Bar header starts at 45 (foo data starts at 40 and is 5 bytes long). + // Bar header ends at 75. + // If unaligned we would have data starting at 78, but with aligned we have data + // starting at 84 (80 isn't enough for the extra header). + String barName = "bar"; + byte[] barData = new byte[] { 6 }; + zf.add(barName, new ByteArrayInputStream(barData), false); + zf.update(); + + StoredEntry bar = zf.get(barName); + long barStart = bar.getCentralDirectoryHeader().getOffset(); + assertEquals(fooOffset + fooData.length, barStart); + + long barStartOffset = ZFileTestConstants.LOCAL_HEADER_SIZE + barName.length() + 6; + assertEquals(barStartOffset, bar.getLocalHeaderSize()); + + // Xpto header starts at 85 (bar data starts at 84 and is 1 byte long). + // Xpto header ends at 115. + // If unaligned we would have data starting at 119, but with aligned we have data + // starting at 128 (120 & 124 are not enough for the extra header). + String xptoName = "xpto"; + byte[] xptoData = new byte[] { 7, 8, 9, 10 }; + zf.add(xptoName, new ByteArrayInputStream(xptoData), false); + zf.update(); + + StoredEntry xpto = zf.get(xptoName); + long xptoStart = xpto.getCentralDirectoryHeader().getOffset(); + assertEquals(barStart + barStartOffset + barData.length, xptoStart); + + long xptoStartOffset = ZFileTestConstants.LOCAL_HEADER_SIZE + xptoName.length() + 9; + assertEquals(xptoStartOffset, xpto.getLocalHeaderSize()); + + // Dummy header starts at 133 (xpto data starts at 128 and is 6 bytes long). + String dummyName = "dummy"; + byte[] dummyData = new byte[] { 11 }; + zf.add(dummyName, new ByteArrayInputStream(dummyData), false); + zf.update(); + + StoredEntry dummy = zf.get(dummyName); + long dummyStart = dummy.getCentralDirectoryHeader().getOffset(); + assertEquals(xptoStart + xptoStartOffset + xptoData.length, dummyStart); + } + } } diff --git a/src/test/java/com/android/apkzlib/zip/EncodeUtilsTest.java b/src/test/java/com/android/apkzlib/zip/EncodeUtilsTest.java index 0eaf1cb..8648aa0 100644 --- a/src/test/java/com/android/apkzlib/zip/EncodeUtilsTest.java +++ b/src/test/java/com/android/apkzlib/zip/EncodeUtilsTest.java @@ -56,4 +56,17 @@ public class EncodeUtilsTest { (byte) 0xd0, (byte) 0xb0 }, encoded); assertEquals(kazakhCapital, EncodeUtils.decode(encoded, flags)); } + + @Test + public void asciiDecodeAsUtf8() { + byte[] greatWallChinese = + new byte[] { + (byte) 0xe9, (byte) 0x95, (byte) 0xB7, (byte) 0xe5, (byte) 0x9F, (byte) 0x8E + }; + + GPFlags flags = GPFlags.make(false); + + String text = EncodeUtils.decode(greatWallChinese, flags); + assertEquals("\u9577\u57ce", text); + } } diff --git a/src/test/java/com/android/apkzlib/zip/ExtraFieldTest.java b/src/test/java/com/android/apkzlib/zip/ExtraFieldTest.java index d80ccc4..2371849 100644 --- a/src/test/java/com/android/apkzlib/zip/ExtraFieldTest.java +++ b/src/test/java/com/android/apkzlib/zip/ExtraFieldTest.java @@ -19,6 +19,7 @@ package com.android.apkzlib.zip; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; import com.google.common.collect.ImmutableList; @@ -332,4 +333,29 @@ public class ExtraFieldTest { assertArrayEquals(new byte[] { 0x54, 0x76, 0x04, 0x00, 2, 4, 2, 4 }, sData); } } + + @Test + public void parseInvalidExtraFieldWithInvalidHeader() throws Exception { + byte[] raw = new byte[1]; + ExtraField ef = new ExtraField(raw); + try { + ef.getSegments(); + fail(); + } catch (IOException e) { + // Expected. + } + } + + @Test + public void parseInvalidExtraFieldWithInsufficientData() throws Exception { + // Remember: 0x05, 0x00 = 5 in little endian! + byte[] raw = new byte[] { /* Header */ 0x01, 0x02, /* Size */ 0x05, 0x00, /* Data */ 0x01 }; + ExtraField ef = new ExtraField(raw); + try { + ef.getSegments(); + fail(); + } catch (IOException e) { + // Expected. + } + } } diff --git a/src/test/java/com/android/apkzlib/zip/ZFileReadOnlyTest.java b/src/test/java/com/android/apkzlib/zip/ZFileReadOnlyTest.java new file mode 100644 index 0000000..a030a83 --- /dev/null +++ b/src/test/java/com/android/apkzlib/zip/ZFileReadOnlyTest.java @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2017 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.apkzlib.zip; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import javax.annotation.Nonnull; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ZFileReadOnlyTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void cannotCreateRoFileOnNonExistingFile() throws Exception { + try { + new ZFile(new File(temporaryFolder.getRoot(), "foo.zip"), new ZFileOptions(), true); + fail(); + } catch (IOException e) { + // Expected. + } + } + + @Nonnull + private File makeTestZip() throws IOException { + File zip = new File(temporaryFolder.getRoot(), "foo.zip"); + try (ZFile zf = new ZFile(zip)) { + zf.add("bar", new ByteArrayInputStream(new byte[] { 0, 1, 2, 3, 4, 5 })); + } + + return zip; + } + + @Test + public void cannotUpdateInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.update(); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotAddFilesInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.add("bar2", new ByteArrayInputStream(new byte[] { 6, 7, })); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotAddRecursivelyInRoMode() throws Exception { + File folder = temporaryFolder.newFolder(); + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.addAllRecursively(folder); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotReplaceFilesInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.add("bar", new ByteArrayInputStream(new byte[] { 6, 7 })); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotDeleteFilesInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + StoredEntry bar = zf.get("bar"); + assertNotNull(bar); + try { + bar.delete(); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotMergeInRoMode() throws Exception { + try (ZFile toMerge = new ZFile(new File(temporaryFolder.getRoot(), "a.zip"))) { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.mergeFrom(toMerge, s -> false); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + } + + @Test + public void cannotTouchInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.touch(); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotRealignInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.realign(); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotAddExtensionInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.addZFileExtension(new ZFileExtension() {}); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotDirectWriteInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.directWrite(0, new byte[1]); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotSetEocdCommentInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.setEocdComment(new byte[2]); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotSetCentralDirectoryOffsetInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.setExtraDirectoryOffset(4); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void cannotSortZipContentsInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + try { + zf.sortZipContents(); + fail(); + } catch (IllegalStateException e) { + // Expeted. + } + } + } + + @Test + public void canOpenAndReadFilesInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + StoredEntry bar = zf.get("bar"); + assertNotNull(bar); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5 }, bar.read()); + } + } + + @Test + public void canGetDirectoryAndEocdBytesInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + zf.getCentralDirectoryBytes(); + zf.getEocdBytes(); + zf.getEocdComment(); + } + } + + @Test + public void canDirectReadInRoMode() throws Exception { + try (ZFile zf = new ZFile(makeTestZip(), new ZFileOptions(), true)) { + zf.directRead(0, new byte[2]); + } + } +} diff --git a/src/test/java/com/android/apkzlib/zip/ZFileTest.java b/src/test/java/com/android/apkzlib/zip/ZFileTest.java index a596399..b7f2979 100644 --- a/src/test/java/com/android/apkzlib/zip/ZFileTest.java +++ b/src/test/java/com/android/apkzlib/zip/ZFileTest.java @@ -26,14 +26,14 @@ import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; -import com.android.apkzlib.utils.ApkZFileTestUtils; import com.android.apkzlib.zip.compress.DeflateExecutionCompressor; import com.android.apkzlib.zip.utils.CloseableByteSource; import com.android.apkzlib.zip.utils.RandomAccessFileUtils; -import com.android.testutils.TestUtils; import com.google.common.base.Charsets; import com.google.common.base.Strings; +import com.google.common.base.Throwables; import com.google.common.hash.Hashing; import com.google.common.io.ByteStreams; import com.google.common.io.Closer; @@ -148,6 +148,21 @@ public class ZFileTest { try (ZFile zf = new ZFile(testZip)) { assertEquals(1, zf.entries().size()); + assertTrue(zf.getCentralDirectoryOffset() > 0); + assertTrue(zf.getEocdOffset() > 0); + } + } + + @Test + public void readOnlyV2SignedApkSupport() throws Exception { + File testZip = ZipTestUtils.cloneRsrc("v2-signed.apk", mTemporaryFolder); + + assertTrue(testZip.setWritable(false)); + + try (ZFile zf = new ZFile(testZip)) { + assertEquals(416, zf.entries().size()); + assertTrue(zf.getCentralDirectoryOffset() > 0); + assertTrue(zf.getEocdOffset() > 0); } } @@ -938,6 +953,31 @@ public class ZFileTest { filetMignonKorean + " " + isGoodJapanese, entry.getCentralDirectoryHeader().getName()); assertArrayEquals( + "Stuff about food is good.\n".getBytes(Charsets.US_ASCII), entry.read()); + } + } + + @Test + public void utf8NamesSupportedOnReadingWithoutUtf8Flag() throws Exception { + File zip = ZipTestUtils.cloneRsrc("zip-with-utf8-filename.zip", mTemporaryFolder); + + // Reset bytes 7 and 122 that have the flag in the local header and central directory. + byte[] data = Files.toByteArray(zip); + data[7] = 0; + data[122] = 0; + Files.write(data, zip); + + try (ZFile f = new ZFile(zip)) { + assertEquals(1, f.entries().size()); + + StoredEntry entry = f.entries().iterator().next(); + String filetMignonKorean = "\uc548\uc2eC \uc694\ub9ac"; + String isGoodJapanese = "\u3068\u3066\u3082\u826f\u3044"; + + assertEquals( + filetMignonKorean + " " + isGoodJapanese, + entry.getCentralDirectoryHeader().getName()); + assertArrayEquals( "Stuff about food is good.\n".getBytes(Charsets.US_ASCII), entry.read()); } @@ -1631,4 +1671,151 @@ public class ZFileTest { assertNotEquals(DataDescriptorType.NO_DATA_DESCRIPTOR, se.getDataDescriptorType()); } } + + @Test + public void zipCommentsAreSaved() throws Exception { + File zipFileWithComments = new File(mTemporaryFolder.getRoot(), "a.zip"); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFileWithComments))) { + zos.setComment("foo"); + } + + /* + * Open the zip file and check the comment is there. + */ + try (ZFile zf = new ZFile(zipFileWithComments)) { + byte[] comment = zf.getEocdComment(); + assertArrayEquals(new byte[] { 'f', 'o', 'o' }, comment); + + /* + * Modify the comment and write the file. + */ + zf.setEocdComment(new byte[] { 'b', 'a', 'r', 'r' }); + } + + /* + * Open the file and see that the comment is there (both with java and zfile). + */ + try (ZipFile zf2 = new ZipFile(zipFileWithComments)) { + assertEquals("barr", zf2.getComment()); + } + + try (ZFile zf3 = new ZFile(zipFileWithComments)) { + assertArrayEquals(new byte[] { 'b', 'a', 'r', 'r' }, zf3.getEocdComment()); + } + } + + @Test + public void eocdCommentsWithMoreThan64kNotAllowed() throws Exception { + File zipFileWithComments = new File(mTemporaryFolder.getRoot(), "a.zip"); + try (ZFile zf = new ZFile(zipFileWithComments)) { + try { + zf.setEocdComment(new byte[65536]); + fail(); + } catch (IllegalArgumentException e) { + // Expected. + } + + zf.setEocdComment(new byte[65535]); + } + } + + @Test + public void eocdCommentsWithTheEocdMarkerAreAllowed() throws Exception { + File zipFileWithComments = new File(mTemporaryFolder.getRoot(), "a.zip"); + byte[] data = new byte[100]; + data[50] = 0x50; // Signature + data[51] = 0x4b; + data[52] = 0x05; + data[53] = 0x06; + data[54] = 0x00; // Number of disk + data[55] = 0x00; + data[56] = 0x00; // Disk CD start + data[57] = 0x00; + data[54] = 0x01; // Total records 1 + data[55] = 0x00; + data[56] = 0x02; // Total records 2, must be = to total records 1 + data[57] = 0x00; + + try (ZFile zf = new ZFile(zipFileWithComments)) { + zf.setEocdComment(data); + } + + try (ZFile zf = new ZFile(zipFileWithComments)) { + assertArrayEquals(data, zf.getEocdComment()); + } + } + + @Test + public void eocdCommentsWithTheEocdMarkerThatAreInvalidAreNotAllowed() throws Exception { + File zipFileWithComments = new File(mTemporaryFolder.getRoot(), "a.zip"); + byte[] data = new byte[100]; + data[50] = 0x50; + data[51] = 0x4b; + data[52] = 0x05; + data[53] = 0x06; + data[67] = 0x00; + + try (ZFile zf = new ZFile(zipFileWithComments)) { + try { + zf.setEocdComment(data); + fail(); + } catch (IllegalArgumentException e) { + // Expected. + } + } + } + + @Test + public void zipCommentsArePreservedWithFileChanges() throws Exception { + File zipFileWithComments = new File(mTemporaryFolder.getRoot(), "a.zip"); + byte[] comment = new byte[] { 1, 3, 4 }; + try (ZFile zf = new ZFile(zipFileWithComments)) { + zf.add("foo", new ByteArrayInputStream(new byte[50])); + zf.setEocdComment(comment); + } + + try (ZFile zf = new ZFile(zipFileWithComments)) { + assertArrayEquals(comment, zf.getEocdComment()); + zf.add("bar", new ByteArrayInputStream(new byte[100])); + } + + try (ZFile zf = new ZFile(zipFileWithComments)) { + assertArrayEquals(comment, zf.getEocdComment()); + } + } + + @Test + public void overlappingZipEntries() throws Exception { + File myZip = ZipTestUtils.cloneRsrc("overlapping.zip", mTemporaryFolder); + try (ZFile zf = new ZFile(myZip)) { + fail(); + } catch (IOException e) { + assertTrue(Throwables.getStackTraceAsString(e).contains("overlapping/bbb")); + assertTrue(Throwables.getStackTraceAsString(e).contains("overlapping/ddd")); + assertFalse(Throwables.getStackTraceAsString(e).contains("Central Directory")); + } + } + + @Test + public void overlappingZipEntryWithCentralDirectory() throws Exception { + File myZip = ZipTestUtils.cloneRsrc("overlapping2.zip", mTemporaryFolder); + try (ZFile zf = new ZFile(myZip)) { + fail(); + } catch (IOException e) { + assertFalse(Throwables.getStackTraceAsString(e).contains("overlapping/bbb")); + assertTrue(Throwables.getStackTraceAsString(e).contains("overlapping/ddd")); + assertTrue(Throwables.getStackTraceAsString(e).contains("Central Directory")); + } + } + + @Test + public void readFileWithOffsetBeyondFileEnd() throws Exception { + File myZip = ZipTestUtils.cloneRsrc("entry-outside-file.zip", mTemporaryFolder); + try (ZFile zf = new ZFile(myZip)) { + fail(); + } catch (IOException e) { + assertTrue(Throwables.getStackTraceAsString(e).contains("entry-outside-file/foo")); + assertTrue(Throwables.getStackTraceAsString(e).contains("EOF")); + } + } } diff --git a/src/test/java/com/android/apkzlib/zip/compress/MultiCompressorTest.java b/src/test/java/com/android/apkzlib/zip/compress/MultiCompressorTest.java index f19962c..4f2eaf0 100644 --- a/src/test/java/com/android/apkzlib/zip/compress/MultiCompressorTest.java +++ b/src/test/java/com/android/apkzlib/zip/compress/MultiCompressorTest.java @@ -106,8 +106,9 @@ public class MultiCompressorTest { File resultFile = new File(mTemporaryFolder.getRoot(), "result.zip"); ZFileOptions resultOptions = new ZFileOptions(); - resultOptions.setCompressor(new BestAndDefaultDeflateExecutorCompressor( - MoreExecutors.sameThreadExecutor(), resultOptions.getTracker(), ratio + 0.001)); + resultOptions.setCompressor( + new BestAndDefaultDeflateExecutorCompressor( + MoreExecutors.directExecutor(), resultOptions.getTracker(), ratio + 0.001)); try ( ZFile defaultZFile = new ZFile(defaultFile); @@ -135,8 +136,9 @@ public class MultiCompressorTest { File resultFile = new File(mTemporaryFolder.getRoot(), "result.zip"); ZFileOptions resultOptions = new ZFileOptions(); - resultOptions.setCompressor(new BestAndDefaultDeflateExecutorCompressor( - MoreExecutors.sameThreadExecutor(), resultOptions.getTracker(), ratio - 0.001)); + resultOptions.setCompressor( + new BestAndDefaultDeflateExecutorCompressor( + MoreExecutors.directExecutor(), resultOptions.getTracker(), ratio - 0.001)); try ( ZFile defaultZFile = new ZFile(defaultFile); diff --git a/src/test/resources/testData/packaging/AndroidManifest.xml b/src/test/resources/testData/packaging/AndroidManifest.xml Binary files differnew file mode 100644 index 0000000..060ec31 --- /dev/null +++ b/src/test/resources/testData/packaging/AndroidManifest.xml diff --git a/src/test/resources/testData/packaging/entry-outside-file.zip b/src/test/resources/testData/packaging/entry-outside-file.zip Binary files differnew file mode 100644 index 0000000..ffd6be9 --- /dev/null +++ b/src/test/resources/testData/packaging/entry-outside-file.zip diff --git a/src/test/resources/testData/packaging/overlapping.zip b/src/test/resources/testData/packaging/overlapping.zip Binary files differnew file mode 100644 index 0000000..7f6144c --- /dev/null +++ b/src/test/resources/testData/packaging/overlapping.zip diff --git a/src/test/resources/testData/packaging/overlapping2.zip b/src/test/resources/testData/packaging/overlapping2.zip Binary files differnew file mode 100644 index 0000000..eecefa9 --- /dev/null +++ b/src/test/resources/testData/packaging/overlapping2.zip diff --git a/src/test/resources/testData/packaging/text-files/.gitattributes b/src/test/resources/testData/packaging/text-files/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/src/test/resources/testData/packaging/text-files/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/src/test/resources/testData/packaging/v2-signed.apk b/src/test/resources/testData/packaging/v2-signed.apk Binary files differnew file mode 100644 index 0000000..7f48475 --- /dev/null +++ b/src/test/resources/testData/packaging/v2-signed.apk |