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.virt.fs; 18 19 import static org.junit.Assert.assertEquals; 20 import static org.junit.Assert.assertFalse; 21 import static org.junit.Assert.assertNotEquals; 22 import static org.junit.Assert.assertTrue; 23 24 import android.platform.test.annotations.RootPermissionTest; 25 26 import com.android.compatibility.common.util.PollingCheck; 27 import com.android.tradefed.device.DeviceNotAvailableException; 28 import com.android.tradefed.device.ITestDevice; 29 import com.android.tradefed.log.LogUtil.CLog; 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.util.concurrent.ExecutorService; 41 import java.util.concurrent.Executors; 42 43 @RootPermissionTest 44 @RunWith(DeviceJUnit4ClassRunner.class) 45 public final class AuthFsHostTest extends BaseHostJUnit4Test { 46 47 /** Test directory where data are located */ 48 private static final String TEST_DIR = "/data/local/tmp/authfs"; 49 50 /** Mount point of authfs during the test */ 51 private static final String MOUNT_DIR = "/data/local/tmp/authfs/mnt"; 52 53 private static final String FD_SERVER_BIN = "/apex/com.android.virt/bin/fd_server"; 54 private static final String AUTHFS_BIN = "/apex/com.android.virt/bin/authfs"; 55 56 /** Plenty of time for authfs to get ready */ 57 private static final int AUTHFS_INIT_TIMEOUT_MS = 1500; 58 59 /** FUSE's magic from statfs(2) */ 60 private static final String FUSE_SUPER_MAGIC_HEX = "65735546"; 61 62 private ITestDevice mDevice; 63 private ExecutorService mThreadPool; 64 65 @Before setUp()66 public void setUp() { 67 mDevice = getDevice(); 68 mThreadPool = Executors.newCachedThreadPool(); 69 } 70 71 @After tearDown()72 public void tearDown() throws DeviceNotAvailableException { 73 mDevice.executeShellV2Command("killall authfs fd_server"); 74 mDevice.executeShellV2Command("umount " + MOUNT_DIR); 75 mDevice.executeShellV2Command("rm -f " + TEST_DIR + "/output"); 76 } 77 78 @Test testReadWithFsverityVerification_LocalFile()79 public void testReadWithFsverityVerification_LocalFile() 80 throws DeviceNotAvailableException, InterruptedException { 81 // Setup 82 runAuthFsInBackground( 83 "--local-ro-file-unverified 3:input.4m" 84 + " --local-ro-file 4:input.4m:input.4m.merkle_dump:input.4m.fsv_sig:cert.der" 85 + " --local-ro-file 5:input.4k1:input.4k1.merkle_dump:input.4k1.fsv_sig:cert.der" 86 + " --local-ro-file 6:input.4k:input.4k.merkle_dump:input.4k.fsv_sig:cert.der" 87 ); 88 89 // Action 90 String actualHashUnverified4m = computeFileHashInGuest(MOUNT_DIR + "/3"); 91 String actualHash4m = computeFileHashInGuest(MOUNT_DIR + "/4"); 92 String actualHash4k1 = computeFileHashInGuest(MOUNT_DIR + "/5"); 93 String actualHash4k = computeFileHashInGuest(MOUNT_DIR + "/6"); 94 95 // Verify 96 String expectedHash4m = computeFileHash(TEST_DIR + "/input.4m"); 97 String expectedHash4k1 = computeFileHash(TEST_DIR + "/input.4k1"); 98 String expectedHash4k = computeFileHash(TEST_DIR + "/input.4k"); 99 100 assertEquals("Inconsistent hash from /authfs/3: ", expectedHash4m, actualHashUnverified4m); 101 assertEquals("Inconsistent hash from /authfs/4: ", expectedHash4m, actualHash4m); 102 assertEquals("Inconsistent hash from /authfs/5: ", expectedHash4k1, actualHash4k1); 103 assertEquals("Inconsistent hash from /authfs/6: ", expectedHash4k, actualHash4k); 104 } 105 106 @Test testReadWithFsverityVerification_RemoteFile()107 public void testReadWithFsverityVerification_RemoteFile() 108 throws DeviceNotAvailableException, InterruptedException { 109 // Setup 110 runFdServerInBackground( 111 "3<input.4m 4<input.4m.merkle_dump 5<input.4m.fsv_sig 6<input.4m", 112 "--ro-fds 3:4:5 --ro-fds 6" 113 ); 114 runAuthFsInBackground( 115 "--remote-ro-file-unverified 10:6:4194304 --remote-ro-file 11:3:4194304:cert.der" 116 ); 117 118 // Action 119 String actualHashUnverified4m = computeFileHashInGuest(MOUNT_DIR + "/10"); 120 String actualHash4m = computeFileHashInGuest(MOUNT_DIR + "/11"); 121 122 // Verify 123 String expectedHash4m = computeFileHash(TEST_DIR + "/input.4m"); 124 125 assertEquals("Inconsistent hash from /authfs/10: ", expectedHash4m, actualHashUnverified4m); 126 assertEquals("Inconsistent hash from /authfs/11: ", expectedHash4m, actualHash4m); 127 } 128 129 // Separate the test from the above simply because exec in shell does not allow open too many 130 // files. 131 @Test testReadWithFsverityVerification_RemoteSmallerFile()132 public void testReadWithFsverityVerification_RemoteSmallerFile() 133 throws DeviceNotAvailableException, InterruptedException { 134 // Setup 135 runFdServerInBackground( 136 "3<input.4k 4<input.4k.merkle_dump 5<input.4k.fsv_sig" 137 + " 6<input.4k1 7<input.4k1.merkle_dump 8<input.4k1.fsv_sig", 138 "--ro-fds 3:4:5 --ro-fds 6:7:8" 139 ); 140 runAuthFsInBackground( 141 "--remote-ro-file 10:3:4096:cert.der --remote-ro-file 11:6:4097:cert.der" 142 ); 143 144 // Action 145 String actualHash4k = computeFileHashInGuest(MOUNT_DIR + "/10"); 146 String actualHash4k1 = computeFileHashInGuest(MOUNT_DIR + "/11"); 147 148 // Verify 149 String expectedHash4k = computeFileHash(TEST_DIR + "/input.4k"); 150 String expectedHash4k1 = computeFileHash(TEST_DIR + "/input.4k1"); 151 152 assertEquals("Inconsistent hash from /authfs/10: ", expectedHash4k, actualHash4k); 153 assertEquals("Inconsistent hash from /authfs/11: ", expectedHash4k1, actualHash4k1); 154 } 155 156 @Test testReadWithFsverityVerification_TamperedMerkleTree()157 public void testReadWithFsverityVerification_TamperedMerkleTree() 158 throws DeviceNotAvailableException, InterruptedException { 159 // Setup 160 runFdServerInBackground( 161 "3<input.4m 4<input.4m.merkle_dump.bad 5<input.4m.fsv_sig", 162 "--ro-fds 3:4:5" 163 ); 164 runAuthFsInBackground("--remote-ro-file 10:3:4096:cert.der"); 165 166 // Verify 167 assertFalse(copyFileInGuest(MOUNT_DIR + "/10", "/dev/null")); 168 } 169 170 @Test testWriteThroughCorrectly()171 public void testWriteThroughCorrectly() 172 throws DeviceNotAvailableException, InterruptedException { 173 // Setup 174 runFdServerInBackground("3<>output", "--rw-fds 3"); 175 runAuthFsInBackground("--remote-new-rw-file 20:3"); 176 177 // Action 178 String srcPath = "/system/bin/linker"; 179 String destPath = MOUNT_DIR + "/20"; 180 String backendPath = TEST_DIR + "/output"; 181 assertTrue(copyFileInGuest(srcPath, destPath)); 182 183 // Verify 184 String expectedHash = computeFileHashInGuest(srcPath); 185 expectBackingFileConsistency(destPath, backendPath, expectedHash); 186 } 187 188 @Test testWriteFailedIfDetectsTampering()189 public void testWriteFailedIfDetectsTampering() 190 throws DeviceNotAvailableException, InterruptedException { 191 // Setup 192 runFdServerInBackground("3<>output", "--rw-fds 3"); 193 runAuthFsInBackground("--remote-new-rw-file 20:3"); 194 195 String srcPath = "/system/bin/linker"; 196 String destPath = MOUNT_DIR + "/20"; 197 String backendPath = TEST_DIR + "/output"; 198 assertTrue(copyFileInGuest(srcPath, destPath)); 199 200 // Action 201 // Tampering with the first 2 4K block of the backing file. 202 expectRemoteCommandToSucceed("dd if=/dev/zero of=" + backendPath + " bs=1 count=8192"); 203 204 // Verify 205 // Write to a block partially requires a read back to calculate the new hash. It should fail 206 // when the content is inconsistent to the known hash. Use direct I/O to avoid simply 207 // writing to the filesystem cache. 208 expectRemoteCommandToFail("dd if=/dev/zero of=" + destPath + " bs=1 count=1024 direct"); 209 210 // A full 4K write does not require to read back, so write can succeed even if the backing 211 // block has already been tampered. 212 expectRemoteCommandToSucceed( 213 "dd if=/dev/zero of=" + destPath + " bs=1 count=4096 skip=4096"); 214 215 // Otherwise, a partial write with correct backing file should still succeed. 216 expectRemoteCommandToSucceed( 217 "dd if=/dev/zero of=" + destPath + " bs=1 count=1024 skip=8192"); 218 } 219 220 @Test testFileResize()221 public void testFileResize() throws DeviceNotAvailableException, InterruptedException { 222 // Setup 223 runFdServerInBackground("3<>output", "--rw-fds 3"); 224 runAuthFsInBackground("--remote-new-rw-file 20:3"); 225 String outputPath = MOUNT_DIR + "/20"; 226 String backendPath = TEST_DIR + "/output"; 227 228 // Action & Verify 229 expectRemoteCommandToSucceed( 230 "yes $'\\x01' | tr -d '\\n' | dd bs=1 count=10000 of=" + outputPath); 231 assertEquals(getFileSizeInBytes(outputPath), 10000); 232 expectBackingFileConsistency( 233 outputPath, 234 backendPath, 235 "684ad25fdc2bbb80cbc910dd1bde6d5499ccf860ca6ee44704b77ec445271353"); 236 237 resizeFile(outputPath, 15000); 238 assertEquals(getFileSizeInBytes(outputPath), 15000); 239 expectBackingFileConsistency( 240 outputPath, 241 backendPath, 242 "567c89f62586e0d33369157afdfe99a2fa36cdffb01e91dcdc0b7355262d610d"); 243 244 resizeFile(outputPath, 5000); 245 assertEquals(getFileSizeInBytes(outputPath), 5000); 246 expectBackingFileConsistency( 247 outputPath, 248 backendPath, 249 "e53130831c13dabff71d5d1797e3aaa467b4b7d32b3b8782c4ff03d76976f2aa"); 250 } 251 expectBackingFileConsistency( String authFsPath, String backendPath, String expectedHash)252 private void expectBackingFileConsistency( 253 String authFsPath, String backendPath, String expectedHash) 254 throws DeviceNotAvailableException { 255 String hashOnAuthFs = computeFileHashInGuest(authFsPath); 256 assertEquals("File hash is different to expectation", expectedHash, hashOnAuthFs); 257 258 String hashOfBackingFile = computeFileHash(backendPath); 259 assertEquals( 260 "Inconsistent file hash on the backend storage", hashOnAuthFs, hashOfBackingFile); 261 } 262 263 // TODO(b/178874539): This does not really run in the guest VM. Send the shell command to the 264 // guest VM when authfs works across VM boundary. computeFileHashInGuest(String path)265 private String computeFileHashInGuest(String path) throws DeviceNotAvailableException { 266 return computeFileHash(path); 267 } 268 copyFileInGuest(String src, String dest)269 private boolean copyFileInGuest(String src, String dest) throws DeviceNotAvailableException { 270 // TODO(b/182576497): cp returns error because close(2) returns ENOSYS in the current authfs 271 // implementation. We should probably fix that since programs can expect close(2) return 0. 272 String cmd = "cat " + src + " > " + dest; 273 CommandResult result = mDevice.executeShellV2Command(cmd); 274 return result.getStatus() == CommandStatus.SUCCESS; 275 } 276 computeFileHash(String path)277 private String computeFileHash(String path) throws DeviceNotAvailableException { 278 String result = expectRemoteCommandToSucceed("sha256sum " + path); 279 String[] tokens = result.split("\\s"); 280 if (tokens.length > 0) { 281 return tokens[0]; 282 } else { 283 CLog.e("Unrecognized output by sha256sum: " + result); 284 return ""; 285 } 286 } 287 resizeFile(String path, long size)288 private void resizeFile(String path, long size) throws DeviceNotAvailableException { 289 expectRemoteCommandToSucceed("truncate -c -s " + size + " " + path); 290 } 291 getFileSizeInBytes(String path)292 private long getFileSizeInBytes(String path) throws DeviceNotAvailableException { 293 return Long.parseLong(expectRemoteCommandToSucceed("stat -c '%s' " + path)); 294 } 295 throwDowncastedException(Exception e)296 private void throwDowncastedException(Exception e) throws DeviceNotAvailableException { 297 if (e instanceof DeviceNotAvailableException) { 298 throw (DeviceNotAvailableException) e; 299 } else { 300 // Convert the broad Exception into an unchecked exception to avoid polluting all other 301 // methods. waitFor throws Exception because the callback, Callable#call(), has a 302 // signature to throw an Exception. 303 throw new RuntimeException(e); 304 } 305 } 306 runAuthFsInBackground(String flags)307 private void runAuthFsInBackground(String flags) throws DeviceNotAvailableException { 308 String cmd = "cd " + TEST_DIR + " && " + AUTHFS_BIN + " " + MOUNT_DIR + " " + flags; 309 310 mThreadPool.submit(() -> { 311 try { 312 CLog.i("Starting authfs"); 313 expectRemoteCommandToSucceed(cmd); 314 } catch (DeviceNotAvailableException e) { 315 CLog.e("Error running authfs", e); 316 throw new RuntimeException(e); 317 } 318 }); 319 try { 320 PollingCheck.waitFor(AUTHFS_INIT_TIMEOUT_MS, () -> isRemoteDirectoryOnFuse(MOUNT_DIR)); 321 } catch (Exception e) { 322 throwDowncastedException(e); 323 } 324 } 325 runFdServerInBackground(String execParamsForOpeningFds, String flags)326 private void runFdServerInBackground(String execParamsForOpeningFds, String flags) 327 throws DeviceNotAvailableException { 328 String cmd = "cd " + TEST_DIR + " && exec " + execParamsForOpeningFds + " " + FD_SERVER_BIN 329 + " " + flags; 330 mThreadPool.submit(() -> { 331 try { 332 CLog.i("Starting fd_server"); 333 expectRemoteCommandToSucceed(cmd); 334 } catch (DeviceNotAvailableException e) { 335 CLog.e("Error running fd_server", e); 336 throw new RuntimeException(e); 337 } 338 }); 339 } 340 isRemoteDirectoryOnFuse(String path)341 private boolean isRemoteDirectoryOnFuse(String path) throws DeviceNotAvailableException { 342 String fs_type = expectRemoteCommandToSucceed("stat -f -c '%t' " + path); 343 return FUSE_SUPER_MAGIC_HEX.equals(fs_type); 344 } 345 expectRemoteCommandToSucceed(String cmd)346 private String expectRemoteCommandToSucceed(String cmd) throws DeviceNotAvailableException { 347 CommandResult result = mDevice.executeShellV2Command(cmd); 348 assertEquals("`" + cmd + "` failed: " + result.getStderr(), CommandStatus.SUCCESS, 349 result.getStatus()); 350 CLog.d("Stdout: " + result.getStdout()); 351 return result.getStdout().trim(); 352 } 353 expectRemoteCommandToFail(String cmd)354 private void expectRemoteCommandToFail(String cmd) throws DeviceNotAvailableException { 355 CommandResult result = mDevice.executeShellV2Command(cmd); 356 assertNotEquals("Unexpected success from `" + cmd + "`: " + result.getStdout(), 357 result.getStatus(), CommandStatus.SUCCESS); 358 } 359 } 360