// Copyright 2015 The Bazel Authors. All rights reserved.
//
// 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.google.devtools.build.singlejar;


import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.CRC32;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;

/**
 * A helper class to validate zip files and provide reasonable diagnostics (better than what zip
 * does). We might want to make this into a fully-fledged binary some day.
 */
final class ZipTester {

  // The following constants are ZIP-specific.
  private static final int LOCAL_FILE_HEADER_MARKER = 0x04034b50;
  private static final int DATA_DESCRIPTOR_MARKER = 0x08074b50;
  private static final int CENTRAL_DIRECTORY_MARKER = 0x02014b50;
  private static final int END_OF_CENTRAL_DIRECTORY_MARKER = 0x06054b50;

  private static final int FILE_HEADER_BUFFER_SIZE = 26; // without marker
  private static final int DATA_DESCRIPTOR_BUFFER_SIZE = 12; // without marker

  private static final int DIRECTORY_ENTRY_BUFFER_SIZE = 42; // without marker
  private static final int END_OF_CENTRAL_DIRECTORY_BUFFER_SIZE = 18; // without marker

  // Set if the size, compressed size and CRC are set to zero, and present in
  // the data descriptor after the data.
  private static final int SIZE_MASKED_FLAG = 1 << 3;

  private static final int STORED_METHOD = 0;
  private static final int DEFLATE_METHOD = 8;

  private static final int VERSION_STORED = 10; // Version 1.0
  private static final int VERSION_DEFLATE = 20; // Version 2.0

  private static class Entry {
    private final long pos;
    private final String name;
    private final int flags;
    private final int method;
    private final int dosTime;
    Entry(long pos, String name, int flags, int method, int dosTime) {
      this.pos = pos;
      this.name = name;
      this.flags = flags;
      this.method = method;
      this.dosTime = dosTime;
    }
  }

  private final InputStream in;
  private final byte[] buffer = new byte[1024];
  private int bufferLength;
  private int bufferOffset;
  private long pos;

  private List<Entry> entries = new ArrayList<Entry>();

  public ZipTester(InputStream in) {
    this.in = in;
  }

  public ZipTester(byte[] data) {
    this(new ByteArrayInputStream(data));
  }

  private void warn(String msg) {
    System.err.println("WARNING: " + msg);
  }

  private void readMoreData(String action) throws IOException {
    if ((bufferLength > 0) && (bufferOffset > 0)) {
      System.arraycopy(buffer, bufferOffset, buffer, 0, bufferLength);
    }
    if (bufferLength >= buffer.length) {
      // The buffer size is specifically chosen to avoid this situation.
      throw new AssertionError("Internal error: buffer overrun.");
    }
    bufferOffset = 0;
    int bytesRead = in.read(buffer, bufferLength, buffer.length - bufferLength);
    if (bytesRead <= 0) {
      throw new IOException("Unexpected end of file, while " + action);
    }
    bufferLength += bytesRead;
  }

  private int readByte(String action) throws IOException {
    if (bufferLength == 0) {
      readMoreData(action);
    }
    byte result = buffer[bufferOffset];
    bufferOffset++; bufferLength--;
    pos++;
    return result & 0xff;
  }

  private long getUnsignedInt(String action) throws IOException {
    int a = readByte(action);
    int b = readByte(action);
    int c = readByte(action);
    int d = readByte(action);
    return ((d << 24) | (c << 16) | (b << 8) | a) & 0xffffffffL;
  }

  private void readFully(byte[] buffer, String action) throws IOException {
    for (int i = 0; i < buffer.length; i++) {
      buffer[i] = (byte) readByte(action);
    }
  }

  private void skip(long length, String action) throws IOException {
    for (long i = 0; i < length; i++) {
      readByte(action);
    }
  }

  private int getUnsignedShort(byte[] source, int offset) {
    int a = source[offset + 0] & 0xff;
    int b = source[offset + 1] & 0xff;
    return (b << 8) | a;
  }

  private long getUnsignedInt(byte[] source, int offset) {
    int a = source[offset + 0] & 0xff;
    int b = source[offset + 1] & 0xff;
    int c = source[offset + 2] & 0xff;
    int d = source[offset + 3] & 0xff;
    return ((d << 24) | (c << 16) | (b << 8) | a) & 0xffffffffL;
  }

  private class DeflateInputStream extends InputStream {

    private final byte[] singleByteBuffer = new byte[1];
    private int consumedBytes;
    private final Inflater inflater = new Inflater(true);
    private long totalBytesRead;

    private int inflateData(byte[] dest, int off, int len)
        throws IOException {
      consumedBytes = 0;
      int bytesProduced = 0;
      int bytesConsumed = 0;
      while ((bytesProduced == 0) && !inflater.finished()) {
        inflater.setInput(buffer, bufferOffset + bytesConsumed, bufferLength - bytesConsumed);
        int remainingBefore = inflater.getRemaining();
        try {
          bytesProduced = inflater.inflate(dest, off, len);
        } catch (DataFormatException e) {
          throw new IOException("Invalid deflate stream in ZIP file.", e);
        }
        bytesConsumed += remainingBefore - inflater.getRemaining();
        consumedBytes = bytesConsumed;
        if (bytesProduced == 0) {
          if (inflater.needsDictionary()) {
            // The DEFLATE algorithm as used in the ZIP file format does not
            // require an additional dictionary.
            throw new AssertionError("Inflater unexpectedly requires a dictionary.");
          } else if (inflater.needsInput()) {
            readMoreData("need more data for deflate");
          } else if (inflater.finished()) {
            return 0;
          } else {
            // According to the Inflater specification, this cannot happen.
            throw new AssertionError("Inflater unexpectedly produced no output.");
          }
        }
      }
      return bytesProduced;
    }

