Implement APK pin list generation from special asset files

Test: unzip -q -c myapp.apk.signed pinlist.meta | od --endian=big -w8 -tx4
Bug: 65316207
Bug: 79259761
Change-Id: I38f8ba97e1470bb640fc07ead54c19d6b4d428bb
diff --git a/src/main/java/com/android/apksig/ApkSigner.java b/src/main/java/com/android/apksig/ApkSigner.java
index bdf57df..88f2617 100644
--- a/src/main/java/com/android/apksig/ApkSigner.java
+++ b/src/main/java/com/android/apksig/ApkSigner.java
@@ -31,7 +31,9 @@
 import com.android.apksig.util.DataSources;
 import com.android.apksig.util.ReadableDataSink;
 import com.android.apksig.zip.ZipFormatException;
+import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
+import java.io.DataOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.RandomAccessFile;
@@ -49,6 +51,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * APK signer.
@@ -237,6 +240,9 @@
         List<CentralDirectoryRecord> inputCdRecords =
                 parseZipCentralDirectory(inputCd, inputZipSections);
 
+        List<Pattern> pinPatterns = extractPinPatterns(inputCdRecords, inputApkLfhSection);
+        List<Hints.ByteRange> pinByteRanges = pinPatterns == null ? null : new ArrayList<>();
+
         // Step 3. Obtain a signer engine instance
         ApkSignerEngine signerEngine;
         if (mSignerEngine != null) {
@@ -298,6 +304,9 @@
                 new HashMap<>(inputCdRecords.size());
         for (final CentralDirectoryRecord inputCdRecord : inputCdRecordsSortedByLfhOffset) {
             String entryName = inputCdRecord.getName();
+            if (Hints.PIN_BYTE_RANGE_ZIP_ENTRY_NAME.equals(entryName)) {
+                continue;  // We'll re-add below if needed.
+            }
             ApkSignerEngine.InputJarEntryInstructions entryInstructions =
                     signerEngine.inputJarEntry(entryName);
             boolean shouldOutput;
@@ -370,6 +379,23 @@
                                 outputLocalFileHeaderOffset);
                 outputOffset += outputLocalFileRecordSize;
 
+                if (pinPatterns != null) {
+                    boolean pinThisFile = false;
+                    for (Pattern pinPattern : pinPatterns) {
+                        if (pinPattern.matcher(inputCdRecord.getName()).matches()) {
+                            pinThisFile = true;
+                            break;
+                        }
+                    }
+
+                    if (pinThisFile) {
+                        pinByteRanges.add(
+                            new Hints.ByteRange(
+                                outputLocalFileHeaderOffset,
+                                outputOffset));
+                    }
+                }
+
                 // Enqueue entry's Central Directory record for output
                 CentralDirectoryRecord outputCdRecord;
                 if (outputLocalFileHeaderOffset == inputLocalFileRecord.getStartOffsetInArchive()) {
@@ -455,6 +481,35 @@
             outputJarSignatureRequest.done();
         }
 
+        if (pinByteRanges != null) {
+            pinByteRanges.add(new Hints.ByteRange(outputOffset, Long.MAX_VALUE));  // central dir
+            String entryName = Hints.PIN_BYTE_RANGE_ZIP_ENTRY_NAME;
+            byte[] uncompressedData = Hints.encodeByteRangeList(pinByteRanges);
+            ZipUtils.DeflateResult deflateResult =
+                    ZipUtils.deflate(ByteBuffer.wrap(uncompressedData));
+            byte[] compressedData = deflateResult.output;
+            long uncompressedDataCrc32 = deflateResult.inputCrc32;
+            long localFileHeaderOffset = outputOffset;
+            outputOffset +=
+                    LocalFileRecord.outputRecordWithDeflateCompressedData(
+                        entryName,
+                        lastModifiedTimeForNewEntries,
+                        lastModifiedDateForNewEntries,
+                        compressedData,
+                        uncompressedDataCrc32,
+                        uncompressedData.length,
+                        outputApkOut);
+            outputCdRecords.add(
+                CentralDirectoryRecord.createWithDeflateCompressedData(
+                    entryName,
+                    lastModifiedTimeForNewEntries,
+                    lastModifiedDateForNewEntries,
+                    uncompressedDataCrc32,
+                    compressedData.length,
+                    uncompressedData.length,
+                    localFileHeaderOffset));
+        }
+
         // Step 8. Construct output ZIP Central Directory in an in-memory buffer
         long outputCentralDirSizeBytes = 0;
         for (CentralDirectoryRecord record : outputCdRecords) {
@@ -707,6 +762,16 @@
         return cdRecords;
     }
 
+    private static CentralDirectoryRecord findCdRecord(
+        List<CentralDirectoryRecord> cdRecords, String name) {
+        for (CentralDirectoryRecord cdRecord : cdRecords) {
+            if (name.equals(cdRecord.getName())) {
+                return cdRecord;
+            }
+        }
+        return null;
+    }
+
     /**
      * Returns the contents of the APK's {@code AndroidManifest.xml} or {@code null} if this entry
      * is not present in the APK.
@@ -714,13 +779,8 @@
     static ByteBuffer getAndroidManifestFromApk(
             List<CentralDirectoryRecord> cdRecords, DataSource lhfSection)
                     throws IOException, ApkFormatException, ZipFormatException {
-        CentralDirectoryRecord androidManifestCdRecord = null;
-        for (CentralDirectoryRecord cdRecord : cdRecords) {
-            if (ANDROID_MANIFEST_ZIP_ENTRY_NAME.equals(cdRecord.getName())) {
-                androidManifestCdRecord = cdRecord;
-                break;
-            }
-        }
+        CentralDirectoryRecord androidManifestCdRecord =
+                findCdRecord(cdRecords, ANDROID_MANIFEST_ZIP_ENTRY_NAME);
         if (androidManifestCdRecord == null) {
             throw new ApkFormatException("Missing " + ANDROID_MANIFEST_ZIP_ENTRY_NAME);
         }
@@ -731,6 +791,30 @@
     }
 
     /**
+     * Return list of pin patterns embedded in the pin pattern asset
+     * file.  If no such file, return {@code null}.
+     */
+    private static List<Pattern> extractPinPatterns(
+            List<CentralDirectoryRecord> cdRecords, DataSource lhfSection)
+                    throws IOException, ApkFormatException {
+        CentralDirectoryRecord pinListCdRecord =
+                findCdRecord(cdRecords, Hints.PIN_HINT_ASSET_ZIP_ENTRY_NAME);
+        List<Pattern> pinPatterns = null;
+        if (pinListCdRecord != null) {
+            pinPatterns = new ArrayList<>();
+            byte[] patternBlob;
+            try {
+                patternBlob = LocalFileRecord.getUncompressedData(
+                    lhfSection, pinListCdRecord, lhfSection.size());
+            } catch (ZipFormatException ex) {
+                throw new ApkFormatException("Bad " + pinListCdRecord);
+            }
+            pinPatterns = Hints.parsePinPatterns(patternBlob);
+        }
+        return pinPatterns;
+    }
+
+    /**
      * Returns the minimum Android version (API Level) supported by the provided APK. This is based
      * on the {@code android:minSdkVersion} attributes of the APK's {@code AndroidManifest.xml}.
      */
diff --git a/src/main/java/com/android/apksig/Hints.java b/src/main/java/com/android/apksig/Hints.java
new file mode 100644
index 0000000..49ef2b0
--- /dev/null
+++ b/src/main/java/com/android/apksig/Hints.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2018 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;
+import java.io.IOException;
+import java.io.DataOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+public final class Hints {
+    /**
+     * Name of hint pattern asset file in APK.
+     */
+    public static final String PIN_HINT_ASSET_ZIP_ENTRY_NAME = "assets/com.android.hints.pins.txt";
+
+    /**
+     * Name of hint byte range data file in APK.  Keep in sync with PinnerService.java.
+     */
+    public static final String PIN_BYTE_RANGE_ZIP_ENTRY_NAME = "pinlist.meta";
+
+    private static int clampToInt(long value) {
+        return (int) Math.max(0, Math.min(value, Integer.MAX_VALUE));
+    }
+
+    public static final class ByteRange {
+        final long start;
+        final long end;
+
+        public ByteRange(long start, long end) {
+            this.start = start;
+            this.end = end;
+        }
+    }
+
+    /**
+     * Create a blob of bytes that PinnerService understands as a
+     * sequence of byte ranges to pin.
+     */
+    public static byte[] encodeByteRangeList(List<ByteRange> pinByteRanges) {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream(pinByteRanges.size() * 8);
+        DataOutputStream out = new DataOutputStream(bos);
+        try {
+            for (ByteRange pinByteRange : pinByteRanges) {
+                out.writeInt(clampToInt(pinByteRange.start));
+                out.writeInt(clampToInt(pinByteRange.end - pinByteRange.start));
+            }
+        } catch (IOException ex) {
+            throw new AssertionError("impossible", ex);
+        }
+        return bos.toByteArray();
+    }
+
+    public static ArrayList<Pattern> parsePinPatterns(byte[] patternBlob) {
+        ArrayList<Pattern> pinPatterns = new ArrayList<>();
+        try {
+            for (String rawLine : new String(patternBlob, "UTF-8").split("\n")) {
+                String line = rawLine.replaceFirst("#.*", "");  // # starts a comment
+                if (!("".equals(line))) {
+                    pinPatterns.add(Pattern.compile(line));
+                }
+            }
+        } catch (UnsupportedEncodingException ex) {
+            throw new RuntimeException("UTF-8 must be supported", ex);
+        }
+        return pinPatterns;
+    }
+}