Support cross-user lookups

Apps should typically not be able to lookup files from a FUSE daemon
running in another FUSE daemon user.

Unfortunately, some OEMs rely on this behavior for "app cloning"
feature. To maintain support in Android R, we allow app clone pair lookups.

If the system_server determines that two users are an app clone pair,
cross-user lookups will succeed in the respective FUSE daemons.

OEMs should ensure the following:
1. Work profiles should never be paired with any other user
2. Pairings should only involve user 0 and a special user number e.g 999
3. An associated user number MUST never be re-used for a work profile
user within the same boot context

Test: Manual
Bug: 162476851
Bug: 154057120
Change-Id: If0bdc467999c77a5b6268405bd36f333504b7d6a
diff --git a/jni/FuseDaemon.cpp b/jni/FuseDaemon.cpp
index 21b20c7..3c825c3 100644
--- a/jni/FuseDaemon.cpp
+++ b/jni/FuseDaemon.cpp
@@ -592,10 +592,15 @@
 
     std::smatch match;
     std::regex_search(child_path, match, storage_emulated_regex);
+
+    // Ensure the FuseDaemon user id matches the user id or cross-user lookups are allowed in
+    // requested path
     if (match.size() == 2 && std::to_string(getuid() / PER_USER_RANGE) != match[1].str()) {
-        // Ensure the FuseDaemon user id matches the user id in requested path
-        *error_code = EACCES;
-        return nullptr;
+        // If user id mismatch, check cross-user lookups
+        if (!fuse->mp->ShouldAllowLookup(req->ctx.uid, std::stoi(match[1].str()))) {
+            *error_code = EACCES;
+            return nullptr;
+        }
     }
     return make_node_entry(req, parent_node, name, child_path, e, error_code);
 }
diff --git a/jni/MediaProviderWrapper.cpp b/jni/MediaProviderWrapper.cpp
index 894e0ba..8a1fda9 100644
--- a/jni/MediaProviderWrapper.cpp
+++ b/jni/MediaProviderWrapper.cpp
@@ -281,6 +281,8 @@
                               /*is_static*/ false);
     mid_on_file_created_ = CacheMethod(env, "onFileCreated", "(Ljava/lang/String;)V",
                                        /*is_static*/ false);
+    mid_should_allow_lookup_ = CacheMethod(env, "shouldAllowLookup", "(II)Z",
+                                           /*is_static*/ false);
     mid_get_io_path_ = CacheMethod(env, "getIoPath", "(Ljava/lang/String;I)Ljava/lang/String;",
                                    /*is_static*/ false);
     mid_get_transforms_ = CacheMethod(env, "getTransforms", "(Ljava/lang/String;I)I",
@@ -430,6 +432,18 @@
     return onFileCreatedInternal(env, media_provider_object_, mid_on_file_created_, path);
 }
 
