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