• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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.updatablesystemfont;
18 
19 import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
20 
21 import static com.google.common.truth.Truth.assertThat;
22 
23 import static org.junit.Assert.assertThrows;
24 import static org.junit.Assume.assumeTrue;
25 
26 import static java.util.concurrent.TimeUnit.SECONDS;
27 
28 import android.app.UiAutomation;
29 import android.content.Context;
30 import android.graphics.fonts.FontFamilyUpdateRequest;
31 import android.graphics.fonts.FontFileUpdateRequest;
32 import android.graphics.fonts.FontManager;
33 import android.os.ParcelFileDescriptor;
34 import android.platform.test.annotations.RootPermissionTest;
35 import android.security.FileIntegrityManager;
36 import android.text.FontConfig;
37 import android.util.Log;
38 import android.util.Pair;
39 
40 import androidx.annotation.Nullable;
41 import androidx.test.ext.junit.runners.AndroidJUnit4;
42 import androidx.test.platform.app.InstrumentationRegistry;
43 import androidx.test.uiautomator.By;
44 import androidx.test.uiautomator.UiDevice;
45 import androidx.test.uiautomator.Until;
46 
47 import com.android.compatibility.common.util.StreamUtil;
48 import com.android.compatibility.common.util.SystemUtil;
49 
50 import org.junit.After;
51 import org.junit.Before;
52 import org.junit.Test;
53 import org.junit.runner.RunWith;
54 
55 import java.io.File;
56 import java.io.FileInputStream;
57 import java.io.FileOutputStream;
58 import java.io.IOException;
59 import java.io.InputStream;
60 import java.io.OutputStream;
61 import java.nio.file.Files;
62 import java.nio.file.Paths;
63 import java.util.ArrayList;
64 import java.util.Arrays;
65 import java.util.Collections;
66 import java.util.List;
67 import java.util.regex.Pattern;
68 
69 /**
70  * Tests if fonts can be updated by {@link FontManager} API.
71  */
72 @RootPermissionTest
73 @RunWith(AndroidJUnit4.class)
74 public class UpdatableSystemFontTest {
75 
76     private static final String TAG = "UpdatableSystemFontTest";
77     private static final String SYSTEM_FONTS_DIR = "/system/fonts/";
78     private static final String DATA_FONTS_DIR = "/data/fonts/files/";
79     private static final String CERT_PATH = "/data/local/tmp/UpdatableSystemFontTestCert.der";
80     private static final String NOTO_COLOR_EMOJI_POSTSCRIPT_NAME = "NotoColorEmoji";
81 
82     private static final String ORIGINAL_NOTO_COLOR_EMOJI_TTF =
83             "/data/local/tmp/NotoColorEmoji.ttf";
84     private static final String ORIGINAL_NOTO_COLOR_EMOJI_TTF_FSV_SIG =
85             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmoji.ttf.fsv_sig";
86     // A font with revision == 0.
87     private static final String TEST_NOTO_COLOR_EMOJI_V0_TTF =
88             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiV0.ttf";
89     private static final String TEST_NOTO_COLOR_EMOJI_V0_TTF_FSV_SIG =
90             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiV0.ttf.fsv_sig";
91     // A font with revision == original + 1
92     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF =
93             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiVPlus1.ttf";
94     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG =
95             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiVPlus1.ttf.fsv_sig";
96     // A font with revision == original + 2
97     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF =
98             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiVPlus2.ttf";
99     private static final String TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF_FSV_SIG =
100             "/data/local/tmp/UpdatableSystemFontTestNotoColorEmojiVPlus2.ttf.fsv_sig";
101 
102     private static final String EMOJI_RENDERING_TEST_APP_ID = "com.android.emojirenderingtestapp";
103     private static final String EMOJI_RENDERING_TEST_ACTIVITY =
104             EMOJI_RENDERING_TEST_APP_ID + "/.EmojiRenderingTestActivity";
105     private static final long ACTIVITY_TIMEOUT_MILLIS = SECONDS.toMillis(10);
106 
107     private static final String GET_AVAILABLE_FONTS_TEST_ACTIVITY =
108             EMOJI_RENDERING_TEST_APP_ID + "/.GetAvailableFontsTestActivity";
109 
110     private static final Pattern PATTERN_FONT_FILES = Pattern.compile("\\.(ttf|otf|ttc|otc)$");
111     private static final Pattern PATTERN_TMP_FILES = Pattern.compile("^/data/local/tmp/");
112     private static final Pattern PATTERN_DATA_FONT_FILES = Pattern.compile("^/data/fonts/files/");
113     private static final Pattern PATTERN_SYSTEM_FONT_FILES =
114             Pattern.compile("^/(system|product)/fonts/");
115 
116     private String mKeyId;
117     private FontManager mFontManager;
118     private UiDevice mUiDevice;
119 
120     @Before
setUp()121     public void setUp() throws Exception {
122         Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
123         // Run tests only if updatable system font is enabled.
124         FileIntegrityManager fim = context.getSystemService(FileIntegrityManager.class);
125         assumeTrue(fim != null);
126         assumeTrue(fim.isApkVeritySupported());
127         mKeyId = insertCert(CERT_PATH);
128         mFontManager = context.getSystemService(FontManager.class);
129         expectCommandToSucceed("cmd font clear");
130         mUiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
131     }
132 
133     @After
tearDown()134     public void tearDown() throws Exception {
135         // Ignore errors because this may fail if updatable system font is not enabled.
136         runShellCommand("cmd font clear", null);
137         if (mKeyId != null) {
138             expectCommandToSucceed("mini-keyctl unlink " + mKeyId + " .fs-verity");
139         }
140     }
141 
142     @Test
updateFont()143     public void updateFont() throws Exception {
144         assertThat(updateFontFile(
145                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
146                 .isEqualTo(FontManager.RESULT_SUCCESS);
147         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
148         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
149         // The updated font should be readable and unmodifiable.
150         expectCommandToSucceed("dd status=none if=" + fontPath + " of=/dev/null");
151         expectCommandToFail("dd status=none if=" + CERT_PATH + " of=" + fontPath);
152     }
153 
154     @Test
updateFont_twice()155     public void updateFont_twice() throws Exception {
156         assertThat(updateFontFile(
157                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
158                 .isEqualTo(FontManager.RESULT_SUCCESS);
159         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
160         assertThat(updateFontFile(
161                 TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF_FSV_SIG))
162                 .isEqualTo(FontManager.RESULT_SUCCESS);
163         String fontPath2 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
164         assertThat(fontPath2).startsWith(DATA_FONTS_DIR);
165         assertThat(fontPath2).isNotEqualTo(fontPath);
166         // The new file should be readable.
167         expectCommandToSucceed("dd status=none if=" + fontPath2 + " of=/dev/null");
168         // The old file should be still readable.
169         expectCommandToSucceed("dd status=none if=" + fontPath + " of=/dev/null");
170     }
171 
172     @Test
updateFont_allowSameVersion()173     public void updateFont_allowSameVersion() throws Exception {
174         // Update original font to the same version
175         assertThat(updateFontFile(
176                 ORIGINAL_NOTO_COLOR_EMOJI_TTF, ORIGINAL_NOTO_COLOR_EMOJI_TTF_FSV_SIG))
177                 .isEqualTo(FontManager.RESULT_SUCCESS);
178         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
179         assertThat(updateFontFile(
180                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
181                 .isEqualTo(FontManager.RESULT_SUCCESS);
182         String fontPath2 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
183         // Update updated font to the same version
184         assertThat(updateFontFile(
185                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
186                 .isEqualTo(FontManager.RESULT_SUCCESS);
187         String fontPath3 = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
188         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
189         assertThat(fontPath2).isNotEqualTo(fontPath);
190         assertThat(fontPath2).startsWith(DATA_FONTS_DIR);
191         assertThat(fontPath3).startsWith(DATA_FONTS_DIR);
192         assertThat(fontPath3).isNotEqualTo(fontPath);
193     }
194 
195     @Test
updateFont_invalidCert()196     public void updateFont_invalidCert() throws Exception {
197         assertThat(updateFontFile(
198                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF_FSV_SIG))
199                 .isEqualTo(FontManager.RESULT_ERROR_VERIFICATION_FAILURE);
200     }
201 
202     @Test
updateFont_downgradeFromSystem()203     public void updateFont_downgradeFromSystem() throws Exception {
204         assertThat(updateFontFile(
205                 TEST_NOTO_COLOR_EMOJI_V0_TTF, TEST_NOTO_COLOR_EMOJI_V0_TTF_FSV_SIG))
206                 .isEqualTo(FontManager.RESULT_ERROR_DOWNGRADING);
207     }
208 
209     @Test
updateFont_downgradeFromData()210     public void updateFont_downgradeFromData() throws Exception {
211         assertThat(updateFontFile(
212                 TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS2_TTF_FSV_SIG))
213                 .isEqualTo(FontManager.RESULT_SUCCESS);
214         assertThat(updateFontFile(
215                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
216                 .isEqualTo(FontManager.RESULT_ERROR_DOWNGRADING);
217     }
218 
219     @Test
launchApp()220     public void launchApp() throws Exception {
221         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
222         assertThat(fontPath).startsWith(SYSTEM_FONTS_DIR);
223         startActivity(EMOJI_RENDERING_TEST_APP_ID, EMOJI_RENDERING_TEST_ACTIVITY);
224         SystemUtil.eventually(
225                 () -> assertThat(isFileOpenedBy(fontPath, EMOJI_RENDERING_TEST_APP_ID)).isTrue(),
226                 ACTIVITY_TIMEOUT_MILLIS);
227     }
228 
229     @Test
launchApp_afterUpdateFont()230     public void launchApp_afterUpdateFont() throws Exception {
231         String originalFontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
232         assertThat(originalFontPath).startsWith(SYSTEM_FONTS_DIR);
233         assertThat(updateFontFile(
234                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
235                 .isEqualTo(FontManager.RESULT_SUCCESS);
236         String updatedFontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
237         assertThat(updatedFontPath).startsWith(DATA_FONTS_DIR);
238         startActivity(EMOJI_RENDERING_TEST_APP_ID, EMOJI_RENDERING_TEST_ACTIVITY);
239         // The original font should NOT be opened by the app.
240         SystemUtil.eventually(() -> {
241             assertThat(isFileOpenedBy(updatedFontPath, EMOJI_RENDERING_TEST_APP_ID)).isTrue();
242             assertThat(isFileOpenedBy(originalFontPath, EMOJI_RENDERING_TEST_APP_ID)).isFalse();
243         }, ACTIVITY_TIMEOUT_MILLIS);
244     }
245 
246     @Test
reboot()247     public void reboot() throws Exception {
248         expectCommandToSucceed(String.format("cmd font update %s %s",
249                 TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG));
250         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
251         assertThat(fontPath).startsWith(DATA_FONTS_DIR);
252 
253         // Emulate reboot by 'cmd font restart'.
254         expectCommandToSucceed("cmd font restart");
255         String fontPathAfterReboot = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
256         assertThat(fontPathAfterReboot).isEqualTo(fontPath);
257     }
258 
259     @Test
fdLeakTest()260     public void fdLeakTest() throws Exception {
261         long originalOpenFontCount =
262                 countMatch(getOpenFiles("system_server"), PATTERN_FONT_FILES);
263         Pattern patternEmojiVPlus1 =
264                 Pattern.compile(Pattern.quote(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF));
265         for (int i = 0; i < 10; i++) {
266             assertThat(updateFontFile(
267                     TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF, TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG))
268                     .isEqualTo(FontManager.RESULT_SUCCESS);
269             List<String> openFiles = getOpenFiles("system_server");
270             for (Pattern p : Arrays.asList(PATTERN_FONT_FILES, PATTERN_SYSTEM_FONT_FILES,
271                     PATTERN_DATA_FONT_FILES, PATTERN_TMP_FILES)) {
272                 Log.i(TAG, String.format("num of %s: %d", p, countMatch(openFiles, p)));
273             }
274             // system_server should not keep /data/fonts files open.
275             assertThat(countMatch(openFiles, PATTERN_DATA_FONT_FILES)).isEqualTo(0);
276             // system_server should not keep passed FD open.
277             assertThat(countMatch(openFiles, patternEmojiVPlus1)).isEqualTo(0);
278             // The number of open font FD should not increase.
279             assertThat(countMatch(openFiles, PATTERN_FONT_FILES))
280                     .isAtMost(originalOpenFontCount);
281         }
282     }
283 
284     @Test
fdLeakTest_withoutPermission()285     public void fdLeakTest_withoutPermission() throws Exception {
286         Pattern patternEmojiVPlus1 =
287                 Pattern.compile(Pattern.quote(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF));
288         byte[] signature = Files.readAllBytes(Paths.get(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF_FSV_SIG));
289         try (ParcelFileDescriptor fd = ParcelFileDescriptor.open(
290                 new File(TEST_NOTO_COLOR_EMOJI_VPLUS1_TTF), MODE_READ_ONLY)) {
291             assertThrows(SecurityException.class,
292                     () -> updateFontFileWithoutPermission(fd, signature, 0));
293         }
294         List<String> openFiles = getOpenFiles("system_server");
295         assertThat(countMatch(openFiles, patternEmojiVPlus1)).isEqualTo(0);
296     }
297 
298     @Test
getAvailableFonts()299     public void getAvailableFonts() throws Exception {
300         String fontPath = getFontPath(NOTO_COLOR_EMOJI_POSTSCRIPT_NAME);
301         startActivity(EMOJI_RENDERING_TEST_APP_ID, GET_AVAILABLE_FONTS_TEST_ACTIVITY);
302         // GET_AVAILABLE_FONTS_TEST_ACTIVITY shows the NotoColorEmoji path it got.
303         mUiDevice.wait(
304                 Until.findObject(By.pkg(EMOJI_RENDERING_TEST_APP_ID).text(fontPath)),
305                 ACTIVITY_TIMEOUT_MILLIS);
306         // The font file should not be opened just by querying the path using
307         // SystemFont.getAvailableFonts().
308         assertThat(isFileOpenedBy(fontPath, EMOJI_RENDERING_TEST_APP_ID)).isFalse();
309     }
310 
insertCert(String certPath)311     private static String insertCert(String certPath) throws Exception {
312         Pair<String, String> result;
313         try (InputStream is = new FileInputStream(certPath)) {
314             result = runShellCommand("mini-keyctl padd asymmetric fsv_test .fs-verity", is);
315         }
316         // Assert that there are no errors.
317         assertThat(result.second).isEmpty();
318         String keyId = result.first.trim();
319         assertThat(keyId).matches("^\\d+$");
320         return keyId;
321     }
322 
updateFontFile(String fontPath, String signaturePath)323     private int updateFontFile(String fontPath, String signaturePath) throws IOException {
324         byte[] signature = Files.readAllBytes(Paths.get(signaturePath));
325         try (ParcelFileDescriptor fd =
326                 ParcelFileDescriptor.open(new File(fontPath), MODE_READ_ONLY)) {
327             return SystemUtil.runWithShellPermissionIdentity(() -> {
328                 int configVersion = mFontManager.getFontConfig().getConfigVersion();
329                 return updateFontFileWithoutPermission(fd, signature, configVersion);
330             });
331         }
332     }
333 
updateFontFileWithoutPermission(ParcelFileDescriptor fd, byte[] signature, int configVersion)334     private int updateFontFileWithoutPermission(ParcelFileDescriptor fd, byte[] signature,
335             int configVersion) {
336         return mFontManager.updateFontFamily(
337                 new FontFamilyUpdateRequest.Builder()
338                         .addFontFileUpdateRequest(new FontFileUpdateRequest(fd, signature))
339                         .build(),
340                 configVersion);
341     }
342 
getFontPath(String psName)343     private String getFontPath(String psName) {
344         return SystemUtil.runWithShellPermissionIdentity(() -> {
345             FontConfig fontConfig = mFontManager.getFontConfig();
346             for (FontConfig.FontFamily family : fontConfig.getFontFamilies()) {
347                 for (FontConfig.Font font : family.getFontList()) {
348                     if (psName.equals(font.getPostScriptName())) {
349                         return font.getFile().getAbsolutePath();
350                     }
351                 }
352             }
353             throw new AssertionError("Font not found: " + psName);
354         });
355     }
356 
357     private static void startActivity(String appId, String activityId) throws Exception {
358         expectCommandToSucceed("am force-stop " + appId);
359         expectCommandToSucceed("am start-activity -n " + activityId);
360     }
361 
362     private static String expectCommandToSucceed(String cmd) throws IOException {
363         Pair<String, String> result = runShellCommand(cmd, null);
364         // UiAutomation.runShellCommand() does not return exit code.
365         // Assume that the command fails if stderr is not empty.
366         assertThat(result.second.trim()).isEmpty();
367         return result.first;
368     }
369 
370     private static void expectCommandToFail(String cmd) throws IOException {
371         Pair<String, String> result = runShellCommand(cmd, null);
372         // UiAutomation.runShellCommand() does not return exit code.
373         // Assume that the command fails if stderr is not empty.
374         assertThat(result.second.trim()).isNotEmpty();
375     }
376 
377     /** Runs a command and returns (stdout, stderr). */
378     private static Pair<String, String> runShellCommand(String cmd, @Nullable InputStream input)
379             throws IOException  {
380         Log.i(TAG, "runShellCommand: " + cmd);
381         UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
382         ParcelFileDescriptor[] rwe = automation.executeShellCommandRwe(cmd);
383         // executeShellCommandRwe returns [stdout, stdin, stderr].
384         try (ParcelFileDescriptor outFd = rwe[0];
385              ParcelFileDescriptor inFd = rwe[1];
386              ParcelFileDescriptor errFd = rwe[2]) {
387             if (input != null) {
388                 try (OutputStream os = new FileOutputStream(inFd.getFileDescriptor())) {
389                     StreamUtil.copyStreams(input, os);
390                 }
391             }
392             // We have to close stdin before reading stdout and stderr.
393             // It's safe to close ParcelFileDescriptor multiple times.
394             inFd.close();
395             String stdout;
396             try (InputStream is = new FileInputStream(outFd.getFileDescriptor())) {
397                 stdout = StreamUtil.readInputStream(is);
398             }
399             Log.i(TAG, "stdout =  " + stdout);
400             String stderr;
401             try (InputStream is = new FileInputStream(errFd.getFileDescriptor())) {
402                 stderr = StreamUtil.readInputStream(is);
403             }
404             Log.i(TAG, "stderr =  " + stderr);
405             return new Pair<>(stdout, stderr);
406         }
407     }
408 
409     private static boolean isFileOpenedBy(String path, String appId) throws Exception {
410         String pid = pidOf(appId);
411         if (pid.isEmpty()) {
412             return false;
413         }
414         String cmd = String.format("lsof -t -p %s %s", pid, path);
415         return !expectCommandToSucceed(cmd).trim().isEmpty();
416     }
417 
418     private static List<String> getOpenFiles(String appId) throws Exception {
419         String pid = pidOf(appId);
420         if (pid.isEmpty()) {
421             return Collections.emptyList();
422         }
423         String cmd = String.format("lsof -p %s", pid);
424         String out = expectCommandToSucceed(cmd);
425         List<String> paths = new ArrayList<>();
426         boolean first = true;
427         for (String line : out.split("\n")) {
428             // Skip the header.
429             if (first) {
430                 first = false;
431                 continue;
432             }
433             String[] records = line.split(" ");
434             if (records.length > 0) {
435                 paths.add(records[records.length - 1]);
436             }
437         }
438         return paths;
439     }
440 
441     private static String pidOf(String appId) throws Exception {
442         return expectCommandToSucceed("pidof " + appId).trim();
443     }
444 
445     private static long countMatch(List<String> paths, Pattern pattern) {
446         // Note: asPredicate() returns true for partial matching.
447         return paths.stream()
448                 .filter(pattern.asPredicate())
449                 .count();
450     }
451 }
452