1 /*
2  * Copyright (C) 2020 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 com.android.tests.apex.host;
18 
19 import static com.google.common.truth.Truth.assertThat;
20 import static com.google.common.truth.Truth.assertWithMessage;
21 
22 import static org.junit.Assert.assertTrue;
23 import static org.junit.Assume.assumeTrue;
24 
25 import android.cts.install.lib.host.InstallUtilsHost;
26 import android.platform.test.annotations.LargeTest;
27 
28 import com.android.tradefed.device.DeviceNotAvailableException;
29 import com.android.tradefed.device.ITestDevice;
30 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
31 import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
32 import com.android.tradefed.util.CommandResult;
33 import com.android.tradefed.util.CommandStatus;
34 
35 import org.junit.After;
36 import org.junit.Before;
37 import org.junit.Test;
38 import org.junit.runner.RunWith;
39 
40 import java.io.File;
41 import java.nio.file.Files;
42 import java.nio.file.Paths;
43 import java.time.Duration;
44 import java.util.List;
45 import java.util.Optional;
46 import java.util.stream.Collectors;
47 
48 /**
49  * Test for platform support for Apex Compression feature
50  */
51 @RunWith(DeviceJUnit4ClassRunner.class)
52 public class ApexCompressionTests extends BaseHostJUnit4Test {
53     private static final String COMPRESSED_APEX_PACKAGE_NAME = "com.android.apex.compressed";
54     private static final String ORIGINAL_APEX_FILE_NAME =
55             COMPRESSED_APEX_PACKAGE_NAME + ".v1.apex";
56     private static final String DECOMPRESSED_DIR_PATH = "/data/apex/decompressed/";
57     private static final String APEX_ACTIVE_DIR = "/data/apex/active/";
58     private static final String OTA_RESERVED_DIR = "/data/apex/ota_reserved/";
59     private static final String DECOMPRESSED_APEX_SUFFIX = ".decompressed.apex";
60 
61     private final InstallUtilsHost mHostUtils = new InstallUtilsHost(this);
62     private boolean mWasAdbRoot = false;
63 
64     @Before
setUp()65     public void setUp() throws Exception {
66         mWasAdbRoot = getDevice().isAdbRoot();
67         if (!mWasAdbRoot) {
68             assumeTrue("Requires root", getDevice().enableAdbRoot());
69         }
70         deleteFiles("/system/apex/" + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
71                 APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
72                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
73                 OTA_RESERVED_DIR + "*");
74     }
75 
76     @After
tearDown()77     public void tearDown() throws Exception {
78         if (!mWasAdbRoot) {
79             getDevice().disableAdbRoot();
80         }
81         deleteFiles("/system/apex/" + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
82                 APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
83                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "*apex",
84                 OTA_RESERVED_DIR + "*");
85     }
86 
87     /**
88      * Runs the given phase of a test by calling into the device.
89      * Throws an exception if the test phase fails.
90      * <p>
91      * For example, <code>runPhase("testApkOnlyEnableRollback");</code>
92      */
runPhase(String phase)93     private void runPhase(String phase) throws Exception {
94         assertTrue(runDeviceTests("com.android.tests.apex.compression.app",
95                 "com.android.tests.apex.app.ApexCompressionTests",
96                 phase));
97     }
98 
99     /**
100      * Deletes files and reboots the device if necessary.
101      * @param files the paths of files which might contain wildcards
102      */
deleteFiles(String... files)103     private void deleteFiles(String... files) throws Exception {
104         boolean found = false;
105         for (String file : files) {
106             CommandResult result = getDevice().executeShellV2Command("ls " + file);
107             if (result.getStatus() == CommandStatus.SUCCESS) {
108                 found = true;
109                 break;
110             }
111         }
112 
113         if (found) {
114             getDevice().remountSystemWritable();
115             for (String file : files) {
116                 getDevice().executeShellCommand("rm -rf " + file);
117             }
118             getDevice().reboot();
119         }
120     }
121 
pushTestApex(final String fileName)122     private void pushTestApex(final String fileName) throws Exception {
123         final File apex = mHostUtils.getTestFile(fileName);
124         getDevice().remountSystemWritable();
125         assertTrue(getDevice().pushFile(apex, "/system/apex/" + fileName));
126         getDevice().reboot();
127     }
128 
getFilesInDir(String baseDir)129     private List<String> getFilesInDir(String baseDir) throws DeviceNotAvailableException {
130         return getDevice().getFileEntry(baseDir).getChildren(false)
131                 .stream().map(entry -> entry.getName())
132                 .collect(Collectors.toList());
133     }
134 
135     /**
136      * Returns the active apex info as optional.
137      */
getActiveApexInfo(String packageName)138     private Optional<ITestDevice.ApexInfo> getActiveApexInfo(String packageName)
139             throws DeviceNotAvailableException {
140         return getDevice().getActiveApexes().stream().filter(
141                 apex -> apex.name.equals(packageName)).findAny();
142     }
143 
144     @Test
145     @LargeTest
testDecompressedApexIsConsideredFactory()146     public void testDecompressedApexIsConsideredFactory() throws Exception {
147         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
148         runPhase("testDecompressedApexIsConsideredFactory");
149     }
150 
151     @Test
152     @LargeTest
testCompressedApexIsDecompressedAndActivated()153     public void testCompressedApexIsDecompressedAndActivated() throws Exception {
154         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
155 
156         // Ensure that compressed APEX was decompressed in DECOMPRESSED_DIR_PATH
157         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
158         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
159 
160         // Match the decompressed apex with original byte for byte
161         final File originalApex = mHostUtils.getTestFile(ORIGINAL_APEX_FILE_NAME);
162         final byte[] originalApexFileBytes = Files.readAllBytes(Paths.get(originalApex.toURI()));
163         final File decompressedFile = getDevice().pullFile(
164                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@1"
165                 + DECOMPRESSED_APEX_SUFFIX);
166         final byte[] decompressedFileBytes =
167                 Files.readAllBytes(Paths.get(decompressedFile.toURI()));
168         assertThat(decompressedFileBytes).isEqualTo(originalApexFileBytes);
169 
170         // The decompressed APEX should note be hard linked to APEX_ACTIVE_DIR
171         files = getFilesInDir(APEX_ACTIVE_DIR);
172         assertThat(files).doesNotContain(
173                 COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
174     }
175 
176     @Test
177     @LargeTest
testDecompressedApexSurvivesReboot()178     public void testDecompressedApexSurvivesReboot() throws Exception {
179         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
180 
181         // Ensure that compressed APEX was activated from DECOMPRESSED_DIR_PATH
182         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
183         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
184         final File decompressedFile = getDevice().pullFile(
185                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@1"
186                 + DECOMPRESSED_APEX_SUFFIX);
187         final byte[] decompressedFileBytes =
188                 Files.readAllBytes(Paths.get(decompressedFile.toURI()));
189 
190         getDevice().reboot();
191 
192         // Ensure it gets activated again on reboot
193         files = getFilesInDir(DECOMPRESSED_DIR_PATH);
194         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
195         final File decompressedFileAfterReboot = getDevice().pullFile(
196                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@1"
197                 + DECOMPRESSED_APEX_SUFFIX);
198         final byte[] decompressedFileBytesAfterReboot =
199                 Files.readAllBytes(Paths.get(decompressedFileAfterReboot.toURI()));
200         assertThat(decompressedFileBytes).isEqualTo(decompressedFileBytesAfterReboot);
201     }
202 
203     @Test
204     @LargeTest
testDecompressionDoesNotHappenOnEveryReboot()205     public void testDecompressionDoesNotHappenOnEveryReboot() throws Exception {
206         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
207 
208         final String decompressedApexFilePath = DECOMPRESSED_DIR_PATH
209                 + COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX;
210         String lastModifiedTime1 =
211                 getDevice().executeShellCommand("stat -c %Y " + decompressedApexFilePath);
212 
213         getDevice().reboot();
214         getDevice().waitForDeviceAvailable();
215 
216         String lastModifiedTime2 =
217                 getDevice().executeShellCommand("stat -c %Y " + decompressedApexFilePath);
218         assertThat(lastModifiedTime1).isEqualTo(lastModifiedTime2);
219     }
220 
221     @Test
222     @LargeTest
testHigherVersionOnSystemTriggerDecompression()223     public void testHigherVersionOnSystemTriggerDecompression() throws Exception {
224         // Install v1 on /system partition
225         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
226         // On boot, /data partition will have decompressed v1 APEX in it
227         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
228         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
229 
230         // Now replace /system APEX with v2
231         getDevice().remountSystemWritable();
232         getDevice().executeShellCommand("rm -rf /system/apex/"
233                 + COMPRESSED_APEX_PACKAGE_NAME + "*apex");
234         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v2.capex");
235 
236         // Ensure that v2 was decompressed
237         files = getFilesInDir(DECOMPRESSED_DIR_PATH);
238         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@2" + DECOMPRESSED_APEX_SUFFIX);
239     }
240 
241 
242     @Test
243     @LargeTest
testDifferentRootDigestTriggersDecompression()244     public void testDifferentRootDigestTriggersDecompression() throws Exception {
245         // Install v1 on /system partition
246         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
247         // On boot, /data partition will have decompressed v1 APEX in it
248         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
249         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
250         final File decompressedFile = getDevice().pullFile(
251                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@1"
252                 + DECOMPRESSED_APEX_SUFFIX);
253         final byte[] decompressedFileBytes =
254                 Files.readAllBytes(Paths.get(decompressedFile.toURI()));
255 
256         // Now replace /system APEX with same version but different root digest
257         getDevice().remountSystemWritable();
258         getDevice().executeShellCommand("rm -rf /system/apex/"
259                 + COMPRESSED_APEX_PACKAGE_NAME + "*apex");
260         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1_different_digest.capex");
261 
262         // Ensure that decompressed APEX is different than before
263         files = getFilesInDir(DECOMPRESSED_DIR_PATH);
264         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
265         final File decompressedFileAfterReboot = getDevice().pullFile(
266                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@1"
267                 + DECOMPRESSED_APEX_SUFFIX);
268         final byte[] decompressedFileBytesAfterReboot =
269                 Files.readAllBytes(Paths.get(decompressedFileAfterReboot.toURI()));
270         assertThat(decompressedFileBytes).isNotEqualTo(decompressedFileBytesAfterReboot);
271     }
272 
273     @Test
274     @LargeTest
testUnusedDecompressedApexIsCleanedUp_HigherVersion()275     public void testUnusedDecompressedApexIsCleanedUp_HigherVersion() throws Exception {
276         // Install v1 on /system partition
277         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
278         // Ensure that compressed APEX was decompressed in DECOMPRESSED_DIR_PATH
279         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
280         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
281 
282         // Now install an update for that APEX so that decompressed APEX becomes redundant
283         runPhase("testUnusedDecompressedApexIsCleanedUp_HigherVersion");
284         getDevice().reboot();
285 
286         // Verify that the decompressed APEX has been cleaned up
287         String filePath = Paths.get(DECOMPRESSED_DIR_PATH,
288                 COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX).toString();
289         mHostUtils.waitForFileDeleted(filePath, Duration.ofSeconds(15));
290     }
291 
292     @Test
293     @LargeTest
testUnusedDecompressedApexIsCleanedUp_SameVersion()294     public void testUnusedDecompressedApexIsCleanedUp_SameVersion() throws Exception {
295         // Install v1 on /system partition
296         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
297         // Ensure that compressed APEX was decompressed in DECOMPRESSED_DIR_PATH
298         List<String> files = getFilesInDir(DECOMPRESSED_DIR_PATH);
299         assertThat(files).contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
300 
301         // Now install an update for that APEX so that decompressed APEX becomes redundant
302         runPhase("testUnusedDecompressedApexIsCleanedUp_SameVersion");
303         getDevice().reboot();
304 
305         // Verify that the decompressed APEX has been cleaned up
306         String filePath = Paths.get(DECOMPRESSED_DIR_PATH,
307                 COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX).toString();
308         mHostUtils.waitForFileDeleted(filePath, Duration.ofSeconds(15));
309     }
310 
311     @Test
312     @LargeTest
testReservedSpaceIsNotCleanedOnReboot()313     public void testReservedSpaceIsNotCleanedOnReboot() throws Exception {
314         getDevice().executeShellCommand("touch " + OTA_RESERVED_DIR + "random");
315 
316         getDevice().reboot();
317 
318         List<String> files = getFilesInDir(OTA_RESERVED_DIR);
319         assertThat(files).hasSize(1);
320         assertThat(files).contains("random");
321     }
322 
323     @Test
324     @LargeTest
testReservedSpaceIsCleanedUpOnDecompression()325     public void testReservedSpaceIsCleanedUpOnDecompression() throws Exception {
326         getDevice().executeShellCommand("touch " + OTA_RESERVED_DIR + "random1");
327         getDevice().executeShellCommand("touch " + OTA_RESERVED_DIR + "random2");
328 
329         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
330 
331         assertThat(getFilesInDir(OTA_RESERVED_DIR)).isEmpty();
332     }
333 
334     @Test
335     @LargeTest
testFailsToActivateApexOnDataFallbacksToPreInstalled()336     public void testFailsToActivateApexOnDataFallbacksToPreInstalled() throws Exception {
337         // Need to make /system writable before pushing an apex to /data/apex/active/.
338         // Otherwise, `pushTestApex()` below reboots the device to make /system writable
339         // to push an apex to /system/apex. The data apex is removed during that reboot
340         // because it doesn't have a pre-installed system apex yet.
341         getDevice().remountSystemWritable();
342 
343         // Push a data apex that will fail to activate
344         final File file =
345                 mHostUtils.getTestFile("com.android.apex.compressed.v2_manifest_mismatch.apex");
346         getDevice().pushFile(file, APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "@2.apex");
347         // Push a CAPEX which should act as the fallback
348         // Note that this reboots the device.
349         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v2.capex");
350         assertWithMessage("Timed out waiting for device to boot").that(
351                 getDevice().waitForBootComplete(Duration.ofMinutes(2).toMillis())).isTrue();
352 
353         // After reboot pre-installed version of shim apex should be activated, and corrupted
354         // version on /data should be deleted.
355         final ITestDevice.ApexInfo activeApex =
356                 getActiveApexInfo(COMPRESSED_APEX_PACKAGE_NAME).get();
357         assertThat(activeApex.versionCode).isEqualTo(2);
358         assertThat(getDevice().doesFileExist(
359                 DECOMPRESSED_DIR_PATH + COMPRESSED_APEX_PACKAGE_NAME + "@2"
360                 + DECOMPRESSED_APEX_SUFFIX)).isTrue();
361         assertThat(getDevice().doesFileExist(
362                 APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "@2"
363                 + DECOMPRESSED_APEX_SUFFIX)).isFalse();
364         assertThat(getDevice().doesFileExist(
365                 APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "@2.apex")).isFalse();
366     }
367 
368     @Test
369     @LargeTest
testCapexToApexSwitch()370     public void testCapexToApexSwitch() throws Exception {
371         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
372         assertThat(getFilesInDir(DECOMPRESSED_DIR_PATH))
373             .contains(COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX);
374 
375         // Now replace the CAPEX with an uncompressed APEX
376         getDevice().remountSystemWritable();
377         getDevice().executeShellCommand("rm -rf /system/apex/"
378                 + COMPRESSED_APEX_PACKAGE_NAME + "*apex");
379         pushTestApex(ORIGINAL_APEX_FILE_NAME);
380         runPhase("testCapexToApexSwitch");
381 
382         // Ensure active apex is running from /system
383         final ITestDevice.ApexInfo activeApex = getActiveApexInfo(COMPRESSED_APEX_PACKAGE_NAME)
384                 .orElseThrow(() -> new AssertionError(
385                         "Can't find " + COMPRESSED_APEX_PACKAGE_NAME));
386         assertThat(activeApex.sourceDir).startsWith("/system");
387         // Ensure previous decompressed APEX has been cleaned up
388         String filePath = Paths.get(DECOMPRESSED_DIR_PATH,
389                 COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX).toString();
390         mHostUtils.waitForFileDeleted(filePath, Duration.ofSeconds(15));
391     }
392 
393     @Test
394     @LargeTest
testDecompressedApexVersionAlwaysHasSameVersionAsCapex()395     public void testDecompressedApexVersionAlwaysHasSameVersionAsCapex() throws Exception {
396         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v2.capex");
397         // Now replace /system APEX with v1
398         getDevice().remountSystemWritable();
399         getDevice().executeShellCommand("rm -rf /system/apex/"
400                 + COMPRESSED_APEX_PACKAGE_NAME + "*apex");
401         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
402         runPhase("testDecompressedApexVersionAlwaysHasSameVersionAsCapex");
403     }
404 
405     @Test
406     @LargeTest
testCompressedApexCanBeRolledBack()407     public void testCompressedApexCanBeRolledBack() throws Exception {
408         pushTestApex(COMPRESSED_APEX_PACKAGE_NAME + ".v1.capex");
409 
410         // Now install update with rollback
411         runPhase("testCompressedApexCanBeRolledBack_Commit");
412         getDevice().reboot();
413 
414         // Rollback the apex
415         runPhase("testCompressedApexCanBeRolledBack_Rollback");
416         getDevice().reboot();
417 
418         runPhase("testCompressedApexCanBeRolledBack_Verify");
419     }
420 
421     @Test
422     @LargeTest
testOrphanedDecompressedApexInActiveDirIsIgnored()423     public void testOrphanedDecompressedApexInActiveDirIsIgnored() throws Exception {
424         final File apex = mHostUtils.getTestFile(
425                 COMPRESSED_APEX_PACKAGE_NAME + ".v1.apex");
426         // Prepare an APEX in active directory with .decompressed.apex suffix.
427         // Place the same apex in system too. When booting, system APEX should
428         // be mounted while the decomrpessed APEX in active direcotyr should
429         // be ignored.
430         getDevice().remountSystemWritable();
431         assertTrue(getDevice().pushFile(apex,
432                 APEX_ACTIVE_DIR + COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX));
433         assertTrue(getDevice().pushFile(apex,
434                 "/system/apex/" + COMPRESSED_APEX_PACKAGE_NAME + ".v1.apex"));
435         getDevice().reboot();
436         // Ensure active apex is running from /system
437         final ITestDevice.ApexInfo activeApex = getActiveApexInfo(COMPRESSED_APEX_PACKAGE_NAME)
438                 .orElseThrow(() -> new AssertionError(
439                         "Can't find " + COMPRESSED_APEX_PACKAGE_NAME));
440         assertThat(activeApex.sourceDir).startsWith("/system");
441         // Ensure orphaned decompressed APEX has been cleaned up
442         String filePath = Paths.get(DECOMPRESSED_DIR_PATH,
443                 COMPRESSED_APEX_PACKAGE_NAME + "@1" + DECOMPRESSED_APEX_SUFFIX).toString();
444         mHostUtils.waitForFileDeleted(filePath, Duration.ofSeconds(15));
445     }
446 }
447 
448