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