Add utilities for parsing/extracting individual files from a zip file.
Main features added are:
1. Parse zip file structure from partial zip file.
2. Extract a specific file from partial zip file.
3. Confirm file CRC check and permissions are correct.
Bug: 73786521
Test: unittest
Change-Id: I7d2a497eb0f6dbf53602371ad8eca5fc911988be
diff --git a/common_util/com/android/tradefed/util/ByteArrayUtil.java b/common_util/com/android/tradefed/util/ByteArrayUtil.java
new file mode 100644
index 0000000..510321b
--- /dev/null
+++ b/common_util/com/android/tradefed/util/ByteArrayUtil.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2019 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.tradefed.util;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+/**
+ * Utilities to operate on byte array, e.g., convert bytes to integer.
+ *
+ * <p>Java doesn't have an unsigned value type, so expansion is needed to convert an unsigned
+ * integer stored in 4 bytes to a long value, or unsigned short stored in 2 bytes to an integer
+ * value.
+ */
+public class ByteArrayUtil {
+
+ /**
+ * Get a {@link ByteBuffer} for the given bytes wrapped in a byte array of given size.
+ *
+ * <p>java doesn't have an unsigned value type, so expansion is needed to convert an unsigned
+ * short stored in 2 bytes to an integer value.
+ *
+ * @param bytes an array of bytes.
+ * @param offset the start offset of the integer data.
+ * @param length the length of the integer data.
+ * @param containerSize the size of the array to store the given bytes, append zero to unfilled
+ * items.
+ * @return a {@link ByteBuffer} for the given bytes wrapped in a byte array of given size.
+ */
+ private static ByteBuffer getByteBuffer(
+ byte[] bytes, int offset, int length, int containerSize) {
+ byte[] data = new byte[containerSize];
+ for (int i = 0; i < length; i++) {
+ data[i] = bytes[offset + i];
+ }
+ return ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+ }
+
+ /**
+ * Get an integer from the given bytes.
+ *
+ * <p>java doesn't have an unsigned value type, so expansion is needed to convert an unsigned
+ * short stored in 2 bytes to an integer value.
+ *
+ * @param bytes an array of bytes.
+ * @param offset the start offset of the integer data.
+ * @param length the length of the integer data.
+ * @return an int value from the given bytes.
+ */
+ public static int getInt(byte[] bytes, int offset, int length) {
+ return getByteBuffer(bytes, offset, length, 4).getInt();
+ }
+
+ /**
+ * Get a long value from the given bytes.
+ *
+ * <p>java doesn't have an unsigned value type, so expansion is needed to convert an unsigned
+ * integer stored in 4 bytes to a long value.
+ *
+ * @param bytes an array of bytes.
+ * @param offset the start offset of the long value.
+ * @param length the length of the long value.
+ * @return a long value from the given bytes.
+ */
+ public static long getLong(byte[] bytes, int offset, int length) {
+ return getByteBuffer(bytes, offset, length, 8).getLong();
+ }
+
+ /**
+ * Get the string from the given bytes.
+ *
+ * @param bytes an array of bytes.
+ * @param offset the start offset of the string data.
+ * @param length the length of the string data.
+ */
+ public static String getString(byte[] bytes, int offset, int length) {
+ return new String(Arrays.copyOfRange(bytes, offset, offset + length));
+ }
+}
diff --git a/common_util/com/android/tradefed/util/FileUtil.java b/common_util/com/android/tradefed/util/FileUtil.java
index ee158e2..2034972 100644
--- a/common_util/com/android/tradefed/util/FileUtil.java
+++ b/common_util/com/android/tradefed/util/FileUtil.java
@@ -616,12 +616,29 @@
*/
public static void writeToFile(
InputStream input, File destFile, boolean append) throws IOException {
+ // Set size to a negative value to write all content starting at the given offset.
+ writeToFile(input, destFile, append, 0, -1);
+ }
+
+ /**
+ * A helper method for writing stream data to file
+ *
+ * @param input the unbuffered input stream
+ * @param destFile the destination file to write or append to
+ * @param append append to end of file if true, overwrite otherwise
+ * @param startOffset the start offset of the input stream to retrieve data
+ * @param size number of bytes to retrieve from the input stream, set it to a negative value to
+ * retrieve all content starting at the given offset.
+ */
+ public static void writeToFile(
+ InputStream input, File destFile, boolean append, long startOffset, long size)
+ throws IOException {
InputStream origStream = null;
OutputStream destStream = null;
try {
origStream = new BufferedInputStream(input);
destStream = new BufferedOutputStream(new FileOutputStream(destFile, append));
- StreamUtil.copyStreams(origStream, destStream);
+ StreamUtil.copyStreams(origStream, destStream, startOffset, size);
} finally {
StreamUtil.close(origStream);
StreamUtil.flushAndCloseStream(destStream);
diff --git a/common_util/com/android/tradefed/util/StreamUtil.java b/common_util/com/android/tradefed/util/StreamUtil.java
index 15d32a1..f6fce21 100644
--- a/common_util/com/android/tradefed/util/StreamUtil.java
+++ b/common_util/com/android/tradefed/util/StreamUtil.java
@@ -173,16 +173,51 @@
*
* @param inStream the {@link InputStream}
* @param outStream the {@link OutputStream}
- * @param offset The offset of when to start copying the data.
+ * @param offset the offset of when to start copying the data.
* @throws IOException
*/
public static void copyStreams(InputStream inStream, OutputStream outStream, int offset)
throws IOException {
+ // Set size to a negative value to copy all content starting at the given offset.
+ copyStreams(inStream, outStream, offset, -1);
+ }
+
+ /**
+ * Copies contents of origStream to destStream starting at a given offset with a specific size.
+ *
+ * <p>Recommended to provide a buffered stream for input and output
+ *
+ * @param inStream the {@link InputStream}
+ * @param outStream the {@link OutputStream}
+ * @param offset the offset of when to start copying the data.
+ * @param size the number of bytes to copy. A negative value means to copy all content.
+ * @throws IOException
+ */
+ public static void copyStreams(
+ InputStream inStream, OutputStream outStream, long offset, long size)
+ throws IOException {
+ assert offset >= 0 : "offset must be greater or equal to zero.";
+ assert size != 0 : "size cannot be zero.";
inStream.skip(offset);
byte[] buf = new byte[BUF_SIZE];
- int size = -1;
- while ((size = inStream.read(buf)) != -1) {
- outStream.write(buf, 0, size);
+ long totalRetrievedSize = 0;
+ int retrievedSize = -1;
+ while ((retrievedSize = inStream.read(buf)) != -1) {
+ if (size > 0 && size < totalRetrievedSize + retrievedSize) {
+ retrievedSize = (int) (size - totalRetrievedSize);
+ }
+ outStream.write(buf, 0, retrievedSize);
+ totalRetrievedSize += retrievedSize;
+ if (size == totalRetrievedSize) {
+ break;
+ }
+ }
+ if (size > 0 && size > totalRetrievedSize) {
+ throw new IOException(
+ String.format(
+ "Failed to read %d bytes starting at offset %d, only %d bytes "
+ + "retrieved.",
+ size, offset, totalRetrievedSize));
}
}
diff --git a/common_util/com/android/tradefed/util/ZipUtil.java b/common_util/com/android/tradefed/util/ZipUtil.java
index 61f46a2..6204b8a 100644
--- a/common_util/com/android/tradefed/util/ZipUtil.java
+++ b/common_util/com/android/tradefed/util/ZipUtil.java
@@ -16,6 +16,9 @@
package com.android.tradefed.util;
import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.zip.CentralDirectoryInfo;
+import com.android.tradefed.util.zip.EndCentralDirectoryInfo;
+import com.android.tradefed.util.zip.LocalFileHeader;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
@@ -25,10 +28,14 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.List;
+import java.util.zip.DataFormatException;
import java.util.zip.GZIPOutputStream;
+import java.util.zip.Inflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
@@ -39,9 +46,19 @@
*/
public class ZipUtil {
+ private static final int COMPRESSION_METHOD_STORED = 0;
+ private static final int COMPRESSION_METHOD_DEFLATE = 8;
private static final String DEFAULT_DIRNAME = "dir";
private static final String DEFAULT_FILENAME = "files";
private static final String ZIP_EXTENSION = ".zip";
+ private static final String PARTIAL_ZIP_DATA = "compressed_data";
+
+ private static final boolean IS_UNIX;
+
+ static {
+ String OS = System.getProperty("os.name").toLowerCase();
+ IS_UNIX = (OS.contains("nix") || OS.contains("nux") || OS.contains("aix"));
+ }
/**
* Utility method to verify that a zip file is not corrupt.
@@ -347,4 +364,212 @@
throw e;
}
}
+
+ /**
+ * Get a list of {link CentralDirectoryInfo} for files in a zip file.
+ *
+ * @param partialZipFile a {@link File} object of the partial zip file that contains central
+ * directory entries.
+ * @param endCentralDirInfo a {@link EndCentralDirectoryInfo} object of the zip file.
+ * @return A list of {@link CentralDirectoryInfo} of the zip file
+ * @throws IOException
+ */
+ public static List<CentralDirectoryInfo> getZipCentralDirectoryInfos(
+ File partialZipFile, EndCentralDirectoryInfo endCentralDirInfo) throws IOException {
+ return getZipCentralDirectoryInfos(partialZipFile, endCentralDirInfo, 0);
+ }
+
+ /**
+ * Get a list of {link CentralDirectoryInfo} for files in a zip file.
+ *
+ * @param partialZipFile a {@link File} object of the partial zip file that contains central
+ * directory entries.
+ * @param endCentralDirInfo a {@link EndCentralDirectoryInfo} object of the zip file.
+ * @param offset the offset in the partial zip file where the content of central directory
+ * entries starts.
+ * @return A list of {@link CentralDirectoryInfo} of the zip file
+ * @throws IOException
+ */
+ public static List<CentralDirectoryInfo> getZipCentralDirectoryInfos(
+ File partialZipFile, EndCentralDirectoryInfo endCentralDirInfo, long offset)
+ throws IOException {
+ List<CentralDirectoryInfo> infos = new ArrayList<>();
+ byte[] data;
+ try (FileInputStream stream = new FileInputStream(partialZipFile)) {
+ // Read in the entire central directory block for a zip file till the end. The block
+ // should be small even for a large zip file.
+ long totalSize = stream.getChannel().size();
+ stream.skip(offset);
+ data = new byte[(int) (totalSize - offset)];
+ stream.read(data);
+ }
+ int startOffset = 0;
+ for (int i = 0; i < endCentralDirInfo.getEntryNumber(); i++) {
+ CentralDirectoryInfo info = new CentralDirectoryInfo(data, startOffset);
+ infos.add(info);
+ startOffset += info.getInfoSize();
+ }
+
+ return infos;
+ }
+
+ /**
+ * Apply the file permission configured in the central directory entry.
+ *
+ * @param targetFile the {@link File} to set permission to.
+ * @param zipEntry a {@link CentralDirectoryInfo} object that contains the file permissions.
+ * @throws IOException if fail to access the file.
+ */
+ public static void applyPermission(File targetFile, CentralDirectoryInfo zipEntry)
+ throws IOException {
+ if (!IS_UNIX) {
+ CLog.w("Permission setting is only supported in Unix/Linux system.");
+ return;
+ }
+
+ if (zipEntry.getFilePermission() != 0) {
+ Files.setPosixFilePermissions(
+ targetFile.toPath(), FileUtil.unixModeToPosix(zipEntry.getFilePermission()));
+ }
+ }
+
+ /**
+ * Extract the requested folder from a partial zip file and apply proper permission.
+ *
+ * @param targetFile the {@link File} to save the extracted file to.
+ * @param zipEntry a {@link CentralDirectoryInfo} object of the file to extract from the partial
+ * zip file.
+ * @throws IOException
+ */
+ public static void unzipPartialZipFolder(File targetFile, CentralDirectoryInfo zipEntry)
+ throws IOException {
+ unzipPartialZipFile(null, targetFile, zipEntry, null, -1);
+ }
+
+ /**
+ * Extract the requested file from a partial zip file.
+ *
+ * <p>This method assumes all files are on the same disk when compressed. It doesn't support
+ * following features yet:
+ *
+ * <p>Zip file larger than 4GB
+ *
+ * <p>ZIP64(require ZipLocalFileHeader update on compressed size)
+ *
+ * <p>Encrypted zip file
+ *
+ * <p>Symlink
+ *
+ * @param partialZip a {@link File} that's a partial of the zip file.
+ * @param targetFile the {@link File} to save the extracted file to.
+ * @param zipEntry a {@link CentralDirectoryInfo} object of the file to extract from the partial
+ * zip file.
+ * @param localFileHeader a {@link LocalFileHeader} object of the file to extract from the
+ * partial zip file.
+ * @param startOffset start offset of the file to extract.
+ * @throws IOException
+ */
+ public static void unzipPartialZipFile(
+ File partialZip,
+ File targetFile,
+ CentralDirectoryInfo zipEntry,
+ LocalFileHeader localFileHeader,
+ long startOffset)
+ throws IOException {
+ try {
+ if (zipEntry.getFileName().endsWith("/")) {
+ // Create a folder.
+ targetFile.mkdir();
+ return;
+ } else if (zipEntry.getCompressedSize() == 0) {
+ // The file is empty, just create an empty file.
+ targetFile.createNewFile();
+ return;
+ }
+
+ File zipFile = targetFile;
+ if (zipEntry.getCompressionMethod() != COMPRESSION_METHOD_STORED)
+ // Create a temp file to store the compressed data, then unzip it.
+ zipFile = FileUtil.createTempFile(PARTIAL_ZIP_DATA, ZIP_EXTENSION);
+ else {
+ // The file is not compressed, stream it directly to the target.
+ zipFile.getParentFile().mkdirs();
+ zipFile.createNewFile();
+ }
+
+ // Save compressed data to zipFile
+ try (FileInputStream stream = new FileInputStream(partialZip)) {
+ FileUtil.writeToFile(
+ stream,
+ zipFile,
+ false,
+ startOffset + localFileHeader.getHeaderSize(),
+ zipEntry.getCompressedSize());
+ }
+
+ if (zipEntry.getCompressionMethod() == COMPRESSION_METHOD_STORED) {
+ return;
+ } else if (zipEntry.getCompressionMethod() == COMPRESSION_METHOD_DEFLATE) {
+ boolean success = false;
+ try {
+ unzipRawZip(zipFile, targetFile, zipEntry);
+ success = true;
+ } catch (DataFormatException e) {
+ throw new IOException(e);
+ } finally {
+ zipFile.delete();
+ if (!success) {
+ CLog.e("Failed to unzip %s", zipEntry.getFileName());
+ targetFile.delete();
+ }
+ }
+ } else {
+ throw new RuntimeException(
+ String.format(
+ "Compression method %d is not supported.",
+ localFileHeader.getCompressionMethod()));
+ }
+ } finally {
+ if (targetFile.exists()) {
+ applyPermission(targetFile, zipEntry);
+ }
+ }
+ }
+
+ /**
+ * Unzip the raw compressed content without wrapper (local file header).
+ *
+ * @param zipFile the {@link File} that contains the compressed data of the target file.
+ * @param targetFile {@link File} to same the decompressed data to.
+ * @throws DataFormatException if decompression failed due to zip format issue.
+ * @throws IOException if failed to access the compressed data or the decompressed file has
+ * mismatched CRC.
+ */
+ private static void unzipRawZip(File zipFile, File targetFile, CentralDirectoryInfo zipEntry)
+ throws IOException, DataFormatException {
+ Inflater decompresser = new Inflater(true);
+
+ targetFile.getParentFile().mkdirs();
+ targetFile.createNewFile();
+
+ try (FileInputStream inputStream = new FileInputStream(zipFile);
+ FileOutputStream outputStream = new FileOutputStream(targetFile)) {
+ byte[] data = new byte[32768];
+ byte[] buffer = new byte[65536];
+ while (inputStream.read(data) > 0) {
+ decompresser.setInput(data);
+ while (!decompresser.finished() && !decompresser.needsInput()) {
+ int size = decompresser.inflate(buffer);
+ outputStream.write(buffer, 0, size);
+ }
+ }
+ } finally {
+ decompresser.end();
+ }
+
+ // Validate CRC
+ if (FileUtil.calculateCrc32(targetFile) != zipEntry.getCrc()) {
+ throw new IOException(String.format("Failed to match CRC for file %s", targetFile));
+ }
+ }
}
diff --git a/common_util/com/android/tradefed/util/zip/CentralDirectoryInfo.java b/common_util/com/android/tradefed/util/zip/CentralDirectoryInfo.java
new file mode 100644
index 0000000..5b16625
--- /dev/null
+++ b/common_util/com/android/tradefed/util/zip/CentralDirectoryInfo.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2019 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.tradefed.util.zip;
+
+import com.android.tradefed.util.ByteArrayUtil;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * CentralDirectoryInfo is a class containing the information of a file/folder inside a zip file.
+ *
+ * <p>Overall zipfile format: [Local file header + Compressed data [+ Extended local header]?]*
+ * [Central directory]* [End of central directory record]
+ *
+ * <p>Refer to following link for more details: https://en.wikipedia.org/wiki/Zip_(file_format)
+ */
+public final class CentralDirectoryInfo {
+
+ private static final byte[] CENTRAL_DIRECTORY_SIGNATURE = {0x50, 0x4b, 0x01, 0x02};
+
+ private int mCompressionMethod;
+ private long mCrc;
+ private long mCompressedSize;
+ private long mUncompressedSize;
+ private long mLocalHeaderOffset;
+ private int mInternalFileAttributes;
+ private long mExternalFileAttributes;
+ private String mFileName;
+ private int mFileNameLength;
+ private int mExtraFieldLength;
+ private int mFileCommentLength;
+
+ /** Get the compression method. */
+ public int getCompressionMethod() {
+ return mCompressionMethod;
+ }
+
+ /** Set the compression method. */
+ public void setCompressionMethod(int compressionMethod) {
+ mCompressionMethod = compressionMethod;
+ }
+
+ /** Get the CRC of the file. */
+ public long getCrc() {
+ return mCrc;
+ }
+
+ /** Set the CRC of the file. */
+ public void setCrc(long crc) {
+ mCrc = crc;
+ }
+
+ /** Get the compressed size. */
+ public int getCompressedSize() {
+ return (int) mCompressedSize;
+ }
+
+ /** Set the compressed size. */
+ public void setCompressedSize(long compressionSize) {
+ mCompressedSize = compressionSize;
+ }
+
+ /** Get the uncompressed size. */
+ public long getUncompressedSize() {
+ return mUncompressedSize;
+ }
+
+ /** Set the uncompressed size. */
+ public void setUncompressedSize(long uncompressedSize) {
+ mUncompressedSize = uncompressedSize;
+ }
+
+ /** Get the offset of local file header entry. */
+ public long getLocalHeaderOffset() {
+ return mLocalHeaderOffset;
+ }
+
+ /** Set the offset of local file header entry. */
+ public void setLocalHeaderOffset(long localHeaderOffset) {
+ mLocalHeaderOffset = localHeaderOffset;
+ }
+
+ /** Get the internal file attributes. */
+ public int getInternalFileAttributes() {
+ return mInternalFileAttributes;
+ }
+
+ /** Set the internal file attributes. */
+ public void setInternalFileAttributes(int internalFileAttributes) {
+ mInternalFileAttributes = internalFileAttributes;
+ }
+
+ /** Get the external file attributes. */
+ public long getExternalFileAttributes() {
+ return mExternalFileAttributes;
+ }
+
+ /** Set the external file attributes. */
+ public void setExternalFileAttributes(long externalFileAttributes) {
+ mExternalFileAttributes = externalFileAttributes;
+ }
+
+ /** Get the Linux file permission, stored in the last 9 bits of external file attributes. */
+ public int getFilePermission() {
+ return ((int) mExternalFileAttributes & (0777 << 16L)) >> 16L;
+ }
+
+ /** Get the file name including the relative path. */
+ public String getFileName() {
+ return mFileName;
+ }
+
+ /** Set the file name including the relative path. */
+ public void setFileName(String fileName) {
+ mFileName = fileName;
+ }
+
+ /** Get the file name length. */
+ public int getFileNameLength() {
+ return mFileNameLength;
+ }
+
+ /** Set the file name length. */
+ public void setFileNameLength(int fileNameLength) {
+ mFileNameLength = fileNameLength;
+ }
+
+ /** Get the extra field length. */
+ public int getExtraFieldLength() {
+ return mExtraFieldLength;
+ }
+
+ /** Set the extra field length. */
+ public void setExtraFieldLength(int extraFieldLength) {
+ mExtraFieldLength = extraFieldLength;
+ }
+
+ /** Get the file comment length. */
+ public int getFileCommentLength() {
+ return mFileCommentLength;
+ }
+
+ /** Set the file comment length. */
+ public void setFileCommentLength(int fileCommentLength) {
+ mFileCommentLength = fileCommentLength;
+ }
+
+ /** Get the size of the central directory entry. */
+ public int getInfoSize() {
+ return 46 + mFileNameLength + mExtraFieldLength + mFileCommentLength;
+ }
+
+ /**
+ * Constructor to collect the information of a file entry inside zip file.
+ *
+ * @param data {@code byte[]} of data that contains the information of a file entry.
+ * @param startOffset start offset of the information block.
+ * @throws IOException
+ */
+ public CentralDirectoryInfo(byte[] data, int startOffset) throws IOException {
+ // Central directory:
+ // Offset Length Contents
+ // 0 4 bytes Central file header signature (0x02014b50)
+ // 4 2 bytes Version made by
+ // 6 2 bytes Version needed to extract
+ // 8 2 bytes General purpose bit flag
+ // 10 2 bytes Compression method
+ // 12 2 bytes Last mod file time
+ // 14 2 bytes Last mod file date
+ // 16 4 bytes CRC-32
+ // 20 4 bytes Compressed size
+ // 24 4 bytes Uncompressed size
+ // 28 2 bytes Filename length (f)
+ // 30 2 bytes Extra field length (e)
+ // 32 2 bytes File comment length (c)
+ // 34 2 bytes Disk number start
+ // 36 2 bytes Internal file attributes
+ // 38 4 bytes External file attributes (file permission stored in the last 9 bits)
+ // 42 4 bytes Relative offset of local header
+ // 46 (f)bytes Filename
+ // (e)bytes Extra field
+ // (c)bytes File comment
+
+ // Check signature
+ if (!Arrays.equals(
+ CENTRAL_DIRECTORY_SIGNATURE,
+ Arrays.copyOfRange(data, startOffset, startOffset + 4))) {
+ throw new IOException("Invalid central directory info for zip file is found.");
+ }
+ mCompressionMethod = ByteArrayUtil.getInt(data, startOffset + 10, 2);
+ mCrc = ByteArrayUtil.getLong(data, startOffset + 16, 4);
+ mCompressedSize = ByteArrayUtil.getLong(data, startOffset + 20, 4);
+ mUncompressedSize = ByteArrayUtil.getLong(data, startOffset + 24, 4);
+ mInternalFileAttributes = ByteArrayUtil.getInt(data, startOffset + 36, 2);
+ mExternalFileAttributes = ByteArrayUtil.getLong(data, startOffset + 38, 4);
+ mLocalHeaderOffset = ByteArrayUtil.getLong(data, startOffset + 42, 4);
+ mFileNameLength = ByteArrayUtil.getInt(data, startOffset + 28, 2);
+ mFileName = ByteArrayUtil.getString(data, startOffset + 46, mFileNameLength);
+ mExtraFieldLength = ByteArrayUtil.getInt(data, startOffset + 30, 2);
+ mFileCommentLength = ByteArrayUtil.getInt(data, startOffset + 32, 2);
+ }
+}
diff --git a/common_util/com/android/tradefed/util/zip/EndCentralDirectoryInfo.java b/common_util/com/android/tradefed/util/zip/EndCentralDirectoryInfo.java
new file mode 100644
index 0000000..b675ebb
--- /dev/null
+++ b/common_util/com/android/tradefed/util/zip/EndCentralDirectoryInfo.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2019 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.tradefed.util.zip;
+
+import com.android.tradefed.util.ByteArrayUtil;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * EndCentralDirectoryInfo is a class containing the overall information of a zip file. It's at the
+ * end of the zip file.
+ *
+ * <p>Overall zipfile format: [Local file header + Compressed data [+ Extended local header]?]*
+ * [Central directory]* [End of central directory record]
+ *
+ * <p>Refer to following link for more details: https://en.wikipedia.org/wiki/Zip_(file_format)
+ */
+public final class EndCentralDirectoryInfo {
+
+ // End central directory of a zip file is at the end of the file, and its size shouldn't be
+ // larger than 64k.
+ public static final int MAX_LOOKBACK = 64 * 1024;
+
+ private static final byte[] END_CENTRAL_DIRECTORY_SIGNATURE = {0x50, 0x4b, 0x05, 0x06};
+ // Central directory signature is always 4 bytes.
+ private static final int CENTRAL_DIRECTORY_MAGIC_LENGTH = 4;
+
+ private int mEntryNumber;
+ private long mCentralDirSize;
+ private long mCentralDirOffset;
+
+ public int getEntryNumber() {
+ return mEntryNumber;
+ }
+
+ public long getCentralDirSize() {
+ return mCentralDirSize;
+ }
+
+ public long getCentralDirOffset() {
+ return mCentralDirOffset;
+ }
+
+ /**
+ * Constructor to collect end central directory information of a zip file.
+ *
+ * @param zipFile a {@link File} contains the end central directory information. It's likely the
+ * ending part of the zip file.
+ * @throws IOException
+ */
+ public EndCentralDirectoryInfo(File zipFile) throws IOException {
+ // End of central directory record:
+ // Offset Length Contents
+ // 0 4 bytes End of central dir signature (0x06054b50)
+ // 4 2 bytes Number of this disk
+ // 6 2 bytes Number of the disk with the start of the central directory
+ // 8 2 bytes Total number of entries in the central dir on this disk
+ // 10 2 bytes Total number of entries in the central dir
+ // 12 4 bytes Size of the central directory
+ // 16 4 bytes Offset of start of central directory with respect to the starting
+ // disk number
+ // 20 2 bytes zipfile comment length (c)
+ // 22 (c)bytes zipfile comment
+
+ try (FileInputStream stream = new FileInputStream(zipFile)) {
+ long size = stream.getChannel().size();
+ if (size > MAX_LOOKBACK) {
+ stream.skip(size - MAX_LOOKBACK);
+ size = MAX_LOOKBACK;
+ }
+ byte[] endCentralDir = new byte[(int) size];
+ stream.read(endCentralDir);
+ int offset = (int) size - CENTRAL_DIRECTORY_MAGIC_LENGTH - 1;
+ // Seek from the end of the file, searching for the end central directory signature.
+ while (offset >= 0) {
+ if (!java.util.Arrays.equals(
+ END_CENTRAL_DIRECTORY_SIGNATURE,
+ Arrays.copyOfRange(endCentralDir, offset, offset + 4))) {
+ offset--;
+ continue;
+ }
+ // Get the total number of entries in the central directory
+ mEntryNumber = ByteArrayUtil.getInt(endCentralDir, offset + 10, 2);
+ // Get the size of the central directory block
+ mCentralDirSize = ByteArrayUtil.getLong(endCentralDir, offset + 12, 4);
+ // Get the offset of start of central directory
+ mCentralDirOffset = ByteArrayUtil.getLong(endCentralDir, offset + 16, 4);
+
+ if (mCentralDirOffset < 0) {
+ throw new IOException(
+ "Failed to get offset of EndCentralDirectoryInfo. Partial unzip doesn't support zip files larger than 4GB.");
+ }
+ break;
+ }
+ if (offset < 0) {
+ throw new RuntimeException(
+ "Failed to find end central directory info for zip file: "
+ + zipFile.getPath());
+ }
+ }
+ }
+}
diff --git a/common_util/com/android/tradefed/util/zip/LocalFileHeader.java b/common_util/com/android/tradefed/util/zip/LocalFileHeader.java
new file mode 100644
index 0000000..2012189
--- /dev/null
+++ b/common_util/com/android/tradefed/util/zip/LocalFileHeader.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2019 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.tradefed.util.zip;
+
+import com.android.tradefed.util.ByteArrayUtil;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * LocalFileHeader is a class containing the information of a file/folder inside a zip file. The
+ * block of data is at the beginning part of each file entry.
+ *
+ * <p>Overall zipfile format: [Local file header + Compressed data [+ Extended local header]?]*
+ * [Central directory]* [End of central directory record]
+ *
+ * <p>Refer to following link for more details: https://en.wikipedia.org/wiki/Zip_(file_format)
+ */
+public final class LocalFileHeader {
+
+ public static final int LOCAL_FILE_HEADER_SIZE = 30;
+ private static final byte[] LOCAL_FILE_HEADER_SIGNATURE = {0x50, 0x4b, 0x03, 0x04};
+
+ private int mCompressionMethod;
+ private long mCrc;
+ private long mCompressedSize;
+ private long mUncompressedSize;
+ private int mFileNameLength;
+ private int mExtraFieldLength;
+
+ public int getCompressionMethod() {
+ return mCompressionMethod;
+ }
+
+ public long getCrc() {
+ return mCrc;
+ }
+
+ public long getCompressedSize() {
+ return mCompressedSize;
+ }
+
+ public long getUncompressedSize() {
+ return mUncompressedSize;
+ }
+
+ public int getFileNameLength() {
+ return mFileNameLength;
+ }
+
+ public int getExtraFieldLength() {
+ return mExtraFieldLength;
+ }
+
+ public int getHeaderSize() {
+ return LOCAL_FILE_HEADER_SIZE + mFileNameLength + mExtraFieldLength;
+ }
+
+ public LocalFileHeader(File partialZipFile) throws IOException {
+ this(partialZipFile, 0);
+ }
+
+ /**
+ * Constructor to collect local file header information of a file entry in a zip file.
+ *
+ * @param partialZipFile a {@link File} contains the local file header information.
+ * @param startOffset the start offset of the block of data for a local file header.
+ * @throws IOException
+ */
+ public LocalFileHeader(File partialZipFile, int startOffset) throws IOException {
+ // Local file header:
+ // Offset Length Contents
+ // 0 4 bytes Local file header signature (0x04034b50)
+ // 4 2 bytes Version needed to extract
+ // 6 2 bytes General purpose bit flag
+ // 8 2 bytes Compression method
+ // 10 2 bytes Last mod file time
+ // 12 2 bytes Last mod file date
+ // 14 4 bytes CRC-32
+ // 18 4 bytes Compressed size (n)
+ // 22 4 bytes Uncompressed size
+ // 26 2 bytes Filename length (f)
+ // 28 2 bytes Extra field length (e)
+ // (f)bytes Filename
+ // (e)bytes Extra field
+ // (n)bytes Compressed data
+ byte[] data;
+ try (FileInputStream stream = new FileInputStream(partialZipFile)) {
+ stream.skip(startOffset);
+ data = new byte[LOCAL_FILE_HEADER_SIZE];
+ stream.read(data);
+ }
+
+ // Check signature
+ if (!Arrays.equals(LOCAL_FILE_HEADER_SIGNATURE, Arrays.copyOfRange(data, 0, 4))) {
+ throw new IOException("Invalid local file header for zip file is found.");
+ }
+ mCompressionMethod = ByteArrayUtil.getInt(data, 8, 2);
+ mCrc = ByteArrayUtil.getLong(data, 14, 4);
+ mCompressedSize = ByteArrayUtil.getLong(data, 18, 2);
+ mUncompressedSize = ByteArrayUtil.getLong(data, 22, 2);
+ mFileNameLength = ByteArrayUtil.getInt(data, 26, 2);
+ mExtraFieldLength = ByteArrayUtil.getInt(data, 28, 2);
+ }
+}
diff --git a/tests/res/util/partial_zip.zip b/tests/res/util/partial_zip.zip
new file mode 100644
index 0000000..1f50fef
--- /dev/null
+++ b/tests/res/util/partial_zip.zip
Binary files differ
diff --git a/tests/src/com/android/tradefed/util/StreamUtilTest.java b/tests/src/com/android/tradefed/util/StreamUtilTest.java
index 6cd4995..f2e774c 100644
--- a/tests/src/com/android/tradefed/util/StreamUtilTest.java
+++ b/tests/src/com/android/tradefed/util/StreamUtilTest.java
@@ -172,6 +172,46 @@
assertEquals(text, baos.toString());
}
+ /**
+ * Verify that {@link com.android.tradefed.util.StreamUtil#copyStreams(InputStream,
+ * OutputStream, int, int)} can copy partial content.
+ */
+ public void testCopyStreams_partialSuccess() throws Exception {
+ String text = getLargeText();
+ StringBuilder builder = new StringBuilder(33 * 1024);
+ // Create a string longer than StreamUtil.BUF_SIZE
+ while (builder.length() < 32 * 1024) {
+ builder.append(text);
+ }
+ ByteArrayInputStream bais = new ByteArrayInputStream(builder.toString().getBytes());
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ // Skip the first 1kB, and read longer than StreamUtil.BUF_SIZE
+ StreamUtil.copyStreams(bais, baos, 1024, 20 * 1024);
+ bais.close();
+ baos.close();
+ assertEquals(builder.toString().substring(1024, 21 * 1024), baos.toString());
+ }
+
+ /**
+ * Verify that {@link com.android.tradefed.util.StreamUtil#copyStreams(InputStream,
+ * OutputStream, int, int)} cannot copy partial content if requested size is larger than what's
+ * available.
+ */
+ public void testCopyStreams_partialFail() throws Exception {
+ ByteArrayInputStream bais = null;
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ String text = getLargeText();
+ bais = new ByteArrayInputStream(text.getBytes());
+ // Skip the first 1kB, and read longer than the size of text
+ StreamUtil.copyStreams(bais, baos, 10, text.length() + 1024);
+ fail("IOException should be thrown when reading too much data.");
+ } catch (IOException e) {
+ // Ignore expected error.
+ } finally {
+ StreamUtil.close(bais);
+ }
+ }
+
public void testCopyStreamToWriter() throws Exception {
String text = getLargeText();
ByteArrayInputStream bais = new ByteArrayInputStream(text.getBytes());
diff --git a/tests/src/com/android/tradefed/util/ZipUtilTest.java b/tests/src/com/android/tradefed/util/ZipUtilTest.java
index 4e4fb2f..51dc090 100644
--- a/tests/src/com/android/tradefed/util/ZipUtilTest.java
+++ b/tests/src/com/android/tradefed/util/ZipUtilTest.java
@@ -15,13 +15,24 @@
*/
package com.android.tradefed.util;
+import com.android.tradefed.util.zip.CentralDirectoryInfo;
+import com.android.tradefed.util.zip.EndCentralDirectoryInfo;
+import com.android.tradefed.util.zip.LocalFileHeader;
+
import junit.framework.TestCase;
+import java.io.BufferedReader;
import java.io.File;
+import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
import java.util.Arrays;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import java.util.zip.ZipFile;
@@ -191,6 +202,159 @@
}
}
+ public void testPartipUnzip() throws Exception {
+ File partialZipFile = null;
+ File tmpDir = null;
+ Set<PosixFilePermission> permissions;
+ try {
+ // The zip file is small, read the whole file and assume it's partial.
+ // This does not affect testing the behavior of partial unzipping.
+ partialZipFile = getTestDataFile("partial_zip");
+ EndCentralDirectoryInfo endCentralDirInfo = new EndCentralDirectoryInfo(partialZipFile);
+ List<CentralDirectoryInfo> zipEntries =
+ ZipUtil.getZipCentralDirectoryInfos(
+ partialZipFile,
+ endCentralDirInfo,
+ endCentralDirInfo.getCentralDirOffset());
+ // The zip file has 3 folders, 4 files.
+ assertEquals(7, zipEntries.size());
+
+ CentralDirectoryInfo zipEntry;
+ LocalFileHeader localFileHeader;
+ File targetFile;
+ tmpDir = FileUtil.createTempDir("partial_unzip");
+
+ // Unzip empty file
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("empty_file"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ // Verify file permissions - readonly - 644 rw-r--r--
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("rw-r--r--"), permissions);
+
+ // Unzip text file
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("large_text/file.txt"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ // Verify CRC
+ long crc = FileUtil.calculateCrc32(targetFile);
+ assertEquals(4146093769L, crc);
+ try (BufferedReader br = new BufferedReader(new FileReader(targetFile))) {
+ String line = br.readLine();
+ assertTrue(line.endsWith("this is a text file."));
+ } catch (IOException e) {
+ // fail if the file is corrupt in any way
+ fail("failed reading text file");
+ }
+ // Verify file permissions - read/write - 666 rw-rw-rw-
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("rw-rw-rw-"), permissions);
+
+ // Verify file permissions - executable - 755 rwxr-xr-x
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("executable/executable_file"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("rwxr-xr-x"), permissions);
+
+ // Verify file permissions - readonly - 444 r--r--r--
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("read_only/readonly_file"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("r--r--r--"), permissions);
+
+ // Verify folder permissions - readonly - 744 rwxr--r--
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("read_only/"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("rwxr--r--"), permissions);
+
+ // Verify folder permissions - read/write - 755 rwxr-xr-x
+ zipEntry =
+ zipEntries
+ .stream()
+ .filter(e -> e.getFileName().equals("large_text/"))
+ .findFirst()
+ .get();
+ targetFile = new File(Paths.get(tmpDir.toString(), zipEntry.getFileName()).toString());
+ localFileHeader =
+ new LocalFileHeader(partialZipFile, (int) zipEntry.getLocalHeaderOffset());
+ ZipUtil.unzipPartialZipFile(
+ partialZipFile,
+ targetFile,
+ zipEntry,
+ localFileHeader,
+ zipEntry.getLocalHeaderOffset());
+ permissions = Files.getPosixFilePermissions(targetFile.toPath());
+ assertEquals(PosixFilePermissions.fromString("rwxr-xr-x"), permissions);
+ } finally {
+ FileUtil.deleteFile(partialZipFile);
+ FileUtil.recursiveDelete(tmpDir);
+ }
+ }
+
// Helpers
private File createTempDir(String prefix) throws IOException {
return createTempDir(prefix, null);