+bool MediaProviderWrapper::ShouldAllowLookup(uid_t uid, int path_user_id) {
+    JNIEnv* env = MaybeAttachCurrentThread();
+
+    bool res = env->CallBooleanMethod(media_provider_object_, mid_should_allow_lookup_, uid,
+                                      path_user_id);
+
+    if (CheckForJniException(env)) {
+        return false;
+    }
+    return res;
+}
+
 std::string MediaProviderWrapper::GetIoPath(const std::string& path, uid_t uid) {
     JNIEnv* env = MaybeAttachCurrentThread();
 
diff --git a/jni/MediaProviderWrapper.h b/jni/MediaProviderWrapper.h
index 9c8d778..24133f8 100644
--- a/jni/MediaProviderWrapper.h
+++ b/jni/MediaProviderWrapper.h
@@ -175,6 +175,14 @@
     bool Transform(const std::string& src, const std::string& dst, int transforms, uid_t uid);
 
     /**
+     * Determines if to allow FUSE_LOOKUP for uid. Might allow uids that don't belong to the
+     * MediaProvider user, depending on OEM configuration.
+     *
+     * @param uid linux uid to check
+     */
+    bool ShouldAllowLookup(uid_t uid, int path_user_id);
+
+    /**
      * Initializes per-process static variables associated with the lifetime of
      * a managed runtime.
      */
@@ -198,6 +206,7 @@
     jmethodID mid_rename_;
     jmethodID mid_is_uid_for_package_;
     jmethodID mid_on_file_created_;
+    jmethodID mid_should_allow_lookup_;
     jmethodID mid_get_io_path_;
     jmethodID mid_get_transforms_;
     jmethodID mid_transform_;
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 090b9db..e195b04 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -34,6 +34,9 @@
 import static android.provider.MediaStore.QUERY_ARG_RELATED_URI;
 import static android.provider.MediaStore.getVolumeName;
 
+import static android.content.pm.PackageManager.MATCH_ANY_USER;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
 import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME;
 import static com.android.providers.media.DatabaseHelper.INTERNAL_DATABASE_NAME;
 import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_DELEGATOR;
@@ -63,6 +66,7 @@
 import static com.android.providers.media.util.FileUtils.extractRelativePathForDirectory;
 import static com.android.providers.media.util.FileUtils.extractTopLevelDir;
 import static com.android.providers.media.util.FileUtils.extractVolumeName;
+import static com.android.providers.media.util.FileUtils.extractVolumePath;
 import static com.android.providers.media.util.FileUtils.getAbsoluteSanitizedPath;
 import static com.android.providers.media.util.FileUtils.isDataOrObbPath;
 import static com.android.providers.media.util.FileUtils.isDownload;
@@ -77,6 +81,7 @@
 import android.app.PendingIntent;
 import android.app.RecoverableSecurityException;
 import android.app.RemoteAction;
+import android.app.admin.DevicePolicyManager;
 import android.content.BroadcastReceiver;
 import android.content.ClipData;
 import android.content.ClipDescription;
@@ -198,6 +203,8 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.PrintWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -287,6 +294,11 @@
     private static final String MATCH_PENDING_FROM_FUSE = String.format("lower(%s) NOT REGEXP '%s'",
             MediaColumns.DATA, PATTERN_PENDING_FILEPATH_FOR_SQL);
 
+    // Stolen from: UserHandle#getUserId
+    private static final int PER_USER_RANGE = 100000;
+    private static final boolean PROP_CROSS_USER_ALLOWED =
+            SystemProperties.getBoolean("external_storage.cross_user.enabled", false);
+
     /**
      * Set of {@link Cursor} columns that refer to raw filesystem paths.
      */
@@ -414,6 +426,7 @@
     private StorageManager mStorageManager;
     private AppOpsManager mAppOpsManager;
     private PackageManager mPackageManager;
+    private DevicePolicyManager mDevicePolicyManager;
 
     private int mExternalStorageAuthorityAppId;
     private int mDownloadsAuthorityAppId;
@@ -452,6 +465,9 @@
     private OnOpChangedListener mModeListener =
             (op, packageName) -> invalidateLocalCallingIdentityCache(packageName, "op " + op);
 
+    @GuardedBy("mNonWorkProfileUsers")
+    private final List<Integer> mNonWorkProfileUsers = new ArrayList<>();
+
     /**
      * Retrieves a cached calling identity or creates a new one. Also, always sets the app-op
      * description for the calling identity.
@@ -764,6 +780,10 @@
         }
     }
 
+    private static boolean isCrossUserEnabled() {
+        return PROP_CROSS_USER_ALLOWED && Build.VERSION.SDK_INT <= Build.VERSION_CODES.R;
+    }
+
     /**
      * Ensure that default folders are created on mounted primary storage
      * devices. We only do this once per volume so we don't annoy the user if
@@ -865,6 +885,7 @@
         mStorageManager = context.getSystemService(StorageManager.class);
         mAppOpsManager = context.getSystemService(AppOpsManager.class);
         mPackageManager = context.getPackageManager();
+        mDevicePolicyManager = context.getSystemService(DevicePolicyManager.class);
 
         // Reasonable thumbnail size is half of the smallest screen edge width
         final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
@@ -1163,6 +1184,81 @@
     }
 
     /**
+     * Determines if to allow FUSE_LOOKUP for uid. Might allow uids that don't belong to the
+     * MediaProvider user, depending on OEM configuration.
+     *
+     * @param uid linux uid to check
+     *
+     * Called from JNI in jni/MediaProviderWrapper.cpp
+     */
+    @Keep
+    public boolean shouldAllowLookupForFuse(int uid, int pathUserId) {
+        int callingUserId = uid / PER_USER_RANGE;
+        if (!isCrossUserEnabled()) {
+            Log.d(TAG, "CrossUser not enabled. Users: " + callingUserId + " and " + pathUserId);
+            return false;
+        }
+
+        if (callingUserId != 0 && pathUserId != 0) {
+            Log.w(TAG, "CrossUser at least one user is 0 check failed. Users: " + callingUserId
+                    + " and " + pathUserId);
+            return false;
+        }
+
+        if (!isWorkProfile(callingUserId) || !isWorkProfile(pathUserId)) {
+            // Cross-user lookup not allowed if one user in the pair has a profile owner app
+            Log.w(TAG, "CrossUser work profile check failed. Users: " + callingUserId + " and "
+                    + pathUserId);
+            return false;
+        }
+
+        try {
+            Method isAppCloneUserPair = StorageManager.class.getMethod("isAppCloneUserPair",
+                    int.class, int.class);
+            boolean result = (Boolean) isAppCloneUserPair.invoke(mStorageManager, pathUserId,
+                    callingUserId);
+
+            if (result) {
+                Log.i(TAG, "CrossUser allowed. Users: " + callingUserId + " and " + pathUserId);
+            } else {
+                Log.w(TAG, "CrossUser isAppCloneUserPair check failed. Users: " + callingUserId
+                        + " and " + pathUserId);
+            }
+            return result;
+        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+            Log.w(TAG, "isAppCloneUserPair failed. Users: " + callingUserId + " and " + pathUserId);
+            return false;
+        }
+    }
+
+    private boolean isWorkProfile(int userId) {
+        synchronized (mNonWorkProfileUsers) {
+            if (mNonWorkProfileUsers.contains(userId)) {
+                return false;
+            }
+            if (userId == 0) {
+                mNonWorkProfileUsers.add(userId);
+                // user 0 cannot have a profile owner
+                return false;
+            }
+        }
+
+        List<Integer> uids = new ArrayList<>();
+        for (ApplicationInfo ai : mPackageManager.getInstalledApplications(MATCH_DIRECT_BOOT_AWARE
+                        | MATCH_DIRECT_BOOT_UNAWARE | MATCH_ANY_USER)) {
+            if (((ai.uid / PER_USER_RANGE) == userId)
+                    && mDevicePolicyManager.isProfileOwnerApp(ai.packageName)) {
+                return true;
+            }
+        }
+
+        synchronized (mNonWorkProfileUsers) {
+            mNonWorkProfileUsers.add(userId);
+            return false;
+        }
+    }
+
+    /**
      * Called from FUSE to transform a file
      *
      * A transform can change the file contents for {@code uid} from {@code src} to {@code dst}
@@ -5374,7 +5470,22 @@
                 initialValues.remove(MediaColumns.DATA);
                 ensureUniqueFileColumns(match, uri, extras, initialValues, beforePath);
 
-                final String afterPath = initialValues.getAsString(MediaColumns.DATA);
+                String afterPath = initialValues.getAsString(MediaColumns.DATA);
+
+                if (isCrossUserEnabled()) {
+                    String afterVolume = extractVolumeName(afterPath);
+                    String afterVolumePath =  extractVolumePath(afterPath);
+                    String beforeVolumePath = extractVolumePath(beforePath);
+
+                    if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equals(beforeVolume)
+                            && beforeVolume.equals(afterVolume)
+                            && !beforeVolumePath.equals(afterVolumePath)) {
+                        // On cross-user enabled devices, it can happen that a rename intended as
+                        // /storage/emulated/999/foo -> /storage/emulated/999/foo can end up as
+                        // /storage/emulated/999/foo -> /storage/emulated/0/foo. We now fix-up
+                        afterPath = afterPath.replaceFirst(afterVolumePath, beforeVolumePath);
+                    }
+                }
 
                 Log.d(TAG, "Moving " + beforePath + " to " + afterPath);
                 try {