1 /*
2  * Copyright (C) 2022 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.sts.common;
18 
19 import static org.junit.Assert.assertTrue;
20 import static org.junit.Assert.assertNotNull;
21 import static com.android.sts.common.CommandUtil.runAndCheck;
22 
23 import java.io.IOException;
24 import java.nio.charset.StandardCharsets;
25 import java.nio.file.Path;
26 import java.nio.file.Paths;
27 import java.util.ArrayList;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.regex.Matcher;
32 import java.util.regex.Pattern;
33 import org.junit.rules.TestWatcher;
34 import org.junit.runner.Description;
35 
36 import com.android.tradefed.device.DeviceNotAvailableException;
37 import com.android.tradefed.device.ITestDevice;
38 import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
39 import com.android.tradefed.util.CommandResult;
40 import com.android.tradefed.util.CommandStatus;
41 import com.google.common.hash.Hashing;
42 
43 
44 /** TestWatcher that enables writing to read-only partitions and reboots device when done. */
45 public class OverlayFsUtils extends TestWatcher {
46     private static final String OVERLAYFS_PREFIX = "sts_overlayfs_";
47     private static final Path WRITABLE_DIR = Paths.get("/data", "local", "tmp");
48 
49     private final BaseHostJUnit4Test test;
50 
51     // output of `stat`, e.g. "root shell 755 u:object_r:vendor_file:s0"
52     static final Pattern PERM_PATTERN =
53             Pattern.compile(
54                     "^(?<user>[a-zA-Z0-9_-]+) (?<group>[a-zA-Z0-9_-]+) (?<perm>[0-7]+)"
55                             + " (?<secontext>.*)$");
56 
57     private Map<ITestDevice, List<String>> workingDirs = new HashMap<>();
58 
OverlayFsUtils(BaseHostJUnit4Test test)59     public OverlayFsUtils(BaseHostJUnit4Test test) {
60         assertNotNull("Need to pass in a valid testcase object.", test);
61         this.test = test;
62     }
63 
64     /**
65      * Mounts an OverlayFS dir over the top most common dir in the list.
66      *
67      * <p>The directory should be writable after this returns successfully. To cleanup, reboot the
68      * device as unfortunately unmounting overlayfs is complicated.
69      *
70      * @param dir The directory to make writable. Directories with single quotes are not supported.
71      */
makeWritable(final String dir, int megabytes)72     public void makeWritable(final String dir, int megabytes)
73             throws DeviceNotAvailableException, IOException, IllegalStateException {
74         ITestDevice device = test.getDevice();
75         assertNotNull("device not set.", device);
76         assertTrue("dir needs to be an absolute path.", dir.startsWith("/"));
77 
78         // Setup list of temp dirs to be deleted upon cleanup
79         List<String> dirs;
80         if (!workingDirs.containsKey(device)) {
81             dirs = new ArrayList<>(2);
82             workingDirs.put(device, dirs);
83         } else {
84             dirs = workingDirs.get(device);
85         }
86 
87         // losetup doesn't work for image paths 64 bytes or longer, so we have to truncate
88         String dirHash = Hashing.md5().hashString(dir, StandardCharsets.UTF_8).toString();
89         int pathPrefixLength = WRITABLE_DIR.toString().length() + 1 + OVERLAYFS_PREFIX.length();
90         int dirHashLength = Math.min(64 - pathPrefixLength - 5, dirHash.length());
91         assertTrue("Can't fit overlayFS image path in 64 chars.", dirHashLength >= 5);
92         String id = OVERLAYFS_PREFIX + dirHash.substring(0, dirHashLength);
93 
94         // Check and make sure we have not already mounted over this dir. We do that by hashing
95         // the lower dir path and put that as part of the device ID for `mount`.
96         CommandResult res = device.executeShellV2Command("mount | grep -q " + id);
97         if (res.getStatus() == CommandStatus.SUCCESS) {
98             // a mount with the same ID already exists
99             throw new IllegalStateException(dir + " has already been made writable.");
100         }
101 
102         assertTrue("Can't acquire root for " + device.getSerialNumber(), device.enableAdbRoot());
103 
104         // Match permissions of upper dir to lower dir
105         String statOut = runAndCheck(device, "stat -c '%U %G %a %C' '" + dir + "'").getStdout();
106         Matcher m = PERM_PATTERN.matcher(statOut);
107         assertTrue("Bad stats output: " + statOut, m.find());
108         String user = m.group("user");
109         String group = m.group("group");
110         String unixPerm = m.group("perm");
111         String seContext = m.group("secontext");
112 
113         // Disable SELinux enforcement and mount a loopback ext4 image
114         runAndCheck(device, "setenforce 0");
115         Path tempdir = WRITABLE_DIR.resolve(id);
116         Path tempimg = tempdir.getParent().resolve(tempdir.getFileName().toString() + ".img");
117         dirs.add(tempimg.toString());
118         dirs.add(tempdir.toString());
119 
120         runAndCheck(
121                 device,
122                 String.format("dd if=/dev/zero of='%s' bs=%dM count=1", tempimg, megabytes));
123         runAndCheck(device, String.format("mkfs.ext4 '%s'", tempimg));
124         runAndCheck(device, String.format("mkdir '%s'", tempdir));
125 
126         String loopbackDev =
127                 runAndCheck(device, String.format("losetup -f -s '%s'", tempimg), 3)
128                         .getStdout()
129                         .strip();
130         runAndCheck(device, String.format("mount '%s' '%s'", loopbackDev, tempdir), 3);
131 
132         String upperdir = tempdir.resolve("upper").toString();
133         String workdir = tempdir.resolve("workdir").toString();
134 
135         runAndCheck(device, String.format("mkdir -p '%s' '%s'", upperdir, workdir));
136         runAndCheck(device, String.format("chown %s:%s '%s'", user, group, upperdir));
137         runAndCheck(device, String.format("chcon '%s' '%s'", seContext, upperdir));
138         runAndCheck(device, String.format("chmod %s '%s'", unixPerm, upperdir));
139 
140         String mountCmd =
141                 String.format(
142                         "mount -t overlay '%s' -o lowerdir='%s',upperdir='%s',workdir='%s' '%s'",
143                         id, dir, upperdir, workdir, dir);
144         runAndCheck(device, mountCmd);
145     }
146 
anyOverlayFsMounted()147     public boolean anyOverlayFsMounted() throws DeviceNotAvailableException {
148         ITestDevice device = test.getDevice();
149         assertNotNull("Device not set", device);
150         CommandResult res = device.executeShellV2Command("mount | grep -q " + OVERLAYFS_PREFIX);
151         return res.getStatus() == CommandStatus.SUCCESS;
152     }
153 
154     @Override
finished(Description d)155     public void finished(Description d) {
156         ITestDevice device = test.getDevice();
157         assertNotNull("Device not set", device);
158         try {
159             // Since we can't umount an overlayfs cleanly, reboot the device to cleanup
160             if (anyOverlayFsMounted()) {
161                 device.rebootUntilOnline();
162                 device.waitForDeviceAvailable();
163             }
164 
165             // Remove upper and working dirs
166             assertTrue("Can't acquire root: " + device.getSerialNumber(), device.enableAdbRoot());
167             if (workingDirs.containsKey(device)) {
168                 for (String dir : workingDirs.get(device)) {
169                     runAndCheck(device, String.format("rm -rf '%s'", dir));
170                 }
171             }
172 
173             // Restore SELinux enforcement state
174             runAndCheck(device, "setenforce 1");
175 
176             assertTrue("Can't remove root: " + device.getSerialNumber(), device.disableAdbRoot());
177         } catch (DeviceNotAvailableException e) {
178             throw new AssertionError("Device unavailable when cleaning up", e);
179         }
180     }
181 }
182