1 /**
2  * Copyright 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.content.pm.dex;
18 
19 import static android.content.pm.PackageManager.INSTALL_FAILED_BAD_DEX_METADATA;
20 import static android.content.pm.parsing.ApkLiteParseUtils.APK_FILE_EXTENSION;
21 
22 import android.content.pm.parsing.ApkLiteParseUtils;
23 import android.content.pm.parsing.PackageLite;
24 import android.content.pm.parsing.result.ParseInput;
25 import android.content.pm.parsing.result.ParseResult;
26 import android.os.SystemProperties;
27 import android.util.ArrayMap;
28 import android.util.JsonReader;
29 import android.util.Log;
30 import android.util.jar.StrictJarFile;
31 
32 import com.android.internal.annotations.VisibleForTesting;
33 import com.android.internal.security.VerityUtils;
34 
35 import java.io.File;
36 import java.io.IOException;
37 import java.io.InputStream;
38 import java.io.InputStreamReader;
39 import java.io.UnsupportedEncodingException;
40 import java.nio.file.Files;
41 import java.nio.file.Paths;
42 import java.util.ArrayList;
43 import java.util.Collection;
44 import java.util.List;
45 import java.util.Map;
46 import java.util.zip.ZipEntry;
47 
48 /**
49  * Helper class used to compute and validate the location of dex metadata files.
50  *
51  * @hide
52  */
53 public class DexMetadataHelper {
54     public static final String TAG = "DexMetadataHelper";
55     /** $> adb shell 'setprop log.tag.DexMetadataHelper VERBOSE' */
56     public static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
57     /** $> adb shell 'setprop pm.dexopt.dm.require_manifest true' */
58     private static final String PROPERTY_DM_JSON_MANIFEST_REQUIRED =
59             "pm.dexopt.dm.require_manifest";
60     /** $> adb shell 'setprop pm.dexopt.dm.require_fsverity true' */
61     private static final String PROPERTY_DM_FSVERITY_REQUIRED = "pm.dexopt.dm.require_fsverity";
62 
63     private static final String DEX_METADATA_FILE_EXTENSION = ".dm";
64 
DexMetadataHelper()65     private DexMetadataHelper() {}
66 
67     /** Return true if the given file is a dex metadata file. */
isDexMetadataFile(File file)68     public static boolean isDexMetadataFile(File file) {
69         return isDexMetadataPath(file.getName());
70     }
71 
72     /** Return true if the given path is a dex metadata path. */
isDexMetadataPath(String path)73     private static boolean isDexMetadataPath(String path) {
74         return path.endsWith(DEX_METADATA_FILE_EXTENSION);
75     }
76 
77     /**
78      * Returns whether fs-verity is required to install a dex metadata
79      */
isFsVerityRequired()80     public static boolean isFsVerityRequired() {
81         return VerityUtils.isFsVeritySupported()
82                 && SystemProperties.getBoolean(PROPERTY_DM_FSVERITY_REQUIRED, false);
83     }
84 
85     /**
86      * Return the size (in bytes) of all dex metadata files associated with the given package.
87      */
getPackageDexMetadataSize(PackageLite pkg)88     public static long getPackageDexMetadataSize(PackageLite pkg) {
89         long sizeBytes = 0;
90         Collection<String> dexMetadataList = DexMetadataHelper.getPackageDexMetadata(pkg).values();
91         for (String dexMetadata : dexMetadataList) {
92             sizeBytes += new File(dexMetadata).length();
93         }
94         return sizeBytes;
95     }
96 
97     /**
98      * Search for the dex metadata file associated with the given target file.
99      * If it exists, the method returns the dex metadata file; otherwise it returns null.
100      *
101      * Note that this performs a loose matching suitable to be used in the InstallerSession logic.
102      * i.e. the method will attempt to match the {@code dmFile} regardless of {@code targetFile}
103      * extension (e.g. 'foo.dm' will match 'foo' or 'foo.apk').
104      */
findDexMetadataForFile(File targetFile)105     public static File findDexMetadataForFile(File targetFile) {
106         String dexMetadataPath = buildDexMetadataPathForFile(targetFile);
107         File dexMetadataFile = new File(dexMetadataPath);
108         return dexMetadataFile.exists() ? dexMetadataFile : null;
109     }
110 
111     /**
112      * Return the dex metadata files for the given package as a map
113      * [code path -> dex metadata path].
114      *
115      * NOTE: involves I/O checks.
116      */
getPackageDexMetadata(PackageLite pkg)117     private static Map<String, String> getPackageDexMetadata(PackageLite pkg) {
118         return buildPackageApkToDexMetadataMap(pkg.getAllApkPaths());
119     }
120 
121     /**
122      * Look up the dex metadata files for the given code paths building the map
123      * [code path -> dex metadata].
124      *
125      * For each code path (.apk) the method checks if a matching dex metadata file (.dm) exists.
126      * If it does it adds the pair to the returned map.
127      *
128      * Note that this method will do a loose
129      * matching based on the extension ('foo.dm' will match 'foo.apk' or 'foo').
130      *
131      * This should only be used for code paths extracted from a package structure after the naming
132      * was enforced in the installer.
133      */
buildPackageApkToDexMetadataMap( List<String> codePaths)134     public static Map<String, String> buildPackageApkToDexMetadataMap(
135             List<String> codePaths) {
136         ArrayMap<String, String> result = new ArrayMap<>();
137         for (int i = codePaths.size() - 1; i >= 0; i--) {
138             String codePath = codePaths.get(i);
139             String dexMetadataPath = buildDexMetadataPathForFile(new File(codePath));
140 
141             if (Files.exists(Paths.get(dexMetadataPath))) {
142                 result.put(codePath, dexMetadataPath);
143             }
144         }
145 
146         return result;
147     }
148 
149     /**
150      * Return the dex metadata path associated with the given code path.
151      * (replaces '.apk' extension with '.dm')
152      *
153      * @throws IllegalArgumentException if the code path is not an .apk.
154      */
buildDexMetadataPathForApk(String codePath)155     public static String buildDexMetadataPathForApk(String codePath) {
156         if (!ApkLiteParseUtils.isApkPath(codePath)) {
157             throw new IllegalStateException(
158                     "Corrupted package. Code path is not an apk " + codePath);
159         }
160         return codePath.substring(0, codePath.length() - APK_FILE_EXTENSION.length())
161                 + DEX_METADATA_FILE_EXTENSION;
162     }
163 
164     /**
165      * Return the dex metadata path corresponding to the given {@code targetFile} using a loose
166      * matching.
167      * i.e. the method will attempt to match the {@code dmFile} regardless of {@code targetFile}
168      * extension (e.g. 'foo.dm' will match 'foo' or 'foo.apk').
169      */
buildDexMetadataPathForFile(File targetFile)170     private static String buildDexMetadataPathForFile(File targetFile) {
171         return ApkLiteParseUtils.isApkFile(targetFile)
172                 ? buildDexMetadataPathForApk(targetFile.getPath())
173                 : targetFile.getPath() + DEX_METADATA_FILE_EXTENSION;
174     }
175 
176     /**
177      * Validate that the given file is a dex metadata archive.
178      * This is just a validation that the file is a zip archive that contains a manifest.json
179      * with the package name and version code.
180      */
validateDexMetadataFile(ParseInput input, String dmaPath, String packageName, long versionCode)181     public static ParseResult validateDexMetadataFile(ParseInput input, String dmaPath,
182             String packageName, long versionCode) {
183         return validateDexMetadataFile(input, dmaPath, packageName, versionCode,
184                SystemProperties.getBoolean(PROPERTY_DM_JSON_MANIFEST_REQUIRED, false));
185     }
186 
187     @VisibleForTesting
validateDexMetadataFile(ParseInput input, String dmaPath, String packageName, long versionCode, boolean requireManifest)188     public static ParseResult validateDexMetadataFile(ParseInput input, String dmaPath,
189             String packageName, long versionCode, boolean requireManifest) {
190         StrictJarFile jarFile = null;
191 
192         if (DEBUG) {
193             Log.v(TAG, "validateDexMetadataFile: " + dmaPath + ", " + packageName +
194                     ", " + versionCode);
195         }
196 
197         try {
198             jarFile = new StrictJarFile(dmaPath, false, false);
199             return validateDexMetadataManifest(input, dmaPath, jarFile, packageName, versionCode,
200                     requireManifest);
201         } catch (IOException e) {
202             return input.error(INSTALL_FAILED_BAD_DEX_METADATA, "Error opening " + dmaPath, e);
203         } finally {
204             if (jarFile != null) {
205                 try {
206                     jarFile.close();
207                 } catch (IOException ignored) {
208                 }
209             }
210         }
211     }
212 
213     /** Ensure that packageName and versionCode match the manifest.json in the .dm file */
validateDexMetadataManifest(ParseInput input, String dmaPath, StrictJarFile jarFile, String packageName, long versionCode, boolean requireManifest)214     private static ParseResult validateDexMetadataManifest(ParseInput input, String dmaPath,
215             StrictJarFile jarFile, String packageName, long versionCode, boolean requireManifest)
216             throws IOException {
217         if (!requireManifest) {
218             if (DEBUG) {
219                 Log.v(TAG, "validateDexMetadataManifest: " + dmaPath
220                         + " manifest.json check skipped");
221             }
222             return input.success(null);
223         }
224 
225         ZipEntry zipEntry = jarFile.findEntry("manifest.json");
226         if (zipEntry == null) {
227             return input.error(INSTALL_FAILED_BAD_DEX_METADATA,
228                     "Missing manifest.json in " + dmaPath);
229         }
230         InputStream inputStream = jarFile.getInputStream(zipEntry);
231 
232         JsonReader reader;
233         try {
234           reader = new JsonReader(new InputStreamReader(inputStream, "UTF-8"));
235         } catch (UnsupportedEncodingException e) {
236             return input.error(INSTALL_FAILED_BAD_DEX_METADATA,
237                     "Error opening manifest.json in " + dmaPath, e);
238         }
239         String jsonPackageName = null;
240         long jsonVersionCode = -1;
241 
242         reader.beginObject();
243         while (reader.hasNext()) {
244             String name = reader.nextName();
245             if (name.equals("packageName")) {
246                 jsonPackageName = reader.nextString();
247             } else if (name.equals("versionCode")) {
248                 jsonVersionCode = reader.nextLong();
249             } else {
250                 reader.skipValue();
251             }
252         }
253         reader.endObject();
254 
255         if (jsonPackageName == null || jsonVersionCode == -1) {
256             return input.error(INSTALL_FAILED_BAD_DEX_METADATA,
257                     "manifest.json in " + dmaPath
258                     + " is missing 'packageName' and/or 'versionCode'");
259         }
260 
261         if (!jsonPackageName.equals(packageName)) {
262             return input.error(INSTALL_FAILED_BAD_DEX_METADATA,
263                     "manifest.json in " + dmaPath + " has invalid packageName: " + jsonPackageName
264                     + ", expected: " + packageName);
265         }
266 
267         if (versionCode != jsonVersionCode) {
268             return input.error(INSTALL_FAILED_BAD_DEX_METADATA,
269                     "manifest.json in " + dmaPath + " has invalid versionCode: " + jsonVersionCode
270                     + ", expected: " + versionCode);
271         }
272 
273         if (DEBUG) {
274             Log.v(TAG, "validateDexMetadataManifest: " + dmaPath + ", " + packageName +
275                     ", " + versionCode + ": successful");
276         }
277         return input.success(null);
278     }
279 
280     /**
281      * Validates that all dex metadata paths in the given list have a matching apk.
282      * (for any foo.dm there should be either a 'foo' of a 'foo.apk' file).
283      * If that's not the case it throws {@code IllegalStateException}.
284      *
285      * This is used to perform a basic check during adb install commands.
286      * (The installer does not support stand alone .dm files)
287      */
validateDexPaths(String[] paths)288     public static void validateDexPaths(String[] paths) {
289         ArrayList<String> apks = new ArrayList<>();
290         for (int i = 0; i < paths.length; i++) {
291             if (ApkLiteParseUtils.isApkPath(paths[i])) {
292                 apks.add(paths[i]);
293             }
294         }
295         ArrayList<String> unmatchedDmFiles = new ArrayList<>();
296         for (int i = 0; i < paths.length; i++) {
297             String dmPath = paths[i];
298             if (isDexMetadataPath(dmPath)) {
299                 boolean valid = false;
300                 for (int j = apks.size() - 1; j >= 0; j--) {
301                     if (dmPath.equals(buildDexMetadataPathForFile(new File(apks.get(j))))) {
302                         valid = true;
303                         break;
304                     }
305                 }
306                 if (!valid) {
307                     unmatchedDmFiles.add(dmPath);
308                 }
309             }
310         }
311         if (!unmatchedDmFiles.isEmpty()) {
312             throw new IllegalStateException("Unmatched .dm files: " + unmatchedDmFiles);
313         }
314     }
315 
316 }
317