    @Override
    public int read(byte b[], int off, int len) throws IOException {
      if (inflater.finished()) {
        return -1;
      }
      int length = inflateData(b, off, len);
      totalBytesRead += consumedBytes;
      bufferLength -= consumedBytes;
      bufferOffset += consumedBytes;
      pos += consumedBytes;
      return length == 0 ? -1 : length;
    }

    @Override
    public int read() throws IOException {
      int bytesRead = read(singleByteBuffer, 0, 1);
      return (bytesRead == -1) ? -1 : (singleByteBuffer[0] & 0xff);
    }
  }

  private void readEntry() throws IOException {
    long entrypos = pos - 4;
    String entryDesc = "file entry at " + Long.toHexString(entrypos);
    byte[] entryBuffer = new byte[FILE_HEADER_BUFFER_SIZE];
    readFully(entryBuffer, "reading file header");
    int versionToExtract = getUnsignedShort(entryBuffer, 0);
    int flags = getUnsignedShort(entryBuffer, 2);
    int method = getUnsignedShort(entryBuffer, 4);
    int dosTime = (int) getUnsignedInt(entryBuffer, 6);
    int crc32 = (int) getUnsignedInt(entryBuffer, 10);
    long compressedSize = getUnsignedInt(entryBuffer, 14);
    long uncompressedSize = getUnsignedInt(entryBuffer, 18);
    int filenameLength = getUnsignedShort(entryBuffer, 22);
    int extraLength = getUnsignedShort(entryBuffer, 24);

    byte[] filename = new byte[filenameLength];
    readFully(filename, "reading file name");
    skip(extraLength, "skipping extra data");

    String name = new String(filename, "UTF-8");
    for (int i = 0; i < filename.length; i++) {
      if ((filename[i] < ' ') || (filename[i] > 127)) {
        warn(entryDesc + ": file name has unexpected non-ascii characters");
      }
    }
    entryDesc = "file entry '" + name + "' at " + Long.toHexString(entrypos);

    if ((method != STORED_METHOD) && (method != DEFLATE_METHOD)) {
      throw new IOException(entryDesc + ": unknown method " + method);
    }
    if ((flags != 0) && (flags != SIZE_MASKED_FLAG)) {
      throw new IOException(entryDesc + ": unknown flags " + flags);
    }
    if ((method == STORED_METHOD) && (versionToExtract != VERSION_STORED)) {
      warn(entryDesc + ": unexpected version to extract for stored entry " + versionToExtract);
    }
    if ((method == DEFLATE_METHOD) && (versionToExtract != VERSION_DEFLATE)) {
//      warn(entryDesc + ": unexpected version to extract for deflated entry " + versionToExtract);
    }

    if (method == STORED_METHOD) {
      if (compressedSize != uncompressedSize) {
        throw new IOException(entryDesc + ": stored entries should have identical compressed and "
            + "uncompressed sizes");
      }
      skip(compressedSize, entryDesc + "skipping data");
    } else {
      // No OS resources are actually allocated.
      @SuppressWarnings("resource") DeflateInputStream deflater = new DeflateInputStream();
      long generatedBytes = 0;
      byte[] deflated = new byte[1024];
      int readBytes;
      CRC32 crc = new CRC32();
      while ((readBytes = deflater.read(deflated)) > 0) {
        crc.update(deflated, 0, readBytes);
        generatedBytes += readBytes;
      }
      int actualCrc32 = (int) crc.getValue();
      long consumedBytes = deflater.totalBytesRead;
      if (flags == SIZE_MASKED_FLAG) {
        long id = getUnsignedInt("reading footer marker");
        if (id != DATA_DESCRIPTOR_MARKER) {
          throw new IOException(entryDesc + ": expected footer at " + Long.toHexString(pos - 4)
              + ", but found " + Long.toHexString(id));
        }
        byte[] footer = new byte[DATA_DESCRIPTOR_BUFFER_SIZE];
        readFully(footer, "reading footer");
        crc32 = (int) getUnsignedInt(footer, 0);
        compressedSize = getUnsignedInt(footer, 4);
        uncompressedSize = getUnsignedInt(footer, 8);
      }

      if (consumedBytes != compressedSize) {
        throw new IOException(entryDesc + ": amount of compressed data does not match value "
            + "specified in the zip (specified: " + compressedSize + ", actual: " + consumedBytes
            + ")");
      }
      if (generatedBytes != uncompressedSize) {
        throw new IOException(entryDesc + ": amount of uncompressed data does not match value "
            + "specified in the zip (specified: " + uncompressedSize + ", actual: "
            + generatedBytes + ")");
      }
      if (crc32 != actualCrc32) {
        throw new IOException(entryDesc + ": specified crc checksum does not match actual check "
            + "sum");
      }
    }
    entries.add(new Entry(entrypos, name, flags, method, dosTime));
  }

