Compare commits

...
4 changed files with 98 additions and 20 deletions

No files matched your search

@@ -6,6 +6,7 @@ import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES
import java.security.MessageDigest
import java.util.Locale
import java.util.UUID
/** Android shared storage cannot create the links in Wine prefixes or native Steam depots. */
object SecondaryLibrary {
@@ -17,6 +18,30 @@ object SecondaryLibrary {
"3127680" to "FEX-Emu",
)
/** Some devices permit direct access to the backing volume; others require Android's FUSE path. */
fun contentPath(library: File): File {
val path = library.absolutePath
if (!path.startsWith("/storage/")) return library
return verifiedAlias(library, File("/mnt/media_rw/" + path.removePrefix("/storage/")))
}
internal fun verifiedAlias(library: File, candidate: File): File {
if (!candidate.isDirectory || !candidate.canRead() || !candidate.canWrite()) return library
var probe: File? = null
return try {
val created = File.createTempFile(".droiddeck-path-", ".probe", candidate)
probe = created
val token = UUID.randomUUID().toString()
created.writeText(token)
val visible = File(library, created.name)
if (visible.readText() == token && visible.delete() && !created.exists()) candidate else library
} catch (_: Exception) {
library
} finally {
probe?.delete()
}
}
fun privateRoot(files: File, library: File): File {
val digest = MessageDigest.getInstance("SHA-256").digest(library.canonicalPath.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 255) }
@@ -24,7 +49,7 @@ object SecondaryLibrary {
}
/** Parent binds come first; the more specific private directories override them in PRoot. */
fun binds(files: File, library: File): List<String> {
fun binds(files: File, library: File, content: File = library): List<String> {
val private = privateRoot(files, library)
val apps = File(library, "steamapps")
val tools = LinkedHashMap(bootstrapTools)
@@ -58,7 +83,7 @@ object SecondaryLibrary {
}
}
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
listOf("${content.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
}
}
@@ -799,9 +799,10 @@ class SessionService : Service() {
// Links, prefixes and Steam entries made before the rename still name the old path.
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
try {
binds.addAll(SecondaryLibrary.binds(filesDir, File(library.path)))
storageDiagnosticLibrary = File(library.path)
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
val content = SecondaryLibrary.contentPath(File(library.path))
binds.addAll(SecondaryLibrary.binds(filesDir, File(library.path), content))
storageDiagnosticLibrary = content
Log.i(TAG, "game storage: ${content.path} -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
} catch (e: Exception) {
Log.w(TAG, "game storage: private directories could not be prepared; internal only this session", e)
}
@@ -816,9 +817,9 @@ class SessionService : Service() {
val device = StorageDiagnostics.writeSnapshot(
sessionDir, target,
selectedLibrary = if (storageDiagnosticLibrary != null) "secondary" else "internal",
removable = storageDiagnosticLibrary?.let {
runCatching { Environment.isExternalStorageRemovable(it) }.getOrDefault(false)
} ?: false,
removable = storageDiagnosticLibrary != null && library?.let {
runCatching { Environment.isExternalStorageRemovable(File(it.path)) }.getOrDefault(false)
} == true,
)
if (device != null) {
// guest already has the session command at this point. Put these with the `env -i`
@@ -10,6 +10,34 @@ import org.junit.rules.TemporaryFolder
class SecondaryLibraryTest {
@get:Rule val tmp = TemporaryFolder()
@Test fun directContentKeepsTheConfiguredLibrarysPrivateDirectories() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("shared")
val content = tmp.newFolder("direct")
val private = SecondaryLibrary.privateRoot(files, library)
val binds = SecondaryLibrary.binds(files, library, content)
assertTrue(binds.contains("${content.path}:/mnt/droiddeck-sd"))
assertTrue(binds.contains("$private/steamapps/compatdata:/mnt/droiddeck-sd/steamapps/compatdata"))
assertFalse(SecondaryLibrary.privateRoot(files, content).exists())
}
@Test fun aWritableAliasIsSelectedAndItsProbeIsRemoved() {
val library = tmp.newFolder("shared")
val candidate = File(tmp.root, "direct")
Files.createSymbolicLink(candidate.toPath(), library.toPath())
assertEquals(candidate, SecondaryLibrary.verifiedAlias(library, candidate))
assertTrue(library.listFiles()!!.isEmpty())
}
@Test fun anUnrelatedOrMissingDirectoryCannotReplaceTheLibrary() {
val library = tmp.newFolder("shared")
val candidate = tmp.newFolder("other")
assertEquals(library, SecondaryLibrary.verifiedAlias(library, candidate))
assertTrue(candidate.listFiles()!!.isEmpty())
assertTrue(library.listFiles()!!.isEmpty())
assertEquals(library, SecondaryLibrary.verifiedAlias(library, File(tmp.root, "missing")))
}
@Test fun gamesStaySharedButPrefixesAndNativeDownloadsArePrivate() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("card")
+36 -12
View File
@@ -9,14 +9,15 @@
*
* It decides nothing proot would decide differently. The guest path is mapped literally - the
* longest binding whose guest path is a prefix, else the rootfs - and the parent directory is
* opened O_PATH and must come back from /proc/self/fd as exactly that host path, which proves no
* component on the way was a symlink: proot would then have walked the same directories. The
* last component is looked at without following it, and a symlink there goes to proot. So does
* "..", a trailing "/", anything under /proc, any flag not handled here and any failure to
* decide: the real libc call runs, proot traps it and answers as it always has.
* opened O_PATH and checked against /proc/self/fd. A case-only difference is accepted only on
* FUSE or FAT and only after each differently spelled component is verified as a directory rather than
* a symlink. The last component is looked at without following it; other symlinks go to proot.
* So do "..", a trailing "/", anything under /proc, any flag not handled here and any failure
* to decide: the real libc call runs, proot traps it and answers as it always has.
*
* Only syscalls Android's app seccomp policy allows are used (openat, newfstatat, statx,
* faccessat, readlinkat, close) - not openat2 or faccessat2, which it answers with SIGSYS.
* faccessat, fstatfs, readlinkat, close) - not openat2 or faccessat2, which it answers with
* SIGSYS.
*
* Verified parents are remembered for PROOT_FP_TTL_MS (default 2000; 0 = verify every call): a
* parent directory replaced by a symlink within that window would be missed, the same trade the
@@ -33,6 +34,7 @@
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <linux/magic.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdint.h>
@@ -43,6 +45,8 @@
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <sys/vfs.h>
#include <strings.h>
#include <time.h>
#include <unistd.h>
@@ -250,10 +254,28 @@ static long canonical_dir(const char *dir) {
char link[40], out[PATH_MAX];
snprintf(link, sizeof link, "/proc/self/fd/%ld", fd);
long n = sc(SYS_readlinkat, AT_FDCWD, (long)link, (long)out, sizeof out - 1, 0);
sc(SYS_close, fd, 0, 0, 0, 0);
if (n <= 0) return 0;
if (n <= 0) { sc(SYS_close, fd, 0, 0, 0, 0); return 0; }
out[n] = 0;
if (strcmp(out, dir) != 0) return 0;
if (strcmp(out, dir) != 0) {
struct statfs fs;
int case_alias = sc(SYS_fstatfs, fd, (long)&fs, 0, 0, 0) == 0 &&
(fs.f_type == FUSE_SUPER_MAGIC || fs.f_type == EXFAT_SUPER_MAGIC || fs.f_type == MSDOS_SUPER_MAGIC);
sc(SYS_close, fd, 0, 0, 0, 0);
if (!case_alias || strcasecmp(out, dir) != 0) return 0;
/* FUSE and FAT case aliases return the stored spelling from /proc/self/fd. Prove the final
* component and each parent so a case-only symlink path still goes to proot. */
struct stat st;
if (sc(SYS_newfstatat, AT_FDCWD, (long)dir, (long)&st, AT_SYMLINK_NOFOLLOW, 0) != 0 ||
!S_ISDIR(st.st_mode)) return 0;
char parent[PATH_MAX];
snprintf(parent, sizeof parent, "%s", dir);
char *slash = strrchr(parent, '/');
if (!slash || slash == parent) return 0;
*slash = 0;
if (canonical_dir(parent) != 1) return 0;
} else {
sc(SYS_close, fd, 0, 0, 0, 0);
}
remember(dir, h);
return 1;
}
@@ -474,14 +496,16 @@ static int access_common(int dirfd, const char *path, int mode, int flags) {
REAL(faccessat, int (*)(int, const char *, int, int));
char host[PATH_MAX];
struct stat st;
/* faccessat(2) has no flags: AT_EACCESS and AT_SYMLINK_NOFOLLOW stay with glibc and proot. */
long rs = flags == 0 ? resolve(dirfd, path, host) : FP_SLOW;
/* F_OK with AT_SYMLINK_NOFOLLOW asks only whether the last directory entry exists. */
int nofollow_exists = mode == F_OK && flags == AT_SYMLINK_NOFOLLOW;
long rs = flags == 0 || nofollow_exists ? resolve(dirfd, path, host) : FP_SLOW;
if (rs == -ENOENT) { hits++; return ret(rs); }
if (rs == 0) {
long k = kind(host, &st);
if (k != 1) {
if (k != 1 || nofollow_exists) {
hits++;
if (k < 0) return ret(k);
if (nofollow_exists) return 0;
return ret(sc(SYS_faccessat, AT_FDCWD, (long)host, mode, 0, 0));
}
}