content: /*
   * Copyright (C) 2016 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package com.android.apksig.internal.zip;
  
  import com.android.apksig.internal.util.ByteBufferSink;
  import com.android.apksig.util.DataSink;
  import com.android.apksig.util.DataSource;
  import com.android.apksig.zip.ZipFormatException;
  import java.io.Closeable;
  import java.io.IOException;
  import java.nio.ByteBuffer;
  import java.nio.ByteOrder;
  import java.nio.charset.StandardCharsets;
  import java.util.zip.DataFormatException;
  import java.util.zip.Inflater;
  
  /**
   * ZIP Local File record.
   *
   * <p>The record consists of the Local File Header, file data, and (if present) Data Descriptor.
   */
  public class LocalFileRecord {
      private static final int RECORD_SIGNATURE = 0x04034b50;
      private static final int HEADER_SIZE_BYTES = 30;
  
      private static final int GP_FLAGS_OFFSET = 6;
      private static final int CRC32_OFFSET = 14;
      private static final int COMPRESSED_SIZE_OFFSET = 18;
      private static final int UNCOMPRESSED_SIZE_OFFSET = 22;
      private static final int NAME_LENGTH_OFFSET = 26;
      private static final int EXTRA_LENGTH_OFFSET = 28;
      private static final int NAME_OFFSET = HEADER_SIZE_BYTES;
  
      private static final int DATA_DESCRIPTOR_SIZE_BYTES_WITHOUT_SIGNATURE = 12;
      private static final int DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
  
      private final String mName;
      private final int mNameSizeBytes;
      private final ByteBuffer mExtra;
  
      private final long mStartOffsetInArchive;
      private final long mSize;
  
      private final int mDataStartOffset;
      private final long mDataSize;
      private final boolean mDataCompressed;
      private final long mUncompressedDataSize;
  
      private LocalFileRecord(
              String name,
              int nameSizeBytes,
              ByteBuffer extra,
              long startOffsetInArchive,
              long size,
              int dataStartOffset,
              long dataSize,
              boolean dataCompressed,
              long uncompressedDataSize) {
          mName = name;
          mNameSizeBytes = nameSizeBytes;
          mExtra = extra;
          mStartOffsetInArchive = startOffsetInArchive;
          mSize = size;
          mDataStartOffset = dataStartOffset;
          mDataSize = dataSize;
          mDataCompressed = dataCompressed;
          mUncompressedDataSize = uncompressedDataSize;
      }
  
      public String getName() {
          return mName;
      }
  
      public ByteBuffer getExtra() {
          return (mExtra.capacity() > 0) ? mExtra.slice() : mExtra;
      }
  
      public int getExtraFieldStartOffsetInsideRecord() {
          return HEADER_SIZE_BYTES + mNameSizeBytes;
      }
  
      public long getStartOffsetInArchive() {
          return mStartOffsetInArchive;
      }
  
      public int getDataStartOffsetInRecord() {
          return mDataStartOffset;
      }
  
      /**
       * Returns the size (in bytes) of this record.
       */
      public long getSize() {
          return mSize;
      }
  
      /**
       * Returns {@code true} if this record's file data is stored in compressed form.
       */
      public boolean isDataCompressed() {
          return mDataCompressed;
      }
  
      /**
       * Returns the Local File record starting at the current position of the provided buffer
       * and advances the buffer's position immediately past the end of the record. The record
       * consists of the Local File Header, data, and (if present) Data Descriptor.
       */
      public static LocalFileRecord getRecord(
              DataSource apk,
              CentralDirectoryRecord cdRecord,
              long cdStartOffset) throws ZipFormatException, IOException {
          return getRecord(
                  apk,
                  cdRecord,
                  cdStartOffset,
                  true, // obtain extra field contents
                  true // include Data Descriptor (if present)
                  );
      }
  
      /**
       * Returns the Local File record starting at the current position of the provided buffer
       * and advances the buffer's position immediately past the end of the record. The record
       * consists of the Local File Header, data, and (if present) Data Descriptor.
       */
      private static LocalFileRecord getRecord(
              DataSource apk,
              CentralDirectoryRecord cdRecord,
              long cdStartOffset,
              boolean extraFieldContentsNeeded,
              boolean dataDescriptorIncluded) throws ZipFormatException, IOException {
          // IMPLEMENTATION NOTE: This method attempts to mimic the behavior of Android platform
          // exhibited when reading an APK for the purposes of verifying its signatures.
  
          String entryName = cdRecord.getName();
          int cdRecordEntryNameSizeBytes = cdRecord.getNameSizeBytes();
          int headerSizeWithName = HEADER_SIZE_BYTES + cdRecordEntryNameSizeBytes;
          long headerStartOffset = cdRecord.getLocalFileHeaderOffset();
          long headerEndOffset = headerStartOffset + headerSizeWithName;
          if (headerEndOffset > cdStartOffset) {
              throw new ZipFormatException(
                      "Local File Header of " + entryName + " extends beyond start of Central"
                              + " Directory. LFH end: " + headerEndOffset
                              + ", CD start: " + cdStartOffset);
          }
          ByteBuffer header;
          try {
              header = apk.getByteBuffer(headerStartOffset, headerSizeWithName);
          } catch (IOException e) {
              throw new IOException("Failed to read Local File Header of " + entryName, e);
          }
          header.order(ByteOrder.LITTLE_ENDIAN);
  
          int recordSignature = header.getInt();
          if (recordSignature != RECORD_SIGNATURE) {
              throw new ZipFormatException(
                      "Not a Local File Header record for entry " + entryName + ". Signature: 0x"
                              + Long.toHexString(recordSignature & 0xffffffffL));
          }
          short gpFlags = header.getShort(GP_FLAGS_OFFSET);
          boolean dataDescriptorUsed = (gpFlags & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0;
          boolean cdDataDescriptorUsed =
                  (cdRecord.getGpFlags() & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0;
          if (dataDescriptorUsed != cdDataDescriptorUsed) {
              throw new ZipFormatException(
                      "Data Descriptor presence mismatch between Local File Header and Central"
                              + " Directory for entry " + entryName
                              + ". LFH: " + dataDescriptorUsed + ", CD: " + cdDataDescriptorUsed);
          }
          long uncompressedDataCrc32FromCdRecord = cdRecord.getCrc32();
          long compressedDataSizeFromCdRecord = cdRecord.getCompressedSize();
          long uncompressedDataSizeFromCdRecord = cdRecord.getUncompressedSize();
          if (!dataDescriptorUsed) {
              long crc32 = ZipUtils.getUnsignedInt32(header, CRC32_OFFSET);
              if (crc32 != uncompressedDataCrc32FromCdRecord) {
                  throw new ZipFormatException(
                          "CRC-32 mismatch between Local File Header and Central Directory for entry "
                                  + entryName + ". LFH: " + crc32
                                  + ", CD: " + uncompressedDataCrc32FromCdRecord);
              }
              long compressedSize = ZipUtils.getUnsignedInt32(header, COMPRESSED_SIZE_OFFSET);
              if (compressedSize != compressedDataSizeFromCdRecord) {
                  throw new ZipFormatException(
                          "Compressed size mismatch between Local File Header and Central Directory"
                                  + " for entry " + entryName + ". LFH: " + compressedSize
                                  + ", CD: " + compressedDataSizeFromCdRecord);
              }
              long uncompressedSize = ZipUtils.getUnsignedInt32(header, UNCOMPRESSED_SIZE_OFFSET);
              if (uncompressedSize != uncompressedDataSizeFromCdRecord) {
                  throw new ZipFormatException(
                          "Uncompressed size mismatch between Local File Header and Central Directory"
                                  + " for entry " + entryName + ". LFH: " + uncompressedSize
                                  + ", CD: " + uncompressedDataSizeFromCdRecord);
              }
          }
          int nameLength = ZipUtils.getUnsignedInt16(header, NAME_LENGTH_OFFSET);
          if (nameLength > cdRecordEntryNameSizeBytes) {
              throw new ZipFormatException(
                      "Name mismatch between Local File Header and Central Directory for entry"
                              + entryName + ". LFH: " + nameLength
                              + " bytes, CD: " + cdRecordEntryNameSizeBytes + " bytes");
          }
          String name = CentralDirectoryRecord.getName(header, NAME_OFFSET, nameLength);
          if (!entryName.equals(name)) {
              throw new ZipFormatException(
                      "Name mismatch between Local File Header and Central Directory. LFH: \""
                              + name + "\", CD: \"" + entryName + "\"");
          }
          int extraLength = ZipUtils.getUnsignedInt16(header, EXTRA_LENGTH_OFFSET);
          long dataStartOffset = headerStartOffset + HEADER_SIZE_BYTES + nameLength + extraLength;
          long dataSize;
          boolean compressed =
                  (cdRecord.getCompressionMethod() != ZipUtils.COMPRESSION_METHOD_STORED);
          if (compressed) {
              dataSize = compressedDataSizeFromCdRecord;
          } else {
              dataSize = uncompressedDataSizeFromCdRecord;
          }
          long dataEndOffset = dataStartOffset + dataSize;
          if (dataEndOffset > cdStartOffset) {
              throw new ZipFormatException(
                      "Local File Header data of " + entryName + " overlaps with Central Directory"
                              + ". LFH data start: " + dataStartOffset
                              + ", LFH data end: " + dataEndOffset + ", CD start: " + cdStartOffset);
          }
  
          ByteBuffer extra = EMPTY_BYTE_BUFFER;
          if ((extraFieldContentsNeeded) && (extraLength > 0)) {
              extra = apk.getByteBuffer(
                      headerStartOffset + HEADER_SIZE_BYTES + nameLength, extraLength);
          }
  
          long recordEndOffset = dataEndOffset;
          // Include the Data Descriptor (if requested and present) into the record.
          if ((dataDescriptorIncluded) && ((gpFlags & ZipUtils.GP_FLAG_DATA_DESCRIPTOR_USED) != 0)) {
              // The record's data is supposed to be followed by the Data Descriptor. Unfortunately,
              // the descriptor's size is not known in advance because the spec lets the signature
              // field (the first four bytes) be omitted. Thus, there's no 100% reliable way to tell
              // how long the Data Descriptor record is. Most parsers (including Android) check
              // whether the first four bytes look like Data Descriptor record signature and, if so,
              // assume that it is indeed the record's signature. However, this is the wrong
              // conclusion if the record's CRC-32 (next field after the signature) has the same value
              // as the signature. In any case, we're doing what Android is doing.
              long dataDescriptorEndOffset =
                      dataEndOffset + DATA_DESCRIPTOR_SIZE_BYTES_WITHOUT_SIGNATURE;
              if (dataDescriptorEndOffset > cdStartOffset) {
                  throw new ZipFormatException(
                          "Data Descriptor of " + entryName + " overlaps with Central Directory"
                                  + ". Data Descriptor end: " + dataEndOffset
                                  + ", CD start: " + cdStartOffset);
              }
              ByteBuffer dataDescriptorPotentialSig = apk.getByteBuffer(dataEndOffset, 4);
              dataDescriptorPotentialSig.order(ByteOrder.LITTLE_ENDIAN);
              if (dataDescriptorPotentialSig.getInt() == DATA_DESCRIPTOR_SIGNATURE) {
                  dataDescriptorEndOffset += 4;
                  if (dataDescriptorEndOffset > cdStartOffset) {
                      throw new ZipFormatException(
                              "Data Descriptor of " + entryName + " overlaps with Central Directory"
                                      + ". Data Descriptor end: " + dataEndOffset
                                      + ", CD start: " + cdStartOffset);
                  }
              }
              recordEndOffset = dataDescriptorEndOffset;
          }
  
          long recordSize = recordEndOffset - headerStartOffset;
          int dataStartOffsetInRecord = HEADER_SIZE_BYTES + nameLength + extraLength;
  
          return new LocalFileRecord(
                  entryName,
                  cdRecordEntryNameSizeBytes,
                  extra,
                  headerStartOffset,
                  recordSize,
                  dataStartOffsetInRecord,
                  dataSize,
                  compressed,
                  uncompressedDataSizeFromCdRecord);
      }
  
      /**
       * Outputs this record and returns returns the number of bytes output.
       */
      public long outputRecord(DataSource sourceApk, DataSink output) throws IOException {
          long size = getSize();
          sourceApk.feed(getStartOffsetInArchive(), size, output);
          return size;
      }
  
      /**
       * Outputs this record, replacing its extra field with the provided one, and returns returns the
       * number of bytes output.
       */
      public long outputRecordWithModifiedExtra(
              DataSource sourceApk,
              ByteBuffer extra,
              DataSink output) throws IOException {
          long recordStartOffsetInSource = getStartOffsetInArchive();
          int extraStartOffsetInRecord = getExtraFieldStartOffsetInsideRecord();
          int extraSizeBytes = extra.remaining();
          int headerSize = extraStartOffsetInRecord + extraSizeBytes;
          ByteBuffer header = ByteBuffer.allocate(headerSize);
          header.order(ByteOrder.LITTLE_ENDIAN);
          sourceApk.copyTo(recordStartOffsetInSource, extraStartOffsetInRecord, header);
          header.put(extra.slice());
          header.flip();
          ZipUtils.setUnsignedInt16(header, EXTRA_LENGTH_OFFSET, extraSizeBytes);
  
          long outputByteCount = header.remaining();
          output.consume(header);
          long remainingRecordSize = getSize() - mDataStartOffset;
          sourceApk.feed(recordStartOffsetInSource + mDataStartOffset, remainingRecordSize, output);
          outputByteCount += remainingRecordSize;
          return outputByteCount;
      }
  
      /**
       * Outputs the specified Local File Header record with its data and returns the number of bytes
       * output.
       */
      public static long outputRecordWithDeflateCompressedData(
              String name,
              int lastModifiedTime,
              int lastModifiedDate,
              byte[] compressedData,
              long crc32,
              long uncompressedSize,
              DataSink output) throws IOException {
          byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
          int recordSize = HEADER_SIZE_BYTES + nameBytes.length;
          ByteBuffer result = ByteBuffer.allocate(recordSize);
          result.order(ByteOrder.LITTLE_ENDIAN);
          result.putInt(RECORD_SIGNATURE);
          ZipUtils.putUnsignedInt16(result,  0x14); // Minimum version needed to extract
          result.putShort(ZipUtils.GP_FLAG_EFS); // General purpose flag: UTF-8 encoded name
          result.putShort(ZipUtils.COMPRESSION_METHOD_DEFLATED);
          ZipUtils.putUnsignedInt16(result, lastModifiedTime);
          ZipUtils.putUnsignedInt16(result, lastModifiedDate);
          ZipUtils.putUnsignedInt32(result, crc32);
          ZipUtils.putUnsignedInt32(result, compressedData.length);
          ZipUtils.putUnsignedInt32(result, uncompressedSize);
          ZipUtils.putUnsignedInt16(result, nameBytes.length);
          ZipUtils.putUnsignedInt16(result, 0); // Extra field length
          result.put(nameBytes);
          if (result.hasRemaining()) {
              throw new RuntimeException("pos: " + result.position() + ", limit: " + result.limit());
          }
          result.flip();
  
          long outputByteCount = result.remaining();
          output.consume(result);
          outputByteCount += compressedData.length;
          output.consume(compressedData, 0, compressedData.length);
          return outputByteCount;
      }
  
      private static final ByteBuffer EMPTY_BYTE_BUFFER = ByteBuffer.allocate(0);
  
      /**
       * Sends uncompressed data of this record into the the provided data sink.
       */
      public void outputUncompressedData(
              DataSource lfhSection,
              DataSink sink) throws IOException, ZipFormatException {
          long dataStartOffsetInArchive = mStartOffsetInArchive + mDataStartOffset;
          try {
              if (mDataCompressed) {
                  try (InflateSinkAdapter inflateAdapter = new InflateSinkAdapter(sink)) {
                      lfhSection.feed(dataStartOffsetInArchive, mDataSize, inflateAdapter);
                      long actualUncompressedSize = inflateAdapter.getOutputByteCount();
                      if (actualUncompressedSize != mUncompressedDataSize) {
                          throw new ZipFormatException(
                                  "Unexpected size of uncompressed data of " + mName
                                          + ". Expected: " + mUncompressedDataSize + " bytes"
                                          + ", actual: " + actualUncompressedSize + " bytes");
                      }
                  } catch (IOException e) {
                      if (e.getCause() instanceof DataFormatException) {
                          throw new ZipFormatException("Data of entry " + mName + " malformed", e);
                      }
                      throw e;
                  }
              } else {
                  lfhSection.feed(dataStartOffsetInArchive, mDataSize, sink);
                  // No need to check whether output size is as expected because DataSource.feed is
                  // guaranteed to output exactly the number of bytes requested.
              }
          } catch (IOException e) {
              throw new IOException(
                      "Failed to read data of " + ((mDataCompressed) ? "compressed" : "uncompressed")
                          + " entry " + mName,
                      e);
          }
          // Interestingly, Android doesn't check that uncompressed data's CRC-32 is as expected. We
          // thus don't check either.
      }
  
      /**
       * Sends uncompressed data pointed to by the provided ZIP Central Directory (CD) record into the
       * provided data sink.
       */
      public static void outputUncompressedData(
              DataSource source,
              CentralDirectoryRecord cdRecord,
              long cdStartOffsetInArchive,
              DataSink sink) throws ZipFormatException, IOException {
          // IMPLEMENTATION NOTE: This method attempts to mimic the behavior of Android platform
          // exhibited when reading an APK for the purposes of verifying its signatures.
          // When verifying an APK, Android doesn't care reading the extra field or the Data
          // Descriptor.
          LocalFileRecord lfhRecord =
                  getRecord(
                          source,
                          cdRecord,
                          cdStartOffsetInArchive,
                          false, // don't care about the extra field
                          false // don't read the Data Descriptor
                          );
          lfhRecord.outputUncompressedData(source, sink);
      }
  
      /**
       * Returns the uncompressed data pointed to by the provided ZIP Central Directory (CD) record.
       */
      public static byte[] getUncompressedData(
              DataSource source,
              CentralDirectoryRecord cdRecord,
              long cdStartOffsetInArchive) throws ZipFormatException, IOException {
          if (cdRecord.getUncompressedSize() > Integer.MAX_VALUE) {
              throw new IOException(
                      cdRecord.getName() + " too large: " + cdRecord.getUncompressedSize());
          }
          byte[] result = new byte[(int) cdRecord.getUncompressedSize()];
          ByteBuffer resultBuf = ByteBuffer.wrap(result);
          ByteBufferSink resultSink = new ByteBufferSink(resultBuf);
          outputUncompressedData(
                  source,
                  cdRecord,
                  cdStartOffsetInArchive,
                  resultSink);
          return result;
      }
  
      /**
       * {@link DataSink} which inflates received data and outputs the deflated data into the provided
       * delegate sink.
       */
      private static class InflateSinkAdapter implements DataSink, Closeable {
          private final DataSink mDelegate;
  
          private Inflater mInflater = new Inflater(true);
          private byte[] mOutputBuffer;
          private byte[] mInputBuffer;
          private long mOutputByteCount;
          private boolean mClosed;
  
          private InflateSinkAdapter(DataSink delegate) {
              mDelegate = delegate;
          }
  
          @Override
          public void consume(byte[] buf, int offset, int length) throws IOException {
              checkNotClosed();
              mInflater.setInput(buf, offset, length);
              if (mOutputBuffer == null) {
                  mOutputBuffer = new byte[65536];
              }
              while (!mInflater.finished()) {
                  int outputChunkSize;
                  try {
                      outputChunkSize = mInflater.inflate(mOutputBuffer);
                  } catch (DataFormatException e) {
                      throw new IOException("Failed to inflate data", e);
                  }
                  if (outputChunkSize == 0) {
                      return;
                  }
                  mDelegate.consume(mOutputBuffer, 0, outputChunkSize);
                  mOutputByteCount += outputChunkSize;
              }
          }
  
          @Override
          public void consume(ByteBuffer buf) throws IOException {
              checkNotClosed();
              if (buf.hasArray()) {
                  consume(buf.array(), buf.arrayOffset() + buf.position(), buf.remaining());
                  buf.position(buf.limit());
              } else {
                  if (mInputBuffer == null) {
                      mInputBuffer = new byte[65536];
                  }
                  while (buf.hasRemaining()) {
                      int chunkSize = Math.min(buf.remaining(), mInputBuffer.length);
                      buf.get(mInputBuffer, 0, chunkSize);
                      consume(mInputBuffer, 0, chunkSize);
                  }
              }
          }
  
          public long getOutputByteCount() {
              return mOutputByteCount;
          }
  
          @Override
          public void close() throws IOException {
              mClosed = true;
              mInputBuffer = null;
              mOutputBuffer = null;
              if (mInflater != null) {
                  mInflater.end();
                  mInflater = null;
              }
          }
  
          private void checkNotClosed() {
              if (mClosed) {
                  throw new IllegalStateException("Closed");
              }
          }
      }
  }
  