  @SuppressWarnings("unused") // A couple of unused local variables.
  private void validateCentralDirectoryEntry(Entry entry) throws IOException {
    long entrypos = pos - 4;
    String entryDesc = "file directory entry '" + entry.name + "' at " + Long.toHexString(entrypos);

    byte[] entryBuffer = new byte[DIRECTORY_ENTRY_BUFFER_SIZE];
    readFully(entryBuffer, "reading central directory entry");
    int versionMadeBy = getUnsignedShort(entryBuffer, 0);
    int versionToExtract = getUnsignedShort(entryBuffer, 2);
    int flags = getUnsignedShort(entryBuffer, 4);
    int method = getUnsignedShort(entryBuffer, 6);
    int dosTime = (int) getUnsignedInt(entryBuffer, 8);
    int crc32 = (int) getUnsignedInt(entryBuffer, 12);
    long compressedSize = getUnsignedInt(entryBuffer, 16);
    long uncompressedSize = getUnsignedInt(entryBuffer, 20);
    int filenameLength = getUnsignedShort(entryBuffer, 24);
    int extraLength = getUnsignedShort(entryBuffer, 26);
    int commentLength = getUnsignedShort(entryBuffer, 28);
    int diskNumberStart = getUnsignedShort(entryBuffer, 30);
    int internalAttributes = getUnsignedShort(entryBuffer, 32);
    int externalAttributes = (int) getUnsignedInt(entryBuffer, 34);
    long offset = getUnsignedInt(entryBuffer, 38);

    byte[] filename = new byte[filenameLength];
    readFully(filename, "reading file name");
    skip(extraLength, "skipping extra data");
    String name = new String(filename, "UTF-8");

    if (!name.equals(entry.name)) {
      throw new IOException(entryDesc + ": file name in central directory does not match original "
          + "name");
    }
    if (offset != entry.pos) {
      throw new IOException(entryDesc);
    }
    if (flags != entry.flags) {
      throw new IOException(entryDesc);
    }
    if (method != entry.method) {
      throw new IOException(entryDesc);
    }
    if (dosTime != entry.dosTime) {
      throw new IOException(entryDesc);
    }
  }

  private void validateCentralDirectory() throws IOException {
    boolean first = true;
    for (Entry entry : entries) {
      if (first) {
        first = false;
      } else {
        long id = getUnsignedInt("reading marker");
        if (id != CENTRAL_DIRECTORY_MARKER) {
          throw new IOException();
        }
      }
      validateCentralDirectoryEntry(entry);
    }
  }

  @SuppressWarnings("unused") // A couple of unused local variables.
  private void validateEndOfCentralDirectory() throws IOException {
    long id = getUnsignedInt("expecting end of central directory");
    byte[] entryBuffer = new byte[END_OF_CENTRAL_DIRECTORY_BUFFER_SIZE];
    readFully(entryBuffer, "reading end of central directory");
    int diskNumber = getUnsignedShort(entryBuffer, 0);
    int startDiskNumber = getUnsignedShort(entryBuffer, 2);
    int numEntries = getUnsignedShort(entryBuffer, 4);
    int numTotalEntries = getUnsignedShort(entryBuffer, 6);
    long centralDirectorySize = getUnsignedInt(entryBuffer, 8);
    long centralDirectoryOffset = getUnsignedInt(entryBuffer, 12);
    int commentLength = getUnsignedShort(entryBuffer, 16);
    if (diskNumber != 0) {
      throw new IOException(String.format("diskNumber=%d", diskNumber));
    }
    if (startDiskNumber != 0) {
      throw new IOException(String.format("startDiskNumber=%d", diskNumber));
    }
    if (numEntries != numTotalEntries) {
      throw new IOException(String.format("numEntries=%d numTotalEntries=%d",
                                          numEntries, numTotalEntries));
    }
    if (numEntries != (entries.size() % 0x10000)) {
      throw new IOException("bad number of entries in central directory footer");
    }
    if (numTotalEntries != (entries.size() % 0x10000)) {
      throw new IOException("bad number of entries in central directory footer");
    }
    if (commentLength != 0) {
      throw new IOException("Zip file comment is unexpected");
    }
    if (id != END_OF_CENTRAL_DIRECTORY_MARKER) {
      throw new IOException("Expected end of central directory marker");
    }
  }

  public void validate() throws IOException {
    while (true) {
      long id = getUnsignedInt("reading marker");
      if (id == LOCAL_FILE_HEADER_MARKER) {
        readEntry();
      } else if (id == CENTRAL_DIRECTORY_MARKER) {
        validateCentralDirectory();
        validateEndOfCentralDirectory();
        return;
      } else {
        throw new IOException("unexpected result for marker: "
            + Long.toHexString(id) + " at position " + Long.toHexString(pos - 4));
      }
    }
  }
}
