| // Copyright 2016 Google Inc. 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.archivepatcher.explainer; |
| |
| import com.google.archivepatcher.generator.ByteArrayHolder; |
| import com.google.archivepatcher.generator.DeltaGenerator; |
| import com.google.archivepatcher.generator.MinimalZipArchive; |
| import com.google.archivepatcher.generator.MinimalZipEntry; |
| import com.google.archivepatcher.generator.RecommendationReason; |
| import com.google.archivepatcher.generator.TotalRecompressionLimiter; |
| import com.google.archivepatcher.shared.Compressor; |
| import com.google.archivepatcher.shared.UnitTestZipArchive; |
| import com.google.archivepatcher.shared.UnitTestZipEntry; |
| import java.io.ByteArrayOutputStream; |
| import java.io.DataInputStream; |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.io.UnsupportedEncodingException; |
| import java.util.Collections; |
| import java.util.List; |
| import org.junit.After; |
| import org.junit.Assert; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| /** |
| * Tests for {@link PatchExplainer}. |
| */ |
| @RunWith(JUnit4.class) |
| @SuppressWarnings("javadoc") |
| public class PatchExplainerTest { |
| |
| // All the A and B entries consist of a chunk of text followed by a standard corpus of text from |
| // the DefaultDeflateCompatibilityDiviner that ensures the tests will be able to discriminate |
| // between any compression level. Without this additional corpus text, multiple compression levels |
| // can match the entry and the unit tests would not be accurate. |
| private static final UnitTestZipEntry ENTRY_A1_LEVEL_6 = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path A", 6, "entry A1", null); |
| private static final UnitTestZipEntry ENTRY_A1_LEVEL_9 = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path A", 9, "entry A1", null); |
| private static final UnitTestZipEntry ENTRY_A1_STORED = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path A", 0, "entry A", null); |
| private static final UnitTestZipEntry ENTRY_A2_LEVEL_9 = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path A", 9, "entry A2", null); |
| private static final UnitTestZipEntry ENTRY_A2_STORED = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path A", 0, "entry A2", null); |
| private static final UnitTestZipEntry ENTRY_B_LEVEL_6 = |
| UnitTestZipArchive.makeUnitTestZipEntry("/path B", 6, "entry B", null); |
| |
| /** |
| * A "compressor" that always outputs the same exact string regardless of the input and asserts |
| * that the input is exactly as expected. |
| */ |
| private static class FakeCompressor implements Compressor { |
| static final String OUTPUT = "fakecompressor output"; |
| private final byte[] expectedInput; |
| |
| public FakeCompressor(byte[] expectedInput) { |
| this.expectedInput = expectedInput; |
| } |
| |
| @Override |
| public void compress(InputStream uncompressedIn, OutputStream compressedOut) |
| throws IOException { |
| byte[] readBuffer = new byte[32768]; |
| int numRead = 0; |
| ByteArrayOutputStream actualInput = new ByteArrayOutputStream(); |
| while ((numRead = uncompressedIn.read(readBuffer)) >= 0) { |
| actualInput.write(readBuffer, 0, numRead); |
| } |
| Assert.assertArrayEquals(expectedInput, actualInput.toByteArray()); |
| compressedOut.write(OUTPUT.getBytes("US-ASCII")); |
| } |
| } |
| |
| /** |
| * A "delta generator" that always outputs the same exact string regardless of the inputs and |
| * asserts that the input is exactly as expected. |
| */ |
| private static class FakeDeltaGenerator implements DeltaGenerator { |
| static final String OUTPUT = "fakedeltagenerator output"; |
| private final byte[] expectedOld; |
| private final byte[] expectedNew; |
| |
| public FakeDeltaGenerator(byte[] expectedOld, byte[] expectedNew) { |
| this.expectedOld = expectedOld; |
| this.expectedNew = expectedNew; |
| } |
| |
| @Override |
| public void generateDelta(File oldBlob, File newBlob, OutputStream deltaOut) |
| throws IOException { |
| assertFileEquals(oldBlob, expectedOld); |
| assertFileEquals(newBlob, expectedNew); |
| deltaOut.write(OUTPUT.getBytes("US-ASCII")); |
| } |
| |
| private final void assertFileEquals(File file, byte[] expected) throws IOException { |
| byte[] actual = new byte[(int) file.length()]; |
| try (FileInputStream fileIn = new FileInputStream(file); |
| DataInputStream dataIn = new DataInputStream(fileIn)) { |
| dataIn.readFully(actual); |
| } |
| Assert.assertArrayEquals(expected, actual); |
| } |
| } |
| |
| /** |
| * Temporary old file. |
| */ |
| private File oldFile = null; |
| |
| /** |
| * Temporary new file. |
| */ |
| private File newFile = null; |
| |
| @Before |
| public void setup() throws IOException { |
| oldFile = File.createTempFile("patchexplainertest", "old"); |
| newFile = File.createTempFile("patchexplainertest", "new"); |
| } |
| |
| @After |
| public void tearDown() { |
| if (oldFile != null) { |
| try { |
| oldFile.delete(); |
| } catch (Exception ignored) { |
| // Nothing |
| } |
| } |
| if (newFile != null) { |
| try { |
| newFile.delete(); |
| } catch (Exception ignored) { |
| // Nothing |
| } |
| } |
| } |
| |
| @Test |
| public void testExplainPatch_CompressedBytesIdentical() throws Exception { |
| byte[] bytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| save(bytes, oldFile); |
| save(bytes, newFile); |
| PatchExplainer explainer = new PatchExplainer(null, null); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_LEVEL_6), false, RecommendationReason.COMPRESSED_BYTES_IDENTICAL, 0); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_CompressedBytesChanged_UncompressedUnchanged() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_9)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| PatchExplainer explainer = new PatchExplainer(null, null); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| // The compressed bytes changed, but the uncompressed bytes are the same. Thus the patch size |
| // should be zero, because the entries are actually identical in the delta-friendly files. |
| // Additionally no diffing or compression should be performed. |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_LEVEL_9), false, RecommendationReason.COMPRESSED_BYTES_CHANGED, 0L); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_CompressedBytesChanged_UncompressedChanged() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A2_LEVEL_9)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator( |
| ENTRY_A1_LEVEL_6.getUncompressedBinaryContent(), |
| ENTRY_A2_LEVEL_9.getUncompressedBinaryContent()); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| // The compressed bytes changed, and so did the uncompressed bytes. The patch size should be |
| // non-zero because the entries are not identical in the delta-friendly files. |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A2_LEVEL_9), |
| false, |
| RecommendationReason.COMPRESSED_BYTES_CHANGED, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_CompressedBytesChanged_UncompressedChanged_Limited() |
| throws Exception { |
| // Just like above, but this time with a TotalRecompressionLimit that changes the result. |
| TotalRecompressionLimiter limiter = new TotalRecompressionLimiter(1); // 1 byte limit! |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A2_LEVEL_9)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| // Note that we will expect a diff based on the COMPRESSED bytes, not the UNCOMPRESSED bytes, |
| // because the limiter will force uncompression to be suppressed. |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator( |
| ENTRY_A1_LEVEL_6.getCompressedBinaryContent(), |
| ENTRY_A2_LEVEL_9.getCompressedBinaryContent()); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile, limiter); |
| // The uncompressed bytes are not the same. The patch plan will want to uncompress the entries, |
| // but the limiter will prevent it. |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A2_LEVEL_9), |
| false, |
| RecommendationReason.RESOURCE_CONSTRAINED, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_BothEntriesUncompressed_BytesUnchanged() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| PatchExplainer explainer = new PatchExplainer(null, null); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| // The uncompressed bytes are the same. Thus the patch size should be zero, because the entries |
| // are identical in the delta-friendly files. Additionally no diffing or compression should be |
| // performed. |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_STORED), false, RecommendationReason.BOTH_ENTRIES_UNCOMPRESSED, 0L); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_BothEntriesUncompressed_BytesChanged() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A2_STORED)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator( |
| ENTRY_A1_STORED.getUncompressedBinaryContent(), |
| ENTRY_A2_STORED.getUncompressedBinaryContent()); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| // The uncompressed bytes are not the same. Thus the patch size should be non-zero. |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A2_STORED), |
| false, |
| RecommendationReason.BOTH_ENTRIES_UNCOMPRESSED, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_CompressedChangedToUncompressed() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_9)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator( |
| ENTRY_A1_LEVEL_9.getUncompressedBinaryContent(), |
| ENTRY_A1_STORED.getUncompressedBinaryContent()); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_STORED), |
| false, |
| RecommendationReason.COMPRESSED_CHANGED_TO_UNCOMPRESSED, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_UncompressedChangedToCompressed() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator( |
| ENTRY_A1_STORED.getUncompressedBinaryContent(), |
| ENTRY_A1_LEVEL_6.getUncompressedBinaryContent()); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_LEVEL_6), |
| false, |
| RecommendationReason.UNCOMPRESSED_CHANGED_TO_COMPRESSED, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_Unsuitable() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_STORED)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| |
| // Corrupt the data in newFile and re-save. This will make the entry un-divinable. |
| MinimalZipEntry newEntry = MinimalZipArchive.listEntries(newFile).get(0); |
| newBytes[(int) newEntry.getFileOffsetOfCompressedData()] = (byte) 0xff; |
| save(newBytes, newFile); |
| byte[] justNewData = new byte[(int) newEntry.getCompressedSize()]; |
| System.arraycopy( |
| newBytes, |
| (int) newEntry.getFileOffsetOfCompressedData(), |
| justNewData, |
| 0, |
| (int) newEntry.getCompressedSize()); |
| |
| FakeDeltaGenerator fakeDeltaGenerator = |
| new FakeDeltaGenerator(ENTRY_A1_STORED.getUncompressedBinaryContent(), justNewData); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(FakeDeltaGenerator.OUTPUT.getBytes("US-ASCII")); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, fakeDeltaGenerator); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_A1_LEVEL_6), |
| false, |
| RecommendationReason.UNSUITABLE, |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| @Test |
| public void testExplainPatch_NewFile() throws Exception { |
| byte[] oldBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_A1_LEVEL_6)); |
| byte[] newBytes = UnitTestZipArchive.makeTestZip(Collections.singletonList(ENTRY_B_LEVEL_6)); |
| save(oldBytes, oldFile); |
| save(newBytes, newFile); |
| FakeCompressor fakeCompressor = |
| new FakeCompressor(ENTRY_B_LEVEL_6.getCompressedBinaryContent()); |
| PatchExplainer explainer = new PatchExplainer(fakeCompressor, null); |
| List<EntryExplanation> explanations = explainer.explainPatch(oldFile, newFile); |
| EntryExplanation expected = |
| new EntryExplanation( |
| path(ENTRY_B_LEVEL_6), |
| true, // isNew |
| null, // recommendation reason (null because the file is new) |
| FakeCompressor.OUTPUT.length()); |
| checkExplanation(explanations, expected); |
| } |
| |
| /** |
| * Check that the specified list of explanations has exactly one explanation and that it matches |
| * the expected explanation. |
| * @param explanations the explanations created by the {@link PatchExplainer} |
| * @param expected the expected explanation |
| */ |
| private void checkExplanation(List<EntryExplanation> explanations, EntryExplanation expected) { |
| Assert.assertEquals(1, explanations.size()); |
| EntryExplanation actual = explanations.get(0); |
| Assert.assertEquals(expected.getPath(), actual.getPath()); |
| Assert.assertEquals(expected.isNew(), actual.isNew()); |
| Assert.assertEquals(expected.getReasonIncludedIfNotNew(), actual.getReasonIncludedIfNotNew()); |
| Assert.assertEquals(expected.getCompressedSizeInPatch(), actual.getCompressedSizeInPatch()); |
| } |
| |
| /** |
| * Convenience method to convert a {@link UnitTestZipEntry}'s path information into a |
| * {@link ByteArrayHolder}. |
| * @param entry the entry to get the path out of |
| * @return the path as a {@link ByteArrayHolder} |
| * @throws UnsupportedEncodingException if the system doesn't support US-ASCII. No, seriously. |
| */ |
| private static ByteArrayHolder path(UnitTestZipEntry entry) throws UnsupportedEncodingException { |
| return new ByteArrayHolder(entry.path.getBytes("US-ASCII")); |
| } |
| |
| /** |
| * Save the specified data to the specified file. |
| * @param data the data to save |
| * @param file the file to save to |
| * @throws IOException if saving fails |
| */ |
| private static void save(byte[] data, File file) throws IOException { |
| try (FileOutputStream out = new FileOutputStream(file)) { |
| out.write(data); |
| out.flush(); |
| } |
| } |
| } |