// 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 static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.fail;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.devtools.build.singlejar.ZipCombiner.OutputMode;
import com.google.devtools.build.singlejar.ZipEntryFilter.CustomMergeStrategy;
import com.google.devtools.build.zip.ExtraData;
import com.google.devtools.build.zip.ZipFileEntry;
import com.google.devtools.build.zip.ZipReader;
import com.google.devtools.build.zip.ZipUtil;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.jar.JarOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
 * Unit tests for {@link ZipCombiner}.
 */
@RunWith(JUnit4.class)
public class ZipCombinerTest {
  @Rule public TemporaryFolder tmp = new TemporaryFolder();
  @Rule public ExpectedException thrown = ExpectedException.none();

  private File sampleZip() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello.txt", "Hello World!");
    return writeInputStreamToFile(factory.toInputStream());
  }

  private File sampleZip2() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello2.txt", "Hello World 2!");
    return writeInputStreamToFile(factory.toInputStream());
  }

  private File sampleZipWithTwoEntries() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello.txt", "Hello World!");
    factory.addFile("hello2.txt", "Hello World 2!");
    return writeInputStreamToFile(factory.toInputStream());
  }

  private File sampleZipWithOneUncompressedEntry() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello.txt", "Hello World!", false);
    return writeInputStreamToFile(factory.toInputStream());
  }

  private File sampleZipWithTwoUncompressedEntries() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello.txt", "Hello World!", false);
    factory.addFile("hello2.txt", "Hello World 2!", false);
    return writeInputStreamToFile(factory.toInputStream());
  }

  private File writeInputStreamToFile(InputStream in) throws IOException {
    File out = tmp.newFile();
    Files.copy(in, out.toPath(), StandardCopyOption.REPLACE_EXISTING);
    return out;
  }

  private void assertEntry(ZipInputStream zipInput, String filename, long time, byte[] content)
      throws IOException {
    ZipEntry zipEntry = zipInput.getNextEntry();
    assertThat(zipEntry).isNotNull();
    assertThat(zipEntry.getName()).isEqualTo(filename);
    assertThat(zipEntry.getTime()).isEqualTo(time);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    byte[] buffer = new byte[1024];
    int bytesCopied;
    while ((bytesCopied = zipInput.read(buffer)) != -1) {
      out.write(buffer, 0, bytesCopied);
    }
    assertThat(out.toByteArray()).isEqualTo(content);
  }

  private void assertEntry(ZipInputStream zipInput, String filename, byte[] content)
      throws IOException {
    assertEntry(zipInput, filename, ZipUtil.DOS_EPOCH, content);
  }

  private void assertEntry(ZipInputStream zipInput, String filename, String content)
      throws IOException {
    assertEntry(zipInput, filename, content.getBytes(ISO_8859_1));
  }

  private void assertEntry(ZipInputStream zipInput, String filename, Date date, String content)
      throws IOException {
    assertEntry(zipInput, filename, date.getTime(), content.getBytes(ISO_8859_1));
  }

  @Test
  public void testCompressedDontCare() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZip());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", true);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testCompressedForceDeflate() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(OutputMode.FORCE_DEFLATE, out)) {
      zipCombiner.addZip(sampleZip());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", true);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testCompressedForceStored() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(OutputMode.FORCE_STORED, out)) {
      zipCombiner.addZip(sampleZip());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", false);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testUncompressedDontCare() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZipWithOneUncompressedEntry());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", false);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testUncompressedForceDeflate() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(OutputMode.FORCE_DEFLATE, out)) {
      zipCombiner.addZip(sampleZipWithOneUncompressedEntry());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", true);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testUncompressedForceStored() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(OutputMode.FORCE_STORED, out)) {
      zipCombiner.addZip(sampleZipWithOneUncompressedEntry());
    }
    FakeZipFile expectedResult = new FakeZipFile().addEntry("hello.txt", "Hello World!", false);
    expectedResult.assertSame(out.toByteArray());
  }

  @Test
  public void testCopyTwoEntries() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testCopyTwoUncompressedEntries() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testCombine() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZip2());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testDuplicateEntry() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZip());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testBadZipFileNoEntry() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      thrown.expect(ZipException.class);
      thrown.expectMessage("It does not contain an end of central directory record.");
      zipCombiner.addZip(writeInputStreamToFile(new ByteArrayInputStream(new byte[] {1, 2, 3, 4})));
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertThat(zipInput.getNextEntry()).isNull();
  }

  private InputStream asStream(String content) {
    return new ByteArrayInputStream(content.getBytes(UTF_8));
  }

  @Test
  public void testAddFile() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addFile("hello.txt", ZipCombiner.DOS_EPOCH, asStream("Hello World!"));
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testAddFileAndDuplicateZipEntry() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      zipCombiner.addFile("hello.txt", ZipCombiner.DOS_EPOCH, asStream("Hello World!"));
      zipCombiner.addZip(sampleZip());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  static final class MergeStrategyPlaceHolder implements CustomMergeStrategy {

    @Override
    public void finish(OutputStream out) {
      throw new UnsupportedOperationException();
    }

    @Override
    public void merge(InputStream in, OutputStream out) {
      throw new UnsupportedOperationException();
    }
  }

  private static final CustomMergeStrategy COPY_PLACEHOLDER = new MergeStrategyPlaceHolder();
  private static final CustomMergeStrategy SKIP_PLACEHOLDER = new MergeStrategyPlaceHolder();

  /**
   * A mock implementation that either uses the specified behavior or calls
   * through to copy.
   */
  class MockZipEntryFilter implements ZipEntryFilter {

    private Date date = ZipCombiner.DOS_EPOCH;
    private final List<String> calls = new ArrayList<>();
    // File name to merge strategy map.
    private final Map<String, CustomMergeStrategy> behavior =
        new HashMap<>();
    private final ListMultimap<String, String> renameMap = ArrayListMultimap.create();

    @Override
    public void accept(String filename, StrategyCallback callback) throws IOException {
      calls.add(filename);
      CustomMergeStrategy strategy = behavior.get(filename);
      if (strategy == null) {
        callback.copy(null);
      } else if (strategy == COPY_PLACEHOLDER) {
        List<String> names = renameMap.get(filename);
        if (names != null && !names.isEmpty()) {
          // rename to the next name in list of replacement names.
          String newName = names.get(0);
          callback.rename(newName, null);
          // Unless this is the last replacment names, we pop the used name.
          // The lastreplacement name applies any additional entries.
          if (names.size() > 1) {
            names.remove(0);
          }
        } else {
          callback.copy(null);
        }
      } else if (strategy == SKIP_PLACEHOLDER) {
        callback.skip();
      } else {
        callback.customMerge(date, strategy);
      }
    }
  }

  @Test
  public void testCopyCallsFilter() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt");
  }

  @Test
  public void testDuplicateEntryCallsFilterOnce() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZip());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt");
  }

  @Test
  public void testMergeStrategy() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new ConcatenateStrategy());
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt", "hello2.txt").inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertEntry(zipInput, "hello.txt", "Hello World!\nHello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testMergeStrategyWithUncompressedFiles() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new ConcatenateStrategy());
    mockFilter.behavior.put("hello2.txt", SKIP_PLACEHOLDER);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
    }
    assertThat(mockFilter.calls).isEqualTo(Arrays.asList("hello.txt", "hello2.txt"));
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!\nHello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testMergeStrategyWithSlowCopy() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new SlowConcatenateStrategy());
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls).isEqualTo(Arrays.asList("hello.txt", "hello2.txt"));
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertEntry(zipInput, "hello.txt", "Hello World!Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testMergeStrategyWithUncompressedFilesAndSlowCopy() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new SlowConcatenateStrategy());
    mockFilter.behavior.put("hello2.txt", SKIP_PLACEHOLDER);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt", "hello2.txt").inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  private File specialZipWithMinusOne() throws IOException {
    ZipFactory factory = new ZipFactory();
    factory.addFile("hello.txt", new byte[] {-1});
    return writeInputStreamToFile(factory.toInputStream());
  }

  @Test
  public void testMergeStrategyWithSlowCopyAndNegativeBytes() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new SlowConcatenateStrategy());
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(specialZipWithMinusOne());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt");
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", new byte[] { -1 });
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testCopyDateHandling() throws IOException {
    final Date date = new GregorianCalendar(2009, 8, 2, 0, 0, 0).getTime();
    ZipEntryFilter mockFilter =
        new ZipEntryFilter() {
          @Override
          public void accept(String filename, StrategyCallback callback) throws IOException {
            assertThat(filename).isEqualTo("hello.txt");
            callback.copy(date);
          }
        };
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", date, "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testMergeDateHandling() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", new ConcatenateStrategy());
    mockFilter.date = new GregorianCalendar(2009, 8, 2, 0, 0, 0).getTime();
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZip());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls).containsExactly("hello.txt", "hello2.txt").inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello2.txt", ZipCombiner.DOS_EPOCH, "Hello World 2!");
    assertEntry(zipInput, "hello.txt", mockFilter.date, "Hello World!\nHello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  @Test
  public void testDuplicateCallThrowsException() throws IOException {
    ZipEntryFilter badFilter = new ZipEntryFilter() {
      @Override
      public void accept(String filename, StrategyCallback callback) throws IOException {
        // Duplicate callback call.
        callback.skip();
        callback.copy(null);
      }
    };
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(badFilter, out)) {
      zipCombiner.addZip(sampleZip());
      fail();
    } catch (IllegalStateException e) {
      // Expected exception.
    }
  }

  @Test
  public void testNoCallThrowsException() throws IOException {
    ZipEntryFilter badFilter = new ZipEntryFilter() {
      @Override
      public void accept(String filename, StrategyCallback callback) {
        // No callback call.
      }
    };
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(badFilter, out)) {
      zipCombiner.addZip(sampleZip());
      fail();
    } catch (IllegalStateException e) {
      // Expected exception.
    }
  }

  // This test verifies that if an entry A is renamed as A (identy mapping),
  // then subsequent entries named A are still subject to filtering.
  // Note: this is different from a copy, where subsequent entries are skipped.
  @Test
  public void testRenameIdentityMapping() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", COPY_PLACEHOLDER);
    mockFilter.renameMap.put("hello.txt", "hello.txt");   // identity rename, not copy
    mockFilter.renameMap.put("hello2.txt", "hello2.txt"); // identity rename, not copy
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly("hello.txt", "hello2.txt", "hello.txt", "hello2.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // This test verifies that multiple entries with the same name can be
  // renamed to unique names.
  @Test
  public void testRenameNoConflictMapping() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", COPY_PLACEHOLDER);
    mockFilter.renameMap.putAll("hello.txt", Arrays.asList("hello1.txt", "hello2.txt"));
    mockFilter.renameMap.putAll("hello2.txt", Arrays.asList("world1.txt", "world2.txt"));
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly("hello.txt", "hello2.txt", "hello.txt", "hello2.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello1.txt", "Hello World!");
    assertEntry(zipInput, "world1.txt", "Hello World 2!");
    assertEntry(zipInput, "hello2.txt", "Hello World!");
    assertEntry(zipInput, "world2.txt", "Hello World 2!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // This tests verifies that an attempt to rename an entry to a
  // name already written, results in the entry being skipped, after
  // calling the filter.
  @Test
  public void testRenameSkipUsedName() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", COPY_PLACEHOLDER);
    mockFilter.renameMap.putAll("hello.txt",
        Arrays.asList("hello1.txt", "hello2.txt", "hello3.txt"));
    mockFilter.renameMap.put("hello2.txt", "hello2.txt");
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly(
            "hello.txt", "hello2.txt", "hello.txt", "hello2.txt", "hello.txt", "hello2.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello1.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertEntry(zipInput, "hello3.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // This tests verifies that if an entry has been copied, then
  // further entries of the same name are skipped (filter not invoked),
  // and entries renamed to the same name are skipped (after calling filter).
  @Test
  public void testRenameAndCopy() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", COPY_PLACEHOLDER);
    mockFilter.renameMap.putAll("hello.txt",
        Arrays.asList("hello1.txt", "hello2.txt", "hello3.txt"));
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly("hello.txt", "hello2.txt", "hello.txt", "hello.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello1.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertEntry(zipInput, "hello3.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // This tests verifies that if an entry has been skipped, then
  // further entries of the same name are skipped (filter not invoked),
  // and entries renamed to the same name are skipped (after calling filter).
  @Test
  public void testRenameAndSkip() throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", SKIP_PLACEHOLDER);
    mockFilter.renameMap.putAll("hello.txt",
        Arrays.asList("hello1.txt", "hello2.txt", "hello3.txt"));
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
      zipCombiner.addZip(sampleZipWithTwoEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly("hello.txt", "hello2.txt", "hello.txt", "hello.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello1.txt", "Hello World!");
    assertEntry(zipInput, "hello3.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // This test verifies that renaming works when input and output
  // disagree on compression method. This is the simple case, where
  // content is read and rewritten, and no header repair is needed.
  @Test
  public void testRenameWithUncompressedFiles () throws IOException {
    MockZipEntryFilter mockFilter = new MockZipEntryFilter();
    mockFilter.behavior.put("hello.txt", COPY_PLACEHOLDER);
    mockFilter.behavior.put("hello2.txt", COPY_PLACEHOLDER);
    mockFilter.renameMap.putAll("hello.txt",
        Arrays.asList("hello1.txt", "hello2.txt", "hello3.txt"));
    mockFilter.renameMap.put("hello2.txt", "hello2.txt");
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(mockFilter, out)) {
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
      zipCombiner.addZip(sampleZipWithTwoUncompressedEntries());
    }
    assertThat(mockFilter.calls)
        .containsExactly(
            "hello.txt", "hello2.txt", "hello.txt", "hello2.txt", "hello.txt", "hello2.txt")
        .inOrder();
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    assertEntry(zipInput, "hello1.txt", "Hello World!");
    assertEntry(zipInput, "hello2.txt", "Hello World 2!");
    assertEntry(zipInput, "hello3.txt", "Hello World!");
    assertThat(zipInput.getNextEntry()).isNull();
  }

  // The next two tests check that ZipCombiner can handle a ZIP with an data
  // descriptor marker in the compressed data, i.e. that it does not scan for
  // the data descriptor marker. It's unfortunately a bit tricky to create such
  // a ZIP.
  private static final int LOCAL_FILE_HEADER_MARKER = 0x04034b50;

  // Create a ZIP with a partial entry.
  private InputStream zipWithPartialEntry() {
    ByteBuffer out = ByteBuffer.wrap(new byte[32]).order(ByteOrder.LITTLE_ENDIAN);
    out.clear();
    // file header
    out.putInt(LOCAL_FILE_HEADER_MARKER);  // file header signature
    out.putShort((short) 6); // version to extract
    out.putShort((short) 0); // general purpose bit flag
    out.putShort((short) ZipOutputStream.STORED); // compression method
    out.putShort((short) 0); // mtime (00:00:00)
    out.putShort((short) 0x21); // mdate (1.1.1980)
    out.putInt(0); // crc32
    out.putInt(10); // compressed size
    out.putInt(10); // uncompressed size
    out.putShort((short) 1); // file name length
    out.putShort((short) 0); // extra field length
    out.put((byte) 'a'); // file name

    // file contents
    out.put((byte) 0x01);
    // Unexpected end of file.

    return new ByteArrayInputStream(out.array());
  }

  @Test
  public void testBadZipFilePartialEntry() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner = new ZipCombiner(out)) {
      thrown.expect(ZipException.class);
      thrown.expectMessage("It does not contain an end of central directory record.");
      zipCombiner.addZip(writeInputStreamToFile(zipWithPartialEntry()));
    }
  }

  @Test
  public void testZipCombinerAgainstJavaUtil() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (JarOutputStream jarOut = new JarOutputStream(out)) {
      ZipEntry entry;
      entry = new ZipEntry("META-INF/");
      entry.setTime(ZipCombiner.DOS_EPOCH.getTime());
      entry.setMethod(JarOutputStream.STORED);
      entry.setSize(0);
      entry.setCompressedSize(0);
      entry.setCrc(0);
      jarOut.putNextEntry(entry);
      entry = new ZipEntry("META-INF/MANIFEST.MF");
      entry.setTime(ZipCombiner.DOS_EPOCH.getTime());
      entry.setMethod(JarOutputStream.DEFLATED);
      jarOut.putNextEntry(entry);
      jarOut.write(new byte[] {1, 2, 3, 4});
    }
    File javaFile = writeInputStreamToFile(new ByteArrayInputStream(out.toByteArray()));
    out.reset();

    try (ZipCombiner zipcombiner = new ZipCombiner(out)) {
      zipcombiner.addDirectory("META-INF/", ZipCombiner.DOS_EPOCH,
          new ExtraData[] {new ExtraData((short) 0xCAFE, new byte[0])});
      zipcombiner.addFile("META-INF/MANIFEST.MF", ZipCombiner.DOS_EPOCH,
          new ByteArrayInputStream(new byte[] {1, 2, 3, 4}));
    }
    File zipCombinerFile = writeInputStreamToFile(new ByteArrayInputStream(out.toByteArray()));
    byte[] zipCombinerRaw = out.toByteArray();

    new ZipTester(zipCombinerRaw).validate();
    assertZipFilesEquivalent(new ZipReader(zipCombinerFile), new ZipReader(javaFile));
  }

  void assertZipFilesEquivalent(ZipReader x, ZipReader y) {
    Collection<ZipFileEntry> xEntries = x.entries();
    Collection<ZipFileEntry> yEntries = y.entries();
    assertThat(xEntries).hasSize(yEntries.size());
    Iterator<ZipFileEntry> xIter = xEntries.iterator();
    Iterator<ZipFileEntry> yIter = yEntries.iterator();
    for (int i = 0; i < xEntries.size(); i++) {
      assertZipEntryEquivalent(xIter.next(), yIter.next());
    }
  }

  void assertZipEntryEquivalent(ZipFileEntry x, ZipFileEntry y) {
    assertThat(x.getComment()).isEqualTo(y.getComment());
    assertThat(x.getCompressedSize()).isEqualTo(y.getCompressedSize());
    assertThat(x.getCrc()).isEqualTo(y.getCrc());
    assertThat(x.getExternalAttributes()).isEqualTo(y.getExternalAttributes());
    // The JDK adds different extra data to zip files on different platforms, so we don't compare
    // the extra data.
    assertThat(x.getInternalAttributes()).isEqualTo(y.getInternalAttributes());
    assertThat(x.getMethod()).isEqualTo(y.getMethod());
    assertThat(x.getName()).isEqualTo(y.getName());
    assertThat(x.getSize()).isEqualTo(y.getSize());
    assertThat(x.getTime()).isEqualTo(y.getTime());
    assertThat(x.getVersion()).isEqualTo(y.getVersion());
    assertThat(x.getVersionNeeded()).isEqualTo(y.getVersionNeeded());
    // Allow general purpose bit 3 (data descriptor) used in jdk7 to differ.
    // Allow general purpose bit 11 (UTF-8 encoding) used in jdk7 to differ.
    assertThat(x.getFlags() | (1 << 3) | (1 << 11))
        .isEqualTo(y.getFlags() | (1 << 3) | (1 << 11));
  }

  /**
   * Ensures that the code that grows the central directory and the code that patches it is not
   * obviously broken.
   */
  @Test
  public void testLotsOfFiles() throws IOException {
    int fileCount = 100;
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    try (ZipCombiner zipCombiner =
        new ZipCombiner(OutputMode.DONT_CARE, new CopyEntryFilter(), out)) {
      for (int i = 0; i < fileCount; i++) {
        zipCombiner.addFile("hello" + i, ZipCombiner.DOS_EPOCH, asStream("Hello " + i + "!"));
      }
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()));
    for (int i = 0; i < fileCount; i++) {
      assertEntry(zipInput, "hello" + i, "Hello " + i + "!");
    }
    assertThat(zipInput.getNextEntry()).isNull();
    new ZipTester(out.toByteArray()).validate();
  }
}
