From 8cfd54a739180ae39b6288816f399246e6db8088 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Wed, 30 Sep 2026 21:05:10 -0400 Subject: [PATCH] Add proot fast path for in-process path lookups Preload library answers stat/open/access/readlink in-process via a trampoline page proot 0014 lets through seccomp. On by default with a Performance toggle; also sets GLIBC_TUNABLES rseq=0 and malloc top_pad. --- .github/workflows/build.yml | 5 +- .../com/droiddeck/launcher/MainActivity.kt | 5 +- .../droiddeck/launcher/core/DeviceReport.kt | 1 + .../launcher/runtime/ProotFastPath.kt | 45 ++ .../launcher/session/SessionFiles.kt | 5 + .../launcher/session/SessionPrefs.kt | 11 + .../launcher/session/SessionService.kt | 21 + .../launcher/ui/PerformanceDialog.kt | 8 + docs/development/proot-performance.md | 310 ++++++++++ tools/build_local.sh | 3 + tools/proot/PATCHES.md | 12 + tools/proot/bench/equiv.py | 32 + tools/proot/bench/mechbench.c | 274 +++++++++ tools/proot/bench/parstat.c | 16 + tools/proot/bench/run-device.sh | 26 + tools/proot/bench/sysbench.c | 78 +++ tools/proot/bench/workloads.sh | 26 + tools/proot/fastpath/fastpath.c | 545 ++++++++++++++++++ .../patches/0014-fastpath-trampoline.patch | 205 +++++++ 19 files changed, 1626 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt create mode 100644 docs/development/proot-performance.md create mode 100644 tools/proot/bench/equiv.py create mode 100644 tools/proot/bench/mechbench.c create mode 100644 tools/proot/bench/parstat.c create mode 100644 tools/proot/bench/run-device.sh create mode 100644 tools/proot/bench/sysbench.c create mode 100644 tools/proot/bench/workloads.sh create mode 100644 tools/proot/fastpath/fastpath.c create mode 100644 tools/proot/patches/0014-fastpath-trampoline.patch diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5f189da8..b58503417 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -157,7 +157,7 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -176,6 +176,9 @@ jobs: aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so" + aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl + aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so" for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 8c82ed818..9dcd051dc 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -137,6 +137,7 @@ class MainActivity : ComponentActivity() { private var gamescopeRealtime by mutableStateOf(false) private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) + private var prootFastPath by mutableStateOf(true) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE) @@ -1038,7 +1039,7 @@ class MainActivity : ComponentActivity() { tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, gamescopeRealtime = gamescopeRealtime, gpuClockPin = gpuClockPin, - prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, + prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on }, @@ -1048,6 +1049,7 @@ class MainActivity : ComponentActivity() { onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, + onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> clientCores = if (on) clientCores + core else clientCores - core @@ -1147,6 +1149,7 @@ class MainActivity : ComponentActivity() { gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) + prootFastPath = SessionPrefs.prootFastPath(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() } diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 96e495b53..745afd927 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -154,6 +154,7 @@ object DeviceReport { k("Skip xalia", SessionPrefs.noXalia(context)) k("gamescope realtime", SessionPrefs.gamescopeRealtime(context)) k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) + k("proot fast path", SessionPrefs.prootFastPath(context)) k("Guest host name", SessionPrefs.guestHostname(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) k("Stretch games to fill", SessionPrefs.forceFullscreen(context)) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt new file mode 100644 index 000000000..104e73113 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt @@ -0,0 +1,45 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import java.security.MessageDigest + +/** + * proot's fast path: the session's path lookups answered inside each process by libblfastpath + * (tools/proot/fastpath), which issues the translated call from a trampoline page proot's seccomp + * filter lets through (tools/proot/patches/0014). A stat or an open costs about a microsecond + * instead of a 15-60 us round trip through the tracer. + * + * The library maps guest paths to host paths itself, so it has to be told the rootfs and the + * binds exactly as proot was given them. Both sides carry a key derived from that view - proot + * PROOT_FASTPATH=, the guest PROOT_FP_KEY= - and the library answers nothing unless the + * process that traces it has the same key: a program that inherited the variables but runs under + * another proot (a Flatpak sandbox, a one-off command) leaves every call to its own proot. + * + * Needs proot's seccomp filter, so "Run proot without seccomp" turns it off as well. + */ +object ProotFastPath { + const val LIBRARY = "/usr/local/lib/libblfastpath.so" + + fun enabled(context: Context): Boolean = + SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) + + /** The key for this rootfs and these binds, or null when the library cannot be told them. */ + fun key(root: File, binds: List): String? { + if (binds.any { it.contains('|') } || root.path.contains('|')) return null + val digest = MessageDigest.getInstance("SHA-256") + .digest((root.path + "\n" + binds.joinToString("\n")).toByteArray()) + return digest.take(12).joinToString("") { "%02x".format(it) } + } + + /** What proot's own environment carries. */ + fun hostEnv(key: String): Pair = "PROOT_FASTPATH" to key + + /** The guest's assignments (for `env -i`), describing the view proot was given. */ + fun guestEnv(root: File, binds: List, key: String): List = listOf( + "PROOT_FP_ROOT=" + root.path, + "PROOT_FP_BINDS=" + binds.joinToString("|"), + "PROOT_FP_KEY=$key", + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index ed23b57f3..f579e6c94 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -5,6 +5,7 @@ import android.os.Environment import android.util.Log import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.ProotFastPath import java.io.File /** Everything the session needs written into the runtime before it starts. */ @@ -29,6 +30,7 @@ object SessionFiles { "usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env", "libblsession.so" to "usr/local/lib/libblsession.so", "libfakeinput.so" to "usr/local/lib/libfakeinput.so", + "libblfastpath.so" to "usr/local/lib/libblfastpath.so", "usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session", "usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat", "usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install", @@ -151,6 +153,9 @@ object SessionFiles { if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) { preload.append("/usr/local/lib/libfakeinput.so\n") } + // Last, so the others' opens and stats reach it through RTLD_NEXT. It stays idle in any + // process its proot did not describe to it (ProotFastPath). + if (ProotFastPath.enabled(context)) preload.append(ProotFastPath.LIBRARY + "\n") val etc = File(root, "etc").apply { mkdirs() } val staged = File(etc, "ld.so.preload.staged") if (!FileUtils.writeString(staged, preload.toString()) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 9eb8a1d35..86a621b5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -296,6 +296,17 @@ object SessionPrefs { prefs(context).edit().putBoolean("prootNoSeccomp", on).apply() } + /** + * Whether the session's path lookups take proot's fast path (ProotFastPath): answered inside + * each process instead of a round trip through the tracer. On by default; it needs proot's + * seccomp filter, so it is off whenever proot runs without one. + */ + fun prootFastPath(context: Context): Boolean = prefs(context).getBoolean("prootFastPath", true) + + fun setProotFastPath(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("prootFastPath", on).apply() + } + const val DEFAULT_GUEST_HOSTNAME = "DroidDeck" @JvmStatic diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 4114c627e..d9aba810a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.input.FakeInputWriter import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.ProotFastPath import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.text.SimpleDateFormat @@ -278,6 +279,8 @@ class SessionService : Service() { // same set-up as a Steam session: it gets what the client and its games are started with. val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP addClientEnvironment(guest, runtimeDir, steamHere) + // Where the fast path's description of proot's view goes, once the binds are known. + val fastPathAt = guest.size val pulse = startAudio(guest, sessionDir) @@ -363,6 +366,19 @@ class SessionService : Service() { FileUtils.clear(File(cacheDir, "shm")) val binds = sessionBinds(controllersOn, fakeInputDir) + // The fast path is told exactly the rootfs and binds proot is given (ProotFastPath). + val fastPathKey = if (ProotFastPath.enabled(this)) { + val prootBinds = LinuxRuntime.binds( + this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, + ) + val root = LinuxRuntime.rootDir(this) + ProotFastPath.key(root, prootBinds)?.also { key -> + val env = ProotFastPath.guestEnv(root, prootBinds, key) + guest.addAll(fastPathAt, env) + shellGuest.addAll(fastPathAt, env) + Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)") + } + } else null val command = LinuxRuntime.command( this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest, @@ -379,6 +395,7 @@ class SessionService : Service() { hostEnv["PROOT_NO_SECCOMP"] = "1" Log.i(TAG, "proot: seccomp acceleration off by request") } + fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> hostEnv[k] = v } } val prootLibs = LinuxRuntime.prootLibraryPath(this) if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs @@ -535,6 +552,10 @@ class SessionService : Service() { // opened once and read and written with pread/pwrite, which proot never sees. Mesa removes // the old folder itself once it has gone a week untouched. guest.add("MESA_DISK_CACHE_DATABASE=1") + // glibc's per-thread rseq registration is refused by Android's app seccomp policy, so + // every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap + // 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too. + guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index fb441ef06..d7b83cdf5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -40,6 +40,7 @@ fun PerformancePage( gamescopeRealtime: Boolean, gpuClockPin: Boolean, prootNoSeccomp: Boolean, + prootFastPath: Boolean, guestHostname: String, phantomWarning: String?, onClientOverride: (Boolean) -> Unit, @@ -51,6 +52,7 @@ fun PerformancePage( onGamescopeRealtime: (Boolean) -> Unit, onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, + onProotFastPath: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, onGameCore: (Int, Boolean) -> Unit, @@ -127,6 +129,12 @@ fun PerformancePage( "May fix missing syscall errors on some kernels, but can reduce performance.", prootNoSeccomp, onChange = onProotNoSeccomp, ) + ToggleRow( + host, "fastpath", "proot fast path", + if (prootNoSeccomp) "Needs proot's seccomp filter: off while proot runs without seccomp." + else "Answers file lookups inside each program instead of through proot. Faster loading and less stutter; turn off if a program cannot find its files.", + prootFastPath && !prootNoSeccomp, enabled = !prootNoSeccomp, onChange = onProotFastPath, + ) } SettingsGroup("Session identity") { var draft by remember(guestHostname) { mutableStateOf(guestHostname) } diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md new file mode 100644 index 000000000..a9696fe3a --- /dev/null +++ b/docs/development/proot-performance.md @@ -0,0 +1,310 @@ +# proot, glibc and where the time goes + +Research notes for `feat/performance-fixes-2` (2026-09-30). The question was whether Steam and the +games should keep running under proot, run directly on glibc, or a mix of the two, and what the +fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel +6.12.38-android16, Android 16) over adb, unless it says otherwise. + +## Short answer + +- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every + syscall that Android's app seccomp policy blocks (`rseq`, `set_robust_list`, `faccessat2`, + `fchmodat2`, `openat2`, the `set*id` family, `futex_waitv`, ...). + - Stock Arch glibc without a tracer is killed by `SIGSYS` on its first `pthread_create`. That + stays true even with an in-process `SIGSYS` handler, because glibc makes those calls with every + signal blocked. + - Going "pure glibc" means building Termux-style glibc (relocated prefix, blocked syscalls + rewritten), an exec shim for every ELF Steam self-updates, a `/proc/self/exe` emulator for + Chromium, path redirects for `/tmp`, `/dev/shm`, `/mnt/*` and the fake `/proc` and `/sys` + files, and Wine's hard-coded `/tmp/.wine-`. + - That is a project of its own, and its failure mode is a crash instead of a slow call. +- **During gameplay, proot already costs almost nothing**, thanks to patches 0004–0013. GPU submits + (`ioctl`), `futex`, `read`/`write`, `mmap`, `ppoll`/`epoll`, `sendmsg`/`recvmsg` and + `clock_gettime` (via the vDSO) all run at native speed under the filter. +- **What proot still costs** is every *path* syscall, `execve`, `brk`, thread and process creation, + and every Android-blocked syscall: **15–60 µs each**, against ~0.5 µs natively. All of it goes + through **one tracer thread**, which tops out around 75–95k traps/s. Steam start-up, game loading + and asset streaming, Proton's Python, and any burst on several threads at once all hit this. +- **Most of that cost is not proot's code but the two cross-core wake-ups per trap.** Any + out-of-process mechanism pays them: ptrace, and seccomp user-notification too (measured the same). + The only real fix is to not leave the process. +- **Recommendation: proot plus an in-process fast path.** A preloaded library answers the common + path calls inside the process. It sends them through a trampoline page that proot's filter lets + through, and it falls back to proot for anything it can't prove proot would answer the same way. + - The prototype is in this branch: `tools/proot/fastpath/` and `patches/0014`. + - Speed: `stat` 25 µs → 0.6 µs, `open` 25 µs → 0.9 µs, missing-file lookups 23 µs → 1.6 µs. + Eight threads doing `stat` went from 89k/s to 3.6M/s. + - Correctness: an equivalence suite of awkward paths produced byte-identical results. + - It only uses syscalls the app policy allows. + +## How a session runs today + +``` +SessionService ─HostProcess─▶ libproot.so (ONE tracer process, nice -6 after 2 s) + └─ /usr/bin/env -i … bannerlator-session steam + └─ gamescope ─▶ bannerlator-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree) + └─ reaper ─▶ bannerlator-game-env ─▶ bannerlator-proton ─▶ Valve ARM64 Proton (python) + └─ wine (arm64ec, FEX loaded in-process as a DLL) ─▶ wineserver, game.exe, … +``` + +- Every process in the tree, from the session script to the game, is a tracee of the same proot. +- The options are `--kill-on-exit`, `--kernel-release=…` (kompat, for the hostname), `-i uid:uid` + (fake_id0, for Xwayland's `setgid`/`setuid`), and about 25 binds: `/dev`, `/proc`, `/sys`, fake + `/proc` and `/sys` files, the GPU node, `/dev/shm`, storage and the game libraries. +- `/etc/ld.so.preload` loads `libblsession.so` (the `tools/linuxfs/preload/*.c` shims) into every + guest process. +- The Flatpak sandboxes (`BwrapSpawner`) already run in **separate** proot instances, started from + the Android side. + +### What proot does per traced syscall + +The tracee runs into proot's seccomp filter, which returns `SECCOMP_RET_TRACE`. Then: + +1. The kernel stops the tracee and wakes the tracer: a cross-core IPI, and possibly an exit from a + deep idle state. +2. The tracer reads the registers (`PTRACE_GETREGSET`) and reads the path (`process_vm_readv`). +3. It canonicalises the path against the rootfs and bindings: an `lstat` per component, or one + `O_PATH` open plus a `/proc/self/fd` readlink with patch 0002. +4. It writes the host path into a scratch area in the tracee (`process_vm_writev`) and updates the + registers. +5. It resumes the tracee, which is another cross-core wake-up. + +Calls flagged `FILTER_SYSEXIT` stop a second time on the way out, for example to translate the +result of `readlink` or `getcwd`. Android-blocked syscalls arrive as a `SIGSYS` signal stop instead, +and proot emulates them (`src/tracee/seccomp.c`). Fork, clone, exec and thread exit each add +`PTRACE_EVENT_*` stops. + +## Measurements + +The tools are in `tools/proot/bench/`; `run-device.sh` explains the device layout. + +**Caveat:** `adb shell` is *not* under the app seccomp filter. Mechanism costs are the same, but +syscalls that Android blocks only for apps (see below) can't be reproduced there. + +**Caveat:** the phone in Doze (screen off) runs everything about 7× slower. All numbers below were +taken with the screen awake. + +### Per call, as a session runs proot today + +`sysbench` in the patched (`patched`) and unpatched (`vanilla`) columns, using the session's own +proot options and a similar set of binds. + +| call | direct glibc | termux proot (vanilla) | this repo's proot (0001–0013) | + fast path (0014) | +|---|---|---|---|---| +| getppid / futex / ioctl / pread (untraced) | 0.1–0.4 µs | futex **16.6**, fstat **32.8**, ioctl **30.5** µs | 0.2–0.4 µs | 0.2–0.4 µs | +| stat, deep absolute path | 0.8 µs | 41.7 | 25.3 | **0.6** | +| open + close | 1.7 µs | 40.4 | 25.3 | **0.9** | +| access | | 27.2 | 26.2 | **1.0** | +| fstatat relative to a dirfd | | 45.5 | 26.8 | **1.9** | +| stat of a missing file | | 38.9 | 22.9 | **1.6** | +| readlink /proc/self/exe | | 42.8 | 38–43 | (proot) | +| getcwd / uname / brk | | 31–35 | 30–33 | (proot) | +| memfd_create | | 20.4 | 18.6 | (proot) | +| pthread create+join | | 58 | 48 | 48 | +| fork + exec + wait | ~1.1 ms | 1.16 ms | 0.70–0.78 ms | +0.2–0.4 ms (see open items) | + +The patches already in the repo pay off: kompat (0011) and fake_id0 (0012) took `futex`, `fstat` and +`sendmsg` off the trap list, and 0013 did the same for `ioctl`. + +### What one trap costs, by mechanism (`mechbench`; the handler does nothing) + +| mechanism | cost | +|---|---| +| native syscall | 0.1 µs | +| seccomp filter of 0 / 50 / 150 / 300 compares, allowed syscall | **no difference** (kernel ≥5.11 caches constant decisions per syscall number) | +| ptrace + seccomp, tracer and tracee on the **same core** | 6.2 µs (6.7 µs with register get/set) | +| ptrace + seccomp, **unpinned** (as the app runs it) | 15.5 µs | +| ptrace + seccomp, different cores (same cluster / across clusters) | 34–36 / 42–61 µs | +| ptrace with a spinning tracer, different cores | 20 µs (the tracee's side still has to wake) | +| seccomp user-notification (the "modern proot" design) | 5.7 µs same core, 34–58 µs across, 43 µs unpinned; `SYNC_WAKE_UP` doesn't help | +| **in-process SIGSYS** (`SECCOMP_RET_TRAP`) | **0.83 µs** | +| openat2 `RESOLVE_IN_ROOT` (kernel-side chroot lookup) | 1.0 µs, but **blocked in apps** | + +Takeaways: +- The tracer's own work is about 8 µs of the ~14 µs same-core trap. Everything above that is + scheduling. +- Moving to seccomp user-notification would be a rewrite for no gain. +- The filter's length doesn't matter. What matters is not breaking the kernel's cache: + - **Any check on an argument or the instruction pointer must come after the syscall-number + dispatch.** The first version of 0014 checked the address first. That made every syscall in + the session run the whole filter, and cost +0.5 ms per exec. + - Patches 0004 and 0013 already follow this rule. + +### One tracer for everything (`parstat`: N threads doing `stat`) + +| | 1 thread | 4 threads | 8 threads | +|---|---|---|---| +| one proot | 37k/s, 27 µs | 74–95k/s, 42–54 µs | 68–89k/s, **90–118 µs** each | +| two proots, 2 threads each | | 187k/s total, 21 µs | | +| proot + fast path | 1.64M/s, 0.6 µs | 2.76M/s | **3.60M/s, 2.2 µs** | + +A burst of path calls on several threads therefore queues behind the single tracer. CEF loading +assets, Proton's Python, a game streaming files on worker threads and the shader-cache threads all +produce such bursts. + +### CPU placement + +The workload ran with `taskset`; no fast path. Times are per traced call, and the last column is one +fork + exec. + +| affinity | stat | exec | +|---|---|---| +| default | 25.8 µs | 750 µs | +| one core | 14.0 µs | 370 µs | +| the two prime cores | 14.6 µs | | +| the six-core cluster | 25.9 µs | | + +The scheduler's wake-affine placement helps when it can put the tracer and the tracee together, and +spreading them apart hurts. **Never pin the tracer away from its tracees.** Today `raiseTracer` +only renices it, which is correct. + +### Whole programs + +Times are in ms, each with proot's ~40 ms start-up already subtracted. + +| | direct glibc | vanilla proot | this repo's proot | + fast path | +|---|---|---|---|---| +| python3: 10 stdlib imports | 75–89 | ~130 | ~110 | ~90 (878 of 879 path calls answered in-process) | +| tar of 2.7k files | 10–40 | ~180 | ~80 | ~20 | +| 200 × fork+exec of `true` | 230 | ~600 | ~500 | ~500 | +| find over 2.7k files | 16–23 | noisy | noisy | unchanged: glibc's fts calls internal entry points | + +## Android's app seccomp policy decides more than proot does + +The app filter is generated from bionic's `SYSCALLS.TXT`, minus the blocklists, plus the +allowlists. It is the same for every app whatever its `targetSdk`, and anything not listed gets +`SECCOMP_RET_TRAP`, a `SIGSYS`. Filters stack, and the strictest one wins: a blocked call never +reaches proot's `RET_TRACE`. It arrives as a `SIGSYS` stop, and proot answers it (usually `ENOSYS`). + +Checked against bionic `android16-release`: + +| syscall | app policy | who calls it | +|---|---|---| +| `rseq`, `set_robust_list` | **blocked** | glibc, at the start of every thread, while all signals are blocked | +| `faccessat2` | **blocked** | glibc's `faccessat()`, which tries it on **every** call before falling back, so 2 round trips (`access()` calls the plain `faccessat` syscall directly) | +| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx | +| `setuid`, `setgid` and the rest of the family | **blocked** | Xwayland (hence `-i`) | +| `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync, some engines | +| `clone3` | blocked before Android 15 | glibc `pthread_create` | +| `close_range` | blocked on Android 12 | | +| `statx`, `process_vm_readv`/`writev`, `memfd_create`, `pidfd_*`, `seccomp` | allowed | | + +On top of the ptrace round trips, every one of these costs a signal round trip through proot. + +## Every traced syscall: what it needs and whether it can bypass proot + +This is the filter of termux proot at `source.env` plus patches 0001–0013. "Fast path" means the +call can be answered in-process with the 0014 design. + +| syscalls | why proot traps them | can it bypass proot? | +|---|---|---| +| `openat`/`open`/`creat`, `newfstatat`/`stat`/`lstat`, `statx`, `faccessat`/`access`, `readlinkat` (exit), `mkdirat`, `unlinkat`, `renameat(2)`, `symlinkat`, `linkat`, `fchmodat`, `fchownat`, `utimensat`, `truncate`, `*xattr`, `inotify_add_watch`, `name_to_handle_at` | path translation (guest → host), plus exit fixups for `readlink` and `rename` | **Yes, through libc wrappers.** The prototype covers open, stat, statx, access, readlink, opendir and fopen; the write family is next. Raw `svc` and glibc's internal callers (ld.so, fts, realpath, nss) still go to proot. | +| `chdir`/`fchdir`, `getcwd` (exit) | proot emulates the cwd; until 0014 the kernel's cwd never moved | With 0014 the kernel's cwd follows the guest's. `getcwd` stays in proot (cheap, rare). | +| `execve`/`execveat` | runs the loader, maps `PT_INTERP` inside the rootfs, shebangs, tracks `/proc/self/exe` | **No.** This is the core of what proot is for. Costs 0.4–0.8 ms per exec; the session scripts already avoid exec-heavy loops (`nap`). | +| `brk` (entry and exit) | heap emulation: programs are mapped by proot's loader, so the kernel's brk area is the loader's | No (`PR_SET_MM` needs `CAP_SYS_RESOURCE`). **Fewer calls:** `GLIBC_TUNABLES=glibc.malloc.top_pad=…` grows the heap in larger steps. | +| `bind`, `connect`, `accept(4)`, `getsockname`, `getpeername` | `sun_path` of Unix sockets | Same-path binds (runtime dir, files dir) need no translation, but the filter can't see the address. Possible fast-path candidate. | +| `wait4`/`waitpid`, `ptrace` | ptrace emulation inside the guest (breakpad, gdb) | Could become opt-in; then `wait4` is free (one stop per wait today). | +| `prctl(PR_SET_DUMPABLE)`, `setrlimit`/`prlimit64(RLIMIT_STACK)` | loader and stack fixups | Already narrowed to these arguments (0004). | +| `ioctl(TCSETSF, termios2, FICLONE)` | Android pty policy, FICLONE `EACCES` | Already narrowed to these requests; GPU ioctls are free (0013). | +| `uname`, `sethostname`, `setdomainname` | the `DroidDeck` hostname (kompat) | Rare. Keep. | +| `set*id`, `*setxattr` | fake_id0 identity mode (0012) | Rare. Keep. | +| `memfd_create` | Qt JIT and php workarounds (string argument) | Every Wayland `wl_shm` buffer and Chromium shared memory pays one stop. Could be dropped if the DroidDeck runtime doesn't need those workarounds. | +| `statfs` (exit) | fakes tmpfs for `/dev/shm` | Rare. | +| `open_tree`, `move_mount`, `fspick`, `mount_setattr`, `openat2` | answered `ENOSYS` (0008, 0010) | Fine. | +| Android-blocked calls (previous table) | `SIGSYS` emulation | **Avoidable at the source:** `GLIBC_TUNABLES=glibc.pthread.rseq=0` removes one stop per thread. The rest need a patched glibc. | +| clone/fork/vfork/exec/exit events | tracking tracees | Needed while proot traces. | + +## Options weighed + +1. **Pure glibc, no proot.** Rejected as the main path, for the reasons in the short answer. + - Closest prior art: huntergdavis/steamclienttermux, which runs `steamrtarm64` on a patched + Termux glibc 2.44 with an exec shim and a `/tmp` and `/dev/shm` redirect preload. + - They report Steam's first window 7× faster and about 5% more FPS, with proot having used 60–65% + of a core. + - **They still run Proton and the games under proot.** + - DroidDeck also has four package names (`tools/release/variants.txt`), so a relocated prefix + would need four builds or Winlator-style padded path rewriting. +2. **Hybrid by process (Steam in proot, game on glibc, or the reverse).** The game side is the + *harder* half. Wine re-execs itself and `wineserver`, which hits `PT_INTERP`. Its server + directory is hard-coded under `/tmp`, and Proton's esync and fsync use `shm_open` (`/dev/shm`) + and `futex_waitv`, which is blocked. A game's steady state is already mostly untraced, so the + gain is in loading. Not worth it before the fast path. +3. **Seccomp user-notification instead of ptrace.** It measures the same as ptrace, and it can't + rewrite arguments, so every call would have to be emulated. No. +4. **In-process `SIGSYS` for everything** (0.8 µs). The kernel resets a blocked `SIGSYS` to the + default action and kills the process (`force_sig_seccomp`, `HANDLER_CURRENT`). glibc and Wine + block all signals around exactly these calls. Unsafe without also trapping `rt_sigprocmask`. No. +5. **proot plus an in-process fast path (this branch).** Recommended. Details below. +6. **A proot-aware glibc**, the follow-up to 5. Rebuild the rootfs's own glibc (same version, same + Arch package) with two changes: + - its internal path entry points (`__open_nocancel`, `__fstatat64`, ld.so's opens) go through the + fast path, which covers ld.so, fts, realpath and nss; + - it never makes the Android-blocked calls, as Termux's `fakesyscall.json` does. + + This removes the remaining library-loading traps and all the `SIGSYS` round trips. The rootfs is + versioned (`linuxfs-rN`), so glibc rebuilds stay under control. +7. **On the tracer side, with no in-process changes:** + - **A second proot for the game tree.** Measured 2.5× trap throughput, and Steam/CEF bursts no + longer stall the game. `BwrapSpawner` already shows the pattern: a stand-in process in the + session relays stdio, the exit status and signals to a proot started from the app. + - Leave the tracer unpinned (above). + - Set `GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216` in the session + environment. + +## The fast path (prototype) + +The code is `tools/proot/fastpath/fastpath.c`, preloaded into every guest process. On the proot side +it needs `patches/0014`, enabled with `PROOT_FASTPATH=1`. + +- **Trampoline.** The library maps a 4 KiB executable page at the fixed address `0xffff00000`. It + holds `mov x8,x0 … svc #0; ret`. With `PROOT_FASTPATH`, proot's filter checks the caller's address + just before each `RET_TRACE` and allows syscalls coming from that page. +- **Mapping a path.** The library takes the literal guest path and finds the longest binding whose + guest path is a prefix of it; otherwise the path is under the rootfs. + - It opens the *parent* host directory `O_PATH` and requires `/proc/self/fd` to name exactly that + path. That proves no component on the way was a symlink, so proot would have walked the same + directories. + - The last component is never followed: a symlink there goes to proot. +- **Missing files.** For a missing file, the deepest existing ancestor is verified the same way, and + the next component must not exist even as a symlink. Then `ENOENT` is exact. +- **What always goes to proot:** + - `..`, a trailing `/`, `/proc`, `/dev/fd` and `/dev/std*`; + - `O_TMPFILE`, `AT_EACCESS`, and any flag not handled; + - any doubt at all. "Go to proot" means calling the real libc function, so proot decides. +- **Relative paths.** Patch 0014 keeps the kernel's cwd at the guest cwd's host directory. Relative + and dirfd lookups are mapped through `/proc/self/{cwd,fd/N}`. +- **Verified-parent cache.** Verified parents are cached for 2 s (`PROOT_FP_TTL_MS`). That is the + same trade-off `preload/pathcache.c` already makes for the Steam client. A parent directory turned + into a symlink inside that window would be missed; `PROOT_FP_TTL_MS=0` verifies every call, at + 2.0 µs instead of 0.6 µs. +- **Allowed syscalls only.** It uses only `openat`, `newfstatat`, `statx`, `faccessat`, `readlinkat` + and `close`, all allowed for apps. A first version used `openat2 RESOLVE_IN_ROOT`, which the app + policy blocks. +- **Equivalence check.** `bench/equiv.py` runs stat, lstat, readlink, read, listdir and access over + awkward paths, from several working directories and through a dirfd. Its output is byte-identical + with and without the fast path, at TTL 0 and at TTL 2000. The paths include: + - absolute and relative symlinks into binds, `..` symlinks, dangling links and loops; + - files inside binds, nested binds (`/dev/shm` inside `/dev`) and `/proc`; + - missing paths behind symlinks. + +### Open items before shipping it + +- **Validate inside the app**, not just from adb. Three things to check: + - The app domain allows an anonymous `PROT_EXEC` page (`execmem`). If not, map a page of the + library file itself at the fixed address instead, as proot's loader does for every program. + - Nothing claims `0xffff00000` first. + - The stub's syscalls pass Android's filter. They should: all are allowed. +- **Integrate into `libblsession.so`** rather than a second preload. `opens.c` and `pathcache.c` + already define `open` and `access`, so their "real" call should become the fast path. That also + removes the second library's load cost, about 0.2–0.4 ms per exec that is still unexplained (see + the exec row above). +- **Wire the launcher.** The app already knows the binds (`LinuxRuntime.binds`): + - set `PROOT_FASTPATH=1` in proot's environment; + - pass `PROOT_FP_ROOT` and `PROOT_FP_BINDS` to the guest; + - add a Performance toggle next to "Run proot without seccomp", with the fast path off whenever + seccomp is off. +- **Cover the write family** (mkdir, unlink, rename, symlink, chmod, utimens) and Unix-socket + `connect`/`bind` for same-path binds. Then measure a real Steam start in the app, a game load, + and `PROOT_FP_STATS` hit rates for steam, steamwebhelper, wine and wineserver. +- **FEX**, inside Proton as a DLL, translates x86 path syscalls through glibc's `syscall()`, not the + wrappers, so they still trap. A `syscall()` hook could route `openat` and `newfstatat` there too. diff --git a/tools/build_local.sh b/tools/build_local.sh index 484e1f47b..1ed0e5b1b 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -117,6 +117,9 @@ docker run --rm --platform linux/amd64 \ aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ -o "$d/libblsession.so" tools/linuxfs/preload/*.c -ldl aarch64-linux-gnu-strip --strip-unneeded "$d/libblsession.so" + aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o "$d/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl + aarch64-linux-gnu-strip --strip-unneeded "$d/libblfastpath.so" for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$d/usr/local/bin/$(basename "$script")" done diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index ef781dc8a..342bf79e6 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -80,3 +80,15 @@ Added by DroidDeck: The `faccessat2` exit stop (termux 5ba8b95, for glibc's ENOSYS fallback on kernels before 5.8) and the `statx` one (emulation on kernels before 4.11) are dropped when the running kernel is newer. Host build: `ioctl` 31.6 -> 0.5 us; `stat`/`statx`/`faccessat2` about 44 -> 28 us (one stop). + +Prototype (inert unless `PROOT_FASTPATH` is set; see `docs/development/proot-performance.md`): + +- `0014-fastpath-trampoline.patch` - with `PROOT_FASTPATH`, every `SECCOMP_RET_TRACE` in the filter + is preceded by a check of the caller's address, and a syscall made from the fast path's trampoline + page (`fastpath/fastpath.c`, `0xffff00000`) runs without a stop: the tracee already translated it. + The check comes after the syscall-number dispatch, so untraced syscalls stay constant-ALLOW and keep + the kernel's seccomp action cache (checking first cost every syscall the full filter: +0.5 ms an + exec). `chdir`/`fchdir`, still emulated, also move the kernel's cwd to the host directory, and the + first tracee starts at `-w`'s, so relative lookups inside the tracee resolve where proot would. + SM8850 (adb shell): `stat` 25.3 -> 0.6 us, `open+close` 25.3 -> 0.9 us, ENOENT 22.9 -> 1.6 us, + 8 threads `stat`ing 89k -> 3.6M/s; equivalence suite (`bench/equiv.py`) byte-identical to proot. diff --git a/tools/proot/bench/equiv.py b/tools/proot/bench/equiv.py new file mode 100644 index 000000000..209d005ce --- /dev/null +++ b/tools/proot/bench/equiv.py @@ -0,0 +1,32 @@ +import os, sys +paths = ["/etc/os-release", "/root/abs-link", "/root/to-bind", "/root/to-bind/inbind.txt", "/root/rel-to-bind", + "/root/rel-to-bind/inbind.txt", "/root/dotdot", "/root/dotdot/libc.so.6", "/root/dangling", "/root/loop", + "/usr/lib/../lib/libc.so.6", "/usr//lib/./libc.so.6", "/lib", "/lib/libc.so.6", "/bin/env", "/nope", "/nope/x", + "/etc/os-release/x", "/mnt/droiddeck-sd", "/mnt/droiddeck-sd/inbind.txt", "/mnt/droiddeck-sd/../bannerlator", + "/dev/null", "/dev/shm", "/dev/shm/inbind.txt", "/dev/random", "/proc/self/exe", "/proc/version", "/proc", + "/sys/class/thermal/thermal_zone28/temp", "/sdcard", "/", "/tmp", "/root/in-root-abs-dir/os-release", + "/data/local/tmp/dd/tmp/inbind.txt", "/data/local/tmp/dd/mini/etc/os-release", "/root/to-bind/missing/x", "/root/to-bind/inbind.txt/x", "/usr/lib/nope/deeper/x.so", "/mnt/droiddeck-sd/nope/x", "/root/dangling/x", "/root/abs-link/x", "/nope/a/b/c", "/sdcard/nope/x"] +rels = ["os-release", "inbind.txt", "../etc/os-release", "abs-link", "to-bind/inbind.txt", "rel-to-bind", + "shm/inbind.txt", "null", "lib/libc.so.6", "mnt/droiddeck-sd/inbind.txt", "proc/version", "nope", "."] +def probe(p): + out = [] + for name, fn in [("stat", lambda: (lambda s: (oct(s.st_mode), s.st_size if not os.path.isdir(p) else 0))(os.stat(p))), + ("lstat", lambda: oct(os.lstat(p).st_mode)), + ("readlink", lambda: os.readlink(p)), + ("read", lambda: open(p, "rb").read(12)), + ("list", lambda: sorted(x for x in os.listdir(p) if not x.startswith("proot-"))[:6]), + ("access", lambda: (os.access(p, os.R_OK), os.access(p, os.W_OK), os.access(p, os.X_OK)))]: + try: out.append(f"{name}={fn()!r}") + except OSError as e: out.append(f"{name}=E{e.errno}") + return " ".join(out) +for p in paths: print(p, probe(p)) +for cwd in ["/", "/root", "/etc", "/mnt/droiddeck-sd", "/dev", "/usr/lib", "/data/local/tmp/dd/tmp", "/root/to-bind", "/root/dotdot"]: + try: os.chdir(cwd) + except OSError as e: print("chdir", cwd, e.errno); continue + print("== cwd", cwd, os.getcwd()) + for r in rels: print(" ", r, probe(r)) + d = os.open(".", os.O_RDONLY) + for r in rels[:6]: + try: print(" at", r, oct(os.stat(r, dir_fd=d).st_mode)) + except OSError as e: print(" at", r, "E", e.errno) + os.close(d) diff --git a/tools/proot/bench/mechbench.c b/tools/proot/bench/mechbench.c new file mode 100644 index 000000000..ae4319a95 --- /dev/null +++ b/tools/proot/bench/mechbench.c @@ -0,0 +1,274 @@ +/* Cost of the syscall-interception mechanisms available to an Android app, measured on the + * trapped call getppid() (the tracer/handler does no work, so this is pure mechanism cost). + * + * mechbench filter allowed syscall through a filter of n JEQs (linear, proot-style) + * mechbench tree allowed syscall through a binary-search filter (~150 entries) + * mechbench ptrace [spin] [regs] seccomp RET_TRACE + ptrace tracer (proot's mechanism) + * mechbench sigsys seccomp RET_TRAP + in-process SIGSYS handler + * mechbench notif [sync] [spin] seccomp RET_USER_NOTIF + supervisor thread + * mechbench openat2 openat2(RESOLVE_IN_ROOT) vs openat on the host path + * Env CPU_T / CPU_S pin the trapped thread / the tracer-supervisor to a cpu. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP +#define SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP (1UL << 0) +#endif +#ifndef SECCOMP_IOCTL_NOTIF_SET_FLAGS +#define SECCOMP_IOCTL_NOTIF_SET_FLAGS SECCOMP_IOW(4, __u64) +#endif + +#define TRAPPED SYS_getppid +static int N = 100000; + +static double now(void) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return t.tv_sec * 1e9 + t.tv_nsec; +} + +static void pin(const char *env) { + const char *v = getenv(env); + if (!v) return; + cpu_set_t s; + CPU_ZERO(&s); + CPU_SET(atoi(v), &s); + sched_setaffinity(0, sizeof s, &s); +} + +static double time_trapped(void) { + for (int i = 0; i < N / 10; i++) syscall(TRAPPED); + double t0 = now(); + for (int i = 0; i < N; i++) syscall(TRAPPED); + return (now() - t0) / N; +} + +/* A filter: arch check, then `n` JEQs on numbers that never match (like proot's list of ~150 + * traced syscalls seen by an allowed syscall), then `action` for TRAPPED, else ALLOW. */ +static int install(int n_linear, uint32_t action, unsigned flags) { + struct sock_filter *f = calloc(n_linear + 16, sizeof *f); + int k = 0; + f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); + f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0); + f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); + for (int i = 0; i < n_linear; i++) /* numbers far above any real syscall */ + f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 5000 + i, 0, 0); + f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, TRAPPED, 0, 1); + f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, action); + f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + struct sock_fprog p = {.len = k, .filter = f}; + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + return syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, flags, &p); +} + +/* ~150 traced numbers as a balanced binary search (what libseccomp's optimize=2 emits). */ +static int emit_tree(struct sock_filter *f, int *k, int lo, int hi) { + if (lo > hi) { f[(*k)++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); return 0; } + int mid = (lo + hi) / 2; + f[(*k)++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 5000 + mid, 0, 1); + f[(*k)++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE); + f[(*k)++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, 5000 + mid, 0, 1); + int ja = (*k)++; + int left = *k; + emit_tree(f, k, lo, mid - 1); + f[ja] = (struct sock_filter)BPF_STMT(BPF_JMP | BPF_JA, *k - left); + emit_tree(f, k, mid + 1, hi); + return 0; +} + +static void *sigsys_unused; +static void on_sigsys(int sig, siginfo_t *si, void *ctx) { + (void)sig; (void)si; + ucontext_t *uc = ctx; + uc->uc_mcontext.regs[0] = 4242; /* the emulated result */ +} + +struct notif_arg { int fd; int spin; }; +static void *supervisor(void *a) { + struct notif_arg *na = a; + pin("CPU_S"); + struct seccomp_notif *req = calloc(1, 256); + struct seccomp_notif_resp *resp = calloc(1, 256); + for (;;) { + memset(req, 0, sizeof *req); + if (ioctl(na->fd, SECCOMP_IOCTL_NOTIF_RECV, req) < 0) { + if (errno == EINTR) continue; + return NULL; + } + resp->id = req->id; + resp->val = 4242; + resp->error = 0; + resp->flags = 0; + ioctl(na->fd, SECCOMP_IOCTL_NOTIF_SEND, resp); + } +} + +static int notif_fd; +static int notif_flags_sync; +static void *notified_worker(void *a) { + (void)a; + pin("CPU_T"); + notif_fd = install(0, SECCOMP_RET_USER_NOTIF, SECCOMP_FILTER_FLAG_NEW_LISTENER); + return NULL; +} + +int main(int argc, char **argv) { + if (getenv("N")) N = atoi(getenv("N")); + const char *mode = argc > 1 ? argv[1] : "filter"; + if (!strcmp(mode, "filter")) { + pin("CPU_T"); + printf("no filter getppid %6.0f ns\n", time_trapped()); + int n = argc > 2 ? atoi(argv[2]) : 150; + if (install(n, SECCOMP_RET_ALLOW, 0) < 0) { perror("seccomp"); return 1; } + printf("linear %4d JEQs getppid %6.0f ns\n", n, time_trapped()); + return 0; + } + if (!strcmp(mode, "tree")) { + pin("CPU_T"); + printf("no filter getppid %6.0f ns\n", time_trapped()); + struct sock_filter f[2048]; + int k = 0; + f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); + emit_tree(f, &k, 0, 149); + struct sock_fprog p = {.len = k, .filter = f}; + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &p) < 0) { perror("seccomp"); return 1; } + printf("tree of 150 (%d insns) getppid %6.0f ns\n", k, time_trapped()); + return 0; + } + if (!strcmp(mode, "sigsys")) { + pin("CPU_T"); + struct sigaction sa = {0}; + sa.sa_sigaction = on_sigsys; + sa.sa_flags = SA_SIGINFO; + sigaction(SIGSYS, &sa, NULL); + if (install(0, SECCOMP_RET_TRAP, 0) < 0) { perror("seccomp"); return 1; } + long r = syscall(TRAPPED); + printf("sigsys trap (result %ld) %6.0f ns\n", r, time_trapped()); + (void)sigsys_unused; + return 0; + } + if (!strcmp(mode, "notif")) { + int sync = argc > 2 && strstr(argv[2], "sync"); + pthread_t w, s; + pthread_create(&w, NULL, notified_worker, NULL); + pthread_join(w, NULL); + if (notif_fd < 0) { perror("NEW_LISTENER"); return 1; } + if (sync) { + if (ioctl(notif_fd, SECCOMP_IOCTL_NOTIF_SET_FLAGS, SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP) < 0) perror("SYNC_WAKE_UP"); + else printf("(sync wake-up on)\n"); + } + /* The filter lives on thread w, which has exited; install again on a fresh worker that + * does the timing. */ + struct notif_arg na = {.fd = -1}; + (void)na; + printf("note: user-notif is measured in 'notif2'\n"); + return 0; + } + if (!strcmp(mode, "notif2")) { + /* Supervisor = child thread (unfiltered), timed thread = main after installing. */ + int sync = argc > 2 && !strcmp(argv[2], "sync"); + pin("CPU_T"); + int fd = install(0, SECCOMP_RET_USER_NOTIF, SECCOMP_FILTER_FLAG_NEW_LISTENER); + if (fd < 0) { perror("NEW_LISTENER"); return 1; } + if (sync && ioctl(fd, SECCOMP_IOCTL_NOTIF_SET_FLAGS, SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP, 0) < 0) perror("SYNC_WAKE_UP"); + /* The supervisor thread is created AFTER the filter, so it inherits it - but it only + * makes ioctl calls, which the filter allows. */ + static struct notif_arg na; + na.fd = fd; + pthread_t s; + pthread_create(&s, NULL, supervisor, &na); + long r = syscall(TRAPPED); + printf("user_notif%s (result %ld) %6.0f ns\n", sync ? "+sync" : "", r, time_trapped()); + return 0; + } + if (!strcmp(mode, "ptrace")) { + int spin = 0, regs = 0; + for (int i = 2; i < argc; i++) { if (!strcmp(argv[i], "spin")) spin = 1; if (!strcmp(argv[i], "regs")) regs = 1; } + pid_t c = fork(); + if (c == 0) { + pin("CPU_T"); + ptrace(PTRACE_TRACEME, 0, 0, 0); + raise(SIGSTOP); + if (install(0, SECCOMP_RET_TRACE, 0) < 0) { perror("seccomp"); _exit(1); } + printf("ptrace+seccomp%s%s %6.0f ns\n", spin ? " spin" : "", regs ? " +getregs+setregs" : "", time_trapped()); + fflush(stdout); + _exit(0); + } + pin("CPU_S"); + int st; + waitpid(c, &st, 0); + ptrace(PTRACE_SETOPTIONS, c, 0, PTRACE_O_TRACESECCOMP | PTRACE_O_EXITKILL); + ptrace(PTRACE_CONT, c, 0, 0); + for (;;) { + pid_t p; + if (spin) { do p = waitpid(c, &st, WNOHANG | __WALL); while (p == 0); } + else p = waitpid(c, &st, __WALL); + if (p < 0 || WIFEXITED(st) || WIFSIGNALED(st)) break; + int sig = 0; + if ((st >> 8) == (SIGTRAP | (PTRACE_EVENT_SECCOMP << 8))) { + if (regs) { + struct user_regs { uint64_t r[31], sp, pc, pstate; } ur; + struct iovec io = {&ur, sizeof ur}; + ptrace(PTRACE_GETREGSET, c, NT_PRSTATUS, &io); + ptrace(PTRACE_SETREGSET, c, NT_PRSTATUS, &io); + } + } else if (WIFSTOPPED(st) && WSTOPSIG(st) != SIGTRAP) sig = WSTOPSIG(st); + ptrace(PTRACE_CONT, c, 0, sig); + } + return 0; + } + if (!strcmp(mode, "openat2")) { + const char *root = argv[2], *path = argv[3]; + char host[4096]; + snprintf(host, sizeof host, "%s%s", root, path); + int rfd = open(root, O_PATH | O_DIRECTORY); + struct open_how how = {.flags = O_RDONLY | O_CLOEXEC, .resolve = RESOLVE_IN_ROOT}; + long fd = syscall(SYS_openat2, rfd, path, &how, sizeof how); + if (fd < 0) { perror("openat2 RESOLVE_IN_ROOT"); return 1; } + close(fd); + double t0 = now(); + for (int i = 0; i < N; i++) close(open(host, O_RDONLY | O_CLOEXEC)); + printf("openat host path %6.0f ns\n", (now() - t0) / N); + t0 = now(); + for (int i = 0; i < N; i++) close(syscall(SYS_openat2, rfd, path, &how, sizeof how)); + printf("openat2 RESOLVE_IN_ROOT %6.0f ns\n", (now() - t0) / N); + struct stat st; + t0 = now(); + for (int i = 0; i < N; i++) stat(host, &st); + printf("stat host path %6.0f ns\n", (now() - t0) / N); + struct open_how ph = {.flags = O_PATH | O_CLOEXEC, .resolve = RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS}; + t0 = now(); + for (int i = 0; i < N; i++) { int f = syscall(SYS_openat2, rfd, path, &ph, sizeof ph); fstat(f, &st); close(f); } + printf("openat2(O_PATH)+fstat+close %6.0f ns\n", (now() - t0) / N); + return 0; + } + fprintf(stderr, "unknown mode\n"); + return 2; +} diff --git a/tools/proot/bench/parstat.c b/tools/proot/bench/parstat.c new file mode 100644 index 000000000..0b229f080 --- /dev/null +++ b/tools/proot/bench/parstat.c @@ -0,0 +1,16 @@ +/* T threads each stat()ing a path for D seconds: aggregate trapped-syscall throughput. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +static const char *path; static volatile int stop; static long counts[64]; +static void *run(void *a) { long i = (long)a, n = 0; struct stat st; while (!stop) { stat(path, &st); n++; } counts[i] = n; return 0; } +int main(int c, char **v) { + path = v[1]; int T = atoi(v[2]); double D = c > 3 ? atof(v[3]) : 2; + pthread_t t[64]; for (long i = 0; i < T; i++) pthread_create(&t[i], 0, run, (void *)i); + struct timespec ts = {(time_t)D, (long)((D - (long)D) * 1e9)}; nanosleep(&ts, 0); stop = 1; + long tot = 0; for (int i = 0; i < T; i++) { pthread_join(t[i], 0); tot += counts[i]; } + printf("threads=%d %8.0f stat/s total %6.1f us/stat per thread\n", T, tot / D, T * D * 1e6 / tot); + return 0; } diff --git a/tools/proot/bench/run-device.sh b/tools/proot/bench/run-device.sh new file mode 100644 index 000000000..953a07af6 --- /dev/null +++ b/tools/proot/bench/run-device.sh @@ -0,0 +1,26 @@ +#!/system/bin/sh +# Runs a guest command on an adb-connected device the way a DroidDeck session runs proot (same +# options, a similar number of binds), as the shell user from /data/local/tmp/dd. Layout there: +# mini/ a glibc rootfs subset (glibc, bash, coreutils, python) + the bench binaries +# proot, loader this repo's proot (tools/proot/build.sh) -> variant "patched" +# proot-vanilla termux proot at source.env with no patches -> variant "vanilla" +# proot-fp this repo's proot incl. 0014 -> "fastpath" / "fpoff" +# mini/usr/lib/libfastpath.so tools/proot/fastpath/fastpath.c +# NOTE: adb shell is NOT under the app seccomp policy; see docs/development/proot-performance.md. +# +# run-device.sh cmd... +D=/data/local/tmp/dd +V=$1; shift +U=$(id -u) +REL="\\Linux\\DroidDeck\\$(uname -r)\\$(uname -v)\\aarch64\\localdomain\\-1\\" +BINDS="-b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -b /proc/self/fd:/dev/fd -b /proc/self/fd/0:/dev/stdin -b /proc/self/fd/1:/dev/stdout -b /proc/self/fd/2:/dev/stderr -b $D/tmp:/dev/shm -b $D -b /sdcard -b /data/local/tmp/dd/mini/etc/os-release:/proc/version -b $D/mini/etc/os-release:/proc/loadavg -b $D/mini/etc/os-release:/proc/uptime -b /sys/class/thermal/thermal_zone0/temp:/sys/class/thermal/thermal_zone28/temp -b $D/tmp:/mnt/droiddeck-sd -b $D/tmp:/mnt/bannerlator" +export PROOT_TMP_DIR=$D/tmp +case $V in + patched) export PROOT_LOADER=$D/loader; exec $D/proot --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; + noseccomp) export PROOT_LOADER=$D/loader PROOT_NO_SECCOMP=1; exec $D/proot --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; + vanilla) export PROOT_LOADER=$D/loader-vanilla; exec $D/proot-vanilla --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; + bare) export PROOT_LOADER=$D/loader; exec $D/proot --kill-on-exit -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; + fastpath) export PROOT_LOADER=$D/loader-fp PROOT_FASTPATH=bench; FPB=$(echo "$BINDS" | sed 's/-b //g; s/^ *//; s/ *$//; s/ */|/g'); exec $D/proot-fp --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 PROOT_FP_ROOT=$D/mini "PROOT_FP_BINDS=$FPB" PROOT_FP_KEY=bench LD_PRELOAD=/usr/lib/libfastpath.so $FPEXTRA "$@" ;; + fpoff) export PROOT_LOADER=$D/loader-fp; exec $D/proot-fp --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; + direct) cd $D/mini/root; exec $D/mini/usr/lib/ld-linux-aarch64.so.1 --library-path $D/mini/usr/lib "$@" ;; +esac diff --git a/tools/proot/bench/sysbench.c b/tools/proot/bench/sysbench.c new file mode 100644 index 000000000..628e36e46 --- /dev/null +++ b/tools/proot/bench/sysbench.c @@ -0,0 +1,78 @@ +/* Per-syscall latency under whatever runs this program (native, proot, ...). + * Usage: sysbench [iterations] + * Prints "name ns/op" lines. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static double now(void) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return t.tv_sec * 1e9 + t.tv_nsec; +} + +#define BENCH(name, n, body) \ + do { \ + for (int _w = 0; _w < (n) / 10 + 1; _w++) { body; } \ + double _t0 = now(); \ + for (int _i = 0; _i < (n); _i++) { body; } \ + printf("%-28s %10.0f ns\n", name, (now() - _t0) / (n)); \ + fflush(stdout); \ + } while (0) + +static void *thread_fn(void *a) { return a; } + +int main(int argc, char **argv) { + const char *deep = argc > 1 ? argv[1] : "/usr/lib/python3.14/os.py"; + int n = argc > 2 ? atoi(argv[2]) : 20000; + struct stat st; + char buf[4096]; + int pfd[2]; + pipe(pfd); + int fd = open(deep, O_RDONLY); + if (fd < 0) { perror(deep); return 1; } + int futexword = 0; + char *slash = strrchr(deep, '/'); + char dir[1024]; + snprintf(dir, sizeof dir, "%.*s", (int)(slash - deep), deep); + int dfd = open(dir, O_RDONLY | O_DIRECTORY); + + BENCH("getppid (untraced)", n, syscall(SYS_getppid)); + BENCH("futex wake (untraced)", n, syscall(SYS_futex, &futexword, FUTEX_WAKE_PRIVATE, 1, 0, 0, 0)); + BENCH("clock_gettime (vdso)", n, { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); }); + BENCH("fstat fd (untraced)", n, fstat(fd, &st)); + BENCH("pread 4k (untraced)", n, pread(fd, buf, sizeof buf, 0)); + BENCH("ioctl FIONREAD (untraced*)", n, { int v; ioctl(pfd[0], FIONREAD, &v); }); + BENCH("fstatat relative dirfd", n, fstatat(dfd, slash + 1, &st, 0)); + BENCH("stat deep abs path", n, stat(deep, &st)); + BENCH("lstat deep abs path", n, lstat(deep, &st)); + BENCH("statx deep abs path", n, { struct statx sx; statx(AT_FDCWD, deep, 0, STATX_BASIC_STATS, &sx); }); + BENCH("access deep abs path", n, access(deep, R_OK)); + BENCH("open+close deep abs", n, close(open(deep, O_RDONLY))); + BENCH("stat ENOENT", n, stat("/usr/lib/does/not/exist.so", &st)); + BENCH("readlink /proc/self/exe", n, readlink("/proc/self/exe", buf, sizeof buf)); + BENCH("getcwd", n, getcwd(buf, sizeof buf)); + BENCH("uname", n, { struct utsname_dummy { char b[65 * 6]; } u; syscall(SYS_uname, &u); }); + BENCH("memfd_create+close", n / 4, close(memfd_create("x", MFD_CLOEXEC))); + BENCH("brk(0) query", n, syscall(SYS_brk, 0)); + BENCH("mmap+munmap 64k", n, munmap(mmap(0, 65536, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0), 65536)); + BENCH("pthread create+join", n / 20, { pthread_t t; pthread_create(&t, 0, thread_fn, 0); pthread_join(t, 0); }); + BENCH("fork+_exit+wait", n / 100, { pid_t p = fork(); if (!p) _exit(0); waitpid(p, 0, 0); }); + if (argc > 3) { + char *const av[] = {argv[3], NULL}; + BENCH("fork+execve true+wait", n / 200, { pid_t p = fork(); if (!p) { execv(argv[3], av); _exit(127); } waitpid(p, 0, 0); }); + } + return 0; +} diff --git a/tools/proot/bench/workloads.sh b/tools/proot/bench/workloads.sh new file mode 100644 index 000000000..f09c0b9e5 --- /dev/null +++ b/tools/proot/bench/workloads.sh @@ -0,0 +1,26 @@ +#!/system/bin/sh +# workloads: direct glibc (patched PT_INTERP) vs proot variants +D=/data/local/tmp/dd; cd $D +t() { local s=$(date +%s%N); "$@" >/dev/null 2>&1; local e=$(date +%s%N); echo $(( (e - s) / 1000000 )); } +base() { t ./run.sh $1 /usr/bin/true; } +W1='import json,email.parser,http.client,asyncio,decimal,sqlite3,argparse,subprocess,logging,xml.etree.ElementTree' +for v in direct vanilla fpoff fastpath; do + input keyevent KEYCODE_WAKEUP + for rep in 1 2 3; do + if [ $v = direct ]; then + export LD_LIBRARY_PATH=$D/mini/usr/lib PYTHONHOME=$D/mini/usr PYTHONDONTWRITEBYTECODE=1 + a=$(t $D/dbin/python3.14 -c "$W1") + b=$(t $D/dbin/find $D/mini/usr/lib/python3.14 -type f) + c=$(t $D/dbin/bash -c "for i in \$($D/dbin/seq 200); do $D/dbin/true; done") + d=$(t $D/dbin/tar cf /dev/null $D/mini/usr/lib/python3.14) + unset LD_LIBRARY_PATH PYTHONHOME + else + a=$(t ./run.sh $v /usr/bin/env PYTHONDONTWRITEBYTECODE=1 /usr/bin/python3.14 -c "$W1") + b=$(t ./run.sh $v /usr/bin/find /usr/lib/python3.14 -type f) + c=$(t ./run.sh $v /usr/bin/bash -c 'for i in $(seq 200); do /usr/bin/true; done') + d=$(t ./run.sh $v /usr/bin/tar cf /dev/null /usr/lib/python3.14) + fi + [ $v = direct ] && z=0 || z=$(base $v) + echo "$v rep$rep: proot-start=${z}ms python-imports=${a}ms find-8k-files=${b}ms 200x-exec=${c}ms tar-80MB=${d}ms" + done +done diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c new file mode 100644 index 000000000..c7fb1f0b5 --- /dev/null +++ b/tools/proot/fastpath/fastpath.c @@ -0,0 +1,545 @@ +/* + * proot fast path: path syscalls answered inside the process, without a ptrace round trip. + * + * proot traps every path syscall into its tracer: 15-60 us a call on a Snapdragon, most of it + * the two cross-core wake-ups between tracee and tracer. This library, preloaded into every guest + * process, answers the common calls itself and issues the translated syscall from a trampoline + * page at a fixed address, which proot's seccomp filter lets through without a stop + * (PROOT_FASTPATH, tools/proot/patches/0014). + * + * 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. + * + * 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. + * + * 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 + * session's pathcache.c makes for the Steam client. + * + * Config (the launcher sets it): PROOT_FP_ROOT=, PROOT_FP_BINDS=; + * PROOT_FP_KEY=, with PROOT_FASTPATH= in proot's own environment: nothing is answered + * here unless the tracer is that proot (traced_by_match). + * PROOT_FP_OFF=1 disables it; PROOT_FP_STATS=1 reports hits and misses at exit. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define FP_STUB_ADDR 0xffff00000UL /* must match FASTPATH_STUB_ADDR in proot's seccomp.c */ +#define FP_STUB_SIZE 4096UL +#define MAX_BINDS 64 +#define FP_SLOW (-100000L) + +typedef long (*stub_fn)(long nr, long a, long b, long c, long d, long e, long f); +static stub_fn fp_sys; +static int fp_on; + +struct bind { char *host; size_t hlen; char *guest; size_t glen; int canon; }; +static struct bind binds[MAX_BINDS]; +static int nbinds; +static char root[PATH_MAX]; +static size_t rootlen; +static long long ttl_ns = 2000000000LL; +static unsigned long hits, misses; + +static long sc(long nr, long a, long b, long c, long d, long e) { return fp_sys(nr, a, b, c, d, e, 0); } +static void fp_init(void); +static pthread_once_t init_once = PTHREAD_ONCE_INIT; +/* Set up on the first call that could use it: a process that only execs on pays nothing. */ +static inline int fp_ready(void) { + pthread_once(&init_once, fp_init); + return fp_on; +} + +static int under(const char *path, const char *dir, size_t dlen) { + if (dlen == 1 && dir[0] == '/') return path[0] == '/'; + return strncmp(path, dir, dlen) == 0 && (path[dlen] == 0 || path[dlen] == '/'); +} + +/* ---------------------------------------------------------------- setup */ + +static void stats(void) { + char line[160]; + int n = snprintf(line, sizeof line, "fastpath[%d] %s: %lu fast, %lu to proot\n", getpid(), + program_invocation_short_name, hits, misses); + if (write(2, line, n) < 0) {} +} + +/* The canonical form of a host path (bindings may name /sdcard, /data/user/0, ...). */ +static void canonical(char *path, size_t size) { + long fd = sc(SYS_openat, AT_FDCWD, (long)path, O_PATH | O_CLOEXEC, 0, 0); + if (fd < 0) return; + 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 && (size_t)n < size) { out[n] = 0; memcpy(path, out, n + 1); } +} + +static pthread_mutex_t cache_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_mutex_t bind_lock; +static void after_fork(void) { pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&bind_lock, NULL); } + +/* + * The process is traced by a proot that lets the trampoline through and was given the same + * rootfs and bindings: its own environment carries PROOT_FASTPATH= (the launcher + * derives the key from them) and no PROOT_NO_SECCOMP. A process that inherited the variables but + * runs under another proot, or none, must not translate paths itself: a proot that traps the + * trampoline would translate the host path a second time. + */ +static int traced_by_match(const char *key) { + static char buf[32768]; /* under init_once */ + char path[48], want[160]; + long fd = sc(SYS_openat, AT_FDCWD, (long)"/proc/self/status", O_RDONLY | O_CLOEXEC, 0, 0); + if (fd < 0) return 0; + long n = sc(SYS_read, fd, (long)buf, 4095, 0, 0); + sc(SYS_close, fd, 0, 0, 0, 0); + if (n <= 0) return 0; + buf[n] = 0; + const char *line = strstr(buf, "\nTracerPid:"); + if (!line) return 0; + long tracer = strtol(line + 11, NULL, 10); + if (tracer <= 0) return 0; + snprintf(path, sizeof path, "/proc/%ld/environ", tracer); + if (snprintf(want, sizeof want, "PROOT_FASTPATH=%s", key) >= (int)sizeof want) return 0; + fd = sc(SYS_openat, AT_FDCWD, (long)path, O_RDONLY | O_CLOEXEC, 0, 0); + if (fd < 0) return 0; + size_t have = 0; + for (long r; have < sizeof buf - 1 && (r = sc(SYS_read, fd, (long)buf + have, sizeof buf - 1 - have, 0, 0)) > 0;) have += r; + sc(SYS_close, fd, 0, 0, 0, 0); + buf[have] = 0; + int found = 0; + for (size_t i = 0; i < have; i += strlen(buf + i) + 1) { + if (strcmp(buf + i, want) == 0) found = 1; + if (strncmp(buf + i, "PROOT_NO_SECCOMP=", 17) == 0) return 0; + } + return found; +} + +static void fp_init(void) { + const char *r = getenv("PROOT_FP_ROOT"), *b = getenv("PROOT_FP_BINDS"), *t = getenv("PROOT_FP_TTL_MS"); + const char *key = getenv("PROOT_FP_KEY"); + if (!r || !*r || !key || !*key || getenv("PROOT_FP_OFF")) return; + void *p = mmap((void *)FP_STUB_ADDR, FP_STUB_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); + if (p != (void *)FP_STUB_ADDR) { + if (p != MAP_FAILED) { munmap(p, FP_STUB_SIZE); return; } + if (errno != EEXIST) return; /* mapped already: this library loaded twice */ + } else { + static const uint32_t code[] = { + 0xAA0003E8, /* mov x8, x0 */ 0xAA0103E0, /* mov x0, x1 */ 0xAA0203E1, /* mov x1, x2 */ + 0xAA0303E2, /* mov x2, x3 */ 0xAA0403E3, /* mov x3, x4 */ 0xAA0503E4, /* mov x4, x5 */ + 0xAA0603E5, /* mov x5, x6 */ 0xD4000001, /* svc #0 */ 0xD65F03C0, /* ret */ + }; + memcpy(p, code, sizeof code); + __builtin___clear_cache((char *)p, (char *)p + sizeof code); + if (mprotect(p, FP_STUB_SIZE, PROT_READ | PROT_EXEC) != 0) { munmap(p, FP_STUB_SIZE); return; } + } + fp_sys = (stub_fn)FP_STUB_ADDR; + if (!traced_by_match(key)) { fp_sys = NULL; return; } + if (t) ttl_ns = atoll(t) * 1000000LL; + snprintf(root, sizeof root, "%s", r); + canonical(root, sizeof root); + rootlen = strlen(root); + for (const char *s = b; s && *s && nbinds < MAX_BINDS;) { + const char *end = strchr(s, '|'); + size_t len = end ? (size_t)(end - s) : strlen(s); + char spec[2 * PATH_MAX]; + if (len > 0 && len < sizeof spec) { + memcpy(spec, s, len); + spec[len] = 0; + char *colon = strchr(spec, ':'); + if (colon) *colon = 0; + struct bind *bd = &binds[nbinds]; + /* Canonicalised on first use: some (/sdcard) are FUSE, slow to look up at every exec. + * Static storage: malloc here would grow the heap with brk(2), which proot traps. */ + static char hosts[MAX_BINDS][PATH_MAX], guests[MAX_BINDS][512]; + const char *g = colon ? colon + 1 : spec; + if (strlen(g) >= sizeof guests[0]) { s = end ? end + 1 : NULL; continue; } + bd->host = hosts[nbinds]; + bd->guest = guests[nbinds]; + snprintf(bd->guest, sizeof guests[0], "%s", g); + snprintf(bd->host, PATH_MAX, "%.*s", PATH_MAX - 1, spec); + bd->hlen = strlen(bd->host); + bd->glen = strlen(bd->guest); + if (bd->guest[0] == '/' && bd->glen > 1 && bd->hlen > 0) nbinds++; + } + s = end ? end + 1 : NULL; + } + pthread_atfork(NULL, NULL, after_fork); /* registered once per exec */ + fp_on = 1; + if (getenv("PROOT_FP_STATS")) atexit(stats); +} + +static pthread_mutex_t bind_lock = PTHREAD_MUTEX_INITIALIZER; +static const struct bind *canon(struct bind *bd) { + if (__atomic_load_n(&bd->canon, __ATOMIC_ACQUIRE)) return bd; + if (pthread_mutex_trylock(&bind_lock) != 0) return NULL; + if (!bd->canon) { + canonical(bd->host, PATH_MAX); + bd->hlen = strlen(bd->host); + __atomic_store_n(&bd->canon, 1, __ATOMIC_RELEASE); + } + pthread_mutex_unlock(&bind_lock); + return bd; +} + +/* ---------------------------------------------------------------- verified parents */ + +#define SLOTS 256 +#define SLOT_PATH 240 /* longer directories are verified every time */ +struct slot { long long stamp; unsigned hash; char path[SLOT_PATH]; }; +static struct slot cache[SLOTS]; + +static long long now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC_COARSE, &ts); + return ts.tv_sec * 1000000000LL + ts.tv_nsec; +} + +static unsigned hash_of(const char *s) { + unsigned h = 2166136261u; + while (*s) h = (h ^ (unsigned char)*s++) * 16777619u; + return h; +} + +static int cached(const char *dir, unsigned h) { + if (ttl_ns <= 0 || pthread_mutex_trylock(&cache_lock) != 0) return 0; + struct slot *e = &cache[h % SLOTS]; + int hit = e->stamp && e->hash == h && now_ns() - e->stamp < ttl_ns && strcmp(e->path, dir) == 0; + pthread_mutex_unlock(&cache_lock); + return hit; +} + +static void remember(const char *dir, unsigned h) { + if (ttl_ns <= 0 || strlen(dir) >= SLOT_PATH || pthread_mutex_trylock(&cache_lock) != 0) return; + struct slot *e = &cache[h % SLOTS]; + e->hash = h; + snprintf(e->path, sizeof e->path, "%s", dir); + e->stamp = now_ns(); + pthread_mutex_unlock(&cache_lock); +} + +/* The host directory `dir` is reached by the kernel without crossing a symlink. */ +static long canonical_dir(const char *dir) { + unsigned h = hash_of(dir); + if (cached(dir, h)) return 1; + long fd = sc(SYS_openat, AT_FDCWD, (long)dir, O_PATH | O_DIRECTORY | O_CLOEXEC, 0, 0); + if (fd < 0) return fd; + 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; + out[n] = 0; + if (strcmp(out, dir) != 0) return 0; + remember(dir, h); + return 1; +} + +/* ---------------------------------------------------------------- guest -> host */ + +/* Guest paths never answered here: proot emulates /proc (self, exe, fd and cwd links). */ +static int reserved(const char *g) { + return under(g, "/proc", 5) || under(g, "/dev/fd", 7) || under(g, "/dev/stdin", 10) + || under(g, "/dev/stdout", 11) || under(g, "/dev/stderr", 11); +} + +/* The guest path of a host directory (the cwd, a dirfd): the longest host prefix wins. */ +static int host_to_guest(const char *h, char *g, size_t size) { + const struct bind *best = NULL; + for (int i = 0; i < nbinds; i++) + if (under(h, binds[i].host, binds[i].hlen) && (!best || binds[i].hlen > best->hlen)) best = &binds[i]; + if (under(h, root, rootlen) && (!best || rootlen >= best->hlen)) + return snprintf(g, size, "%s", h[rootlen] ? h + rootlen : "/") < (int)size ? 0 : -1; + if (best) return snprintf(g, size, "%s%s", best->guest, h + best->hlen) < (int)size ? 0 : -1; + return -1; +} + +/* + * The host path `path` (relative to dirfd, as the guest means it) names, whose parent directory + * is verified; 0, or FP_SLOW when proot must decide. + */ +static long resolve(int dirfd, const char *path, char *host) { + char guest[PATH_MAX], base[PATH_MAX]; + if (!path || !path[0] || !fp_ready()) return FP_SLOW; + size_t plen = strlen(path); + if (path[plen - 1] == '/' && plen > 1) return FP_SLOW; + if (path[0] == '/') { + if (plen >= sizeof guest) return FP_SLOW; + memcpy(guest, path, plen + 1); + } else { + char link[40], h[PATH_MAX]; + if (dirfd == AT_FDCWD) snprintf(link, sizeof link, "/proc/self/cwd"); + else snprintf(link, sizeof link, "/proc/self/fd/%d", dirfd); + long n = sc(SYS_readlinkat, AT_FDCWD, (long)link, (long)h, sizeof h - 1, 0); + if (n <= 0) return FP_SLOW; + h[n] = 0; + if (host_to_guest(h, base, sizeof base) < 0) return FP_SLOW; + if (snprintf(guest, sizeof guest, "%s/%s", strcmp(base, "/") ? base : "", path) >= (int)sizeof guest) return FP_SLOW; + } + /* Normalise "//" and "/./"; ".." goes to proot (it is not lexical once symlinks are involved). */ + char norm[PATH_MAX]; + size_t o = 0; + for (const char *c = guest; *c;) { + while (*c == '/') c++; + if (!*c) break; + const char *e = strchrnul(c, '/'); + size_t len = e - c; + if (len == 1 && c[0] == '.') { c = e; continue; } + if (len == 2 && c[0] == '.' && c[1] == '.') return FP_SLOW; + if (o + len + 2 >= sizeof norm) return FP_SLOW; + norm[o++] = '/'; + memcpy(norm + o, c, len); + o += len; + c = e; + } + if (o == 0) norm[o++] = '/'; + norm[o] = 0; + if (reserved(norm)) return FP_SLOW; + struct bind *bd = NULL; + for (int i = 0; i < nbinds; i++) + if (under(norm, binds[i].guest, binds[i].glen) && (!bd || binds[i].glen > bd->glen)) bd = &binds[i]; + if (bd && !canon(bd)) return FP_SLOW; + size_t base_len = bd ? bd->hlen : rootlen; + int n = bd ? snprintf(host, PATH_MAX, "%s%s", bd->host, norm + bd->glen) + : snprintf(host, PATH_MAX, "%s%s", root, strcmp(norm, "/") ? norm : ""); + if (n >= PATH_MAX) return FP_SLOW; + char *slash = strrchr(host, '/'); + if (!slash || slash == host) return FP_SLOW; + *slash = 0; + long ok = canonical_dir(host); + *slash = '/'; + if (ok == 1) return 0; + if (ok != -ENOENT) return FP_SLOW; + /* The parent is missing. The path is missing for proot too if the deepest directory that does + * exist is canonical and the next component below it is not there at all - not even as a + * symlink proot would follow. Only within the binding (or rootfs) the path was mapped into. */ + char probe[PATH_MAX]; + memcpy(probe, host, n + 1); + for (char *cut = strrchr(probe, '/'); cut && (size_t)(cut - probe) > base_len; ) { + *cut = 0; + char *up = strrchr(probe, '/'); + if (!up || (size_t)(up - probe) < base_len) break; + *up = 0; + long c = canonical_dir(probe[0] ? probe : "/"); + *up = '/'; + if (c == 1) { + struct stat st; + if (sc(SYS_newfstatat, AT_FDCWD, (long)probe, (long)&st, AT_SYMLINK_NOFOLLOW, 0) == -ENOENT) return -ENOENT; + return FP_SLOW; + } + if (c != -ENOENT) return FP_SLOW; + cut = up; + } + return FP_SLOW; +} + +/* The last component, not followed: 0 something that is not a symlink, 1 a symlink, <0 -errno. */ +static long kind(const char *host, struct stat *st) { + long r = sc(SYS_newfstatat, AT_FDCWD, (long)host, (long)st, AT_SYMLINK_NOFOLLOW, 0); + if (r < 0) return r; + return S_ISLNK(st->st_mode) ? 1 : 0; +} + +static int ret(long r) { + if (r < 0) { errno = (int)-r; return -1; } + return (int)r; +} + +#define REAL(name, type) \ + static __typeof__(type) real_##name; \ + if (!real_##name) real_##name = (type)dlsym(RTLD_NEXT, #name) + +/* ---------------------------------------------------------------- open */ + +static long fp_open(int dirfd, const char *path, int flags, mode_t mode) { + char host[PATH_MAX]; + if ((flags & __O_TMPFILE) == __O_TMPFILE) return FP_SLOW; + long rs = resolve(dirfd, path, host); + if (rs != 0) return rs; + /* The last component is never followed here: a symlink there is proot's to resolve. */ + long fd = sc(SYS_openat, AT_FDCWD, (long)host, flags | O_NOFOLLOW, (flags & O_CREAT) ? mode : 0, 0); + if (fd == -ELOOP && !(flags & O_NOFOLLOW)) return FP_SLOW; + if (fd >= 0 && (flags & O_PATH) && !(flags & O_NOFOLLOW)) { + struct stat st; + if (sc(SYS_fstat, fd, (long)&st, 0, 0, 0) != 0 || S_ISLNK(st.st_mode)) { + sc(SYS_close, fd, 0, 0, 0, 0); + return FP_SLOW; + } + } + return fd; +} + +static int do_openat(int dirfd, const char *path, int flags, mode_t mode, int (*real)(int, const char *, int, ...)) { + long r = fp_open(dirfd, path, flags, mode); + if (r != FP_SLOW) { hits++; return ret(r); } + misses++; + return real(dirfd, path, flags, mode); +} + +int openat(int dirfd, const char *path, int flags, ...) { + REAL(openat, int (*)(int, const char *, int, ...)); + mode_t mode = 0; + if (flags & (O_CREAT | __O_TMPFILE)) { va_list ap; va_start(ap, flags); mode = va_arg(ap, mode_t); va_end(ap); } + return do_openat(dirfd, path, flags, mode, real_openat); +} +int openat64(int dirfd, const char *path, int flags, ...) __attribute__((alias("openat"))); + +int open(const char *path, int flags, ...) { + REAL(openat, int (*)(int, const char *, int, ...)); + mode_t mode = 0; + if (flags & (O_CREAT | __O_TMPFILE)) { va_list ap; va_start(ap, flags); mode = va_arg(ap, mode_t); va_end(ap); } + return do_openat(AT_FDCWD, path, flags, mode, real_openat); +} +int open64(const char *path, int flags, ...) __attribute__((alias("open"))); +int __open_2(const char *path, int flags) { return open(path, flags); } +int __open64_2(const char *path, int flags) { return open(path, flags); } +int __openat_2(int d, const char *path, int flags) { return openat(d, path, flags); } +int __openat64_2(int d, const char *path, int flags) { return openat(d, path, flags); } + +/* ---------------------------------------------------------------- stat family */ + +static long fp_stat(int dirfd, const char *path, int flags, struct stat *st) { + char host[PATH_MAX]; + if (flags & ~AT_SYMLINK_NOFOLLOW) return FP_SLOW; + long rs = resolve(dirfd, path, host); + if (rs != 0) return rs; + long k = kind(host, st); + if (k == 1 && !(flags & AT_SYMLINK_NOFOLLOW)) return FP_SLOW; + return k < 0 ? k : 0; +} + +static int stat_common(int dirfd, const char *path, struct stat *st, int flags, + int (*real)(int, const char *, struct stat *, int)) { + long r = fp_stat(dirfd, path, flags, st); + if (r != FP_SLOW) { hits++; return ret(r); } + misses++; + return real(dirfd, path, st, flags); +} + +int fstatat(int dirfd, const char *path, struct stat *st, int flags) { + REAL(fstatat, int (*)(int, const char *, struct stat *, int)); + return stat_common(dirfd, path, st, flags, real_fstatat); +} +int fstatat64(int d, const char *p, struct stat64 *st, int f) { return fstatat(d, p, (struct stat *)st, f); } +int stat(const char *path, struct stat *st) { + REAL(fstatat, int (*)(int, const char *, struct stat *, int)); + return stat_common(AT_FDCWD, path, st, 0, real_fstatat); +} +int stat64(const char *p, struct stat64 *st) { return stat(p, (struct stat *)st); } +int lstat(const char *path, struct stat *st) { + REAL(fstatat, int (*)(int, const char *, struct stat *, int)); + return stat_common(AT_FDCWD, path, st, AT_SYMLINK_NOFOLLOW, real_fstatat); +} +int lstat64(const char *p, struct stat64 *st) { return lstat(p, (struct stat *)st); } + +int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *out) { + REAL(statx, int (*)(int, const char *, int, unsigned, struct statx *)); + char host[PATH_MAX]; + long rs = (flags & ~(AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT | AT_STATX_SYNC_TYPE)) ? FP_SLOW : resolve(dirfd, path, host); + if (rs == -ENOENT) { hits++; return ret(rs); } + if (rs == 0) { + long r = sc(SYS_statx, AT_FDCWD, (long)host, flags | AT_SYMLINK_NOFOLLOW, mask | STATX_TYPE, (long)out); + if (!(r == 0 && S_ISLNK(out->stx_mode) && !(flags & AT_SYMLINK_NOFOLLOW))) { hits++; return ret(r); } + } + misses++; + return real_statx(dirfd, path, flags, mask, out); +} + +/* ---------------------------------------------------------------- access */ + +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; + if (rs == -ENOENT) { hits++; return ret(rs); } + if (rs == 0) { + long k = kind(host, &st); + if (k != 1) { + hits++; + if (k < 0) return ret(k); + return ret(sc(SYS_faccessat, AT_FDCWD, (long)host, mode, 0, 0)); + } + } + misses++; + return real_faccessat(dirfd, path, mode, flags); +} +int faccessat(int dirfd, const char *path, int mode, int flags) { return access_common(dirfd, path, mode, flags); } +int access(const char *path, int mode) { return access_common(AT_FDCWD, path, mode, 0); } + +/* ---------------------------------------------------------------- readlink */ + +/* The link's own text needs no translation outside /proc: proot stores and returns it as is. */ +static ssize_t readlink_common(int dirfd, const char *path, char *buf, size_t size) { + REAL(readlinkat, ssize_t (*)(int, const char *, char *, size_t)); + char host[PATH_MAX]; + long rs = resolve(dirfd, path, host); + if (rs == -ENOENT) { hits++; return ret(rs); } + if (rs == 0) { + hits++; + long r = sc(SYS_readlinkat, AT_FDCWD, (long)host, (long)buf, size, 0); + return r < 0 ? ret(r) : r; + } + misses++; + return real_readlinkat(dirfd, path, buf, size); +} +ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t size) { return readlink_common(dirfd, path, buf, size); } +ssize_t readlink(const char *path, char *buf, size_t size) { return readlink_common(AT_FDCWD, path, buf, size); } + +/* ---------------------------------------------------------------- libc calls that open internally */ + +DIR *opendir(const char *path) { + REAL(opendir, DIR *(*)(const char *)); + long fd = fp_open(AT_FDCWD, path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NONBLOCK, 0); + if (fd == FP_SLOW) { misses++; return real_opendir(path); } + hits++; + if (fd < 0) { errno = (int)-fd; return NULL; } + DIR *d = fdopendir((int)fd); + if (!d) close((int)fd); + return d; +} + +static FILE *fopen_common(const char *path, const char *m, FILE *(*real)(const char *, const char *)) { + int flags; + switch (m[0]) { + case 'r': flags = strchr(m, '+') ? O_RDWR : O_RDONLY; break; + case 'w': flags = (strchr(m, '+') ? O_RDWR : O_WRONLY) | O_CREAT | O_TRUNC; break; + case 'a': flags = (strchr(m, '+') ? O_RDWR : O_WRONLY) | O_CREAT | O_APPEND; break; + default: return real(path, m); + } + if (strchr(m, 'e')) flags |= O_CLOEXEC; + if (strchr(m, 'x')) flags |= O_EXCL; + long fd = fp_open(AT_FDCWD, path, flags, 0666); + if (fd == FP_SLOW) { misses++; return real(path, m); } + hits++; + if (fd < 0) { errno = (int)-fd; return NULL; } + FILE *f = fdopen((int)fd, m); + if (!f) close((int)fd); + return f; +} +FILE *fopen(const char *path, const char *m) { REAL(fopen, FILE *(*)(const char *, const char *)); return fopen_common(path, m, real_fopen); } +FILE *fopen64(const char *path, const char *m) { REAL(fopen64, FILE *(*)(const char *, const char *)); return fopen_common(path, m, real_fopen64); } diff --git a/tools/proot/patches/0014-fastpath-trampoline.patch b/tools/proot/patches/0014-fastpath-trampoline.patch new file mode 100644 index 000000000..a865a5046 --- /dev/null +++ b/tools/proot/patches/0014-fastpath-trampoline.patch @@ -0,0 +1,205 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Wed, 30 Sep 2026 00:00:00 +0000 +Subject: [PATCH] fastpath: let translated syscalls from the tracee's trampoline + page through, and keep the kernel's cwd at the guest's + +With PROOT_FASTPATH set (and only then), every SECCOMP_RET_TRACE in the filter +is preceded by a check of the caller's address: a syscall made from the fast +path's trampoline page (tools/proot/fastpath, FASTPATH_STUB_ADDR) was already +translated inside the tracee and runs without a stop. The check follows the +syscall-number dispatch, so untraced syscalls stay constant-ALLOW and keep the +kernel's seccomp action cache. chdir/fchdir, emulated as before, also move the +kernel's cwd to the host directory (host "/" where the kernel cannot follow), +and the first tracee starts there, so relative lookups made inside the tracee +resolve where proot resolves them. Without PROOT_FASTPATH nothing changes. +--- +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -20,7 +20,8 @@ + * 02110-1301 USA. + */ + +-#include /* errno(3), E* */ ++#include ++#include /* errno(3), E* */ + #include /* talloc_*, */ + #include /* struct sockaddr_un, */ + #include /* SYS_*, */ +@@ -198,6 +199,20 @@ + if ((statl.st_mode & S_IXUSR) == 0) + return -EACCES; + ++ /* With the fast path, the kernel's cwd follows the guest's ++ * (at its host path), so relative lookups made inside the ++ * tracee resolve where proot would resolve them. */ ++ if (getenv("PROOT_FASTPATH") != NULL) { ++ int fd = open(path, O_PATH | O_DIRECTORY | O_CLOEXEC); ++ ++ /* Where the kernel could not follow, it goes to the ++ * host's "/", which no binding names: the fast path ++ * then sends every relative lookup to proot. */ ++ strcpy(oldpath, fd >= 0 ? path : "/"); ++ if (fd >= 0) ++ close(fd); ++ } ++ + /* Sadly this method doesn't detranslate statefully, + * this means that there's an ambiguity when several + * bindings are from the same host path: +@@ -229,6 +244,14 @@ + tracee->fs->cwd = tmp; + talloc_set_name_const(tracee->fs->cwd, "$cwd"); + ++ if (getenv("PROOT_FASTPATH") != NULL) { ++ /* fchdir(2) runs as it is; chdir(2) on the host path. */ ++ status = syscall_number == PR_chdir ++ ? set_sysarg_path(tracee, oldpath, SYSARG_1) ++ : 0; ++ break; ++ } ++ + set_sysnum(tracee, PR_void); + status = 0; + break; +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -35,6 +35,7 @@ + #include /* talloc_*, */ + #include /* E*, */ + #include /* memcpy(3), */ ++#include /* getenv(3), */ + #include /* offsetof(3), */ + #include /* uint*_t, UINT*_MAX, */ + #include +@@ -77,9 +78,95 @@ + * items). This function returns -errno if an error occurred, + * otherwise 0. + */ ++/* The fast path's trampoline page (tools/proot/fastpath): a traced syscall made from it was ++ * already translated inside the tracee and is let through without a stop. With PROOT_FASTPATH ++ * set, every SECCOMP_RET_TRACE is preceded by a check of the caller's address. The check sits ++ * after the syscall-number dispatch, so syscalls proot never traces stay constant-ALLOW and keep ++ * the kernel's seccomp action cache. FASTPATH_STUB_ADDR must match FP_STUB_ADDR in fastpath.c. */ ++#define FASTPATH_STUB_ADDR 0xffff00000ULL ++#define FASTPATH_STUB_SIZE 0x1000ULL ++#define LENGTH_FASTPATH_TAIL 7 ++ ++static bool fastpath_enabled(void) ++{ ++ static int enabled = -1; ++ if (enabled < 0) ++ enabled = getenv("PROOT_FASTPATH") != NULL; ++ return enabled; ++} ++ ++static bool is_ret_trace(const struct sock_filter *statement) ++{ ++ return statement->code == (BPF_RET + BPF_K) ++ && (statement->k & SECCOMP_RET_ACTION_FULL) == SECCOMP_RET_TRACE; ++} ++ ++static int add_statements_raw(struct sock_fprog *program, size_t nb_statements, ++ struct sock_filter *statements); ++ ++/* Append @statements, each RET_TRACE expanded into the trampoline check when the fast path is ++ * on, every jump within (or out of) the block adjusted for the expansion. */ + static int add_statements(struct sock_fprog *program, size_t nb_statements, + struct sock_filter *statements) + { ++ struct sock_filter out[64]; ++ size_t position[33]; ++ size_t i, o; ++ ++ if (!fastpath_enabled()) ++ return add_statements_raw(program, nb_statements, statements); ++ ++ assert(nb_statements <= 32); ++ for (i = 0, o = 0; i < nb_statements; i++) { ++ position[i] = o; ++ o += is_ret_trace(&statements[i]) ? LENGTH_FASTPATH_TAIL : 1; ++ } ++ position[nb_statements] = o; ++ ++#define NEW_TARGET(index) (((index) <= nb_statements) ? position[(index)] : position[nb_statements] + (index) - nb_statements) ++ ++ for (i = 0, o = 0; i < nb_statements; i++) { ++ struct sock_filter statement = statements[i]; ++ ++ if (BPF_CLASS(statement.code) == BPF_JMP) { ++ if (BPF_OP(statement.code) == BPF_JA) ++ statement.k = NEW_TARGET(i + 1 + statement.k) - position[i] - 1; ++ else { ++ statement.jt = NEW_TARGET(i + 1 + statement.jt) - position[i] - 1; ++ statement.jf = NEW_TARGET(i + 1 + statement.jf) - position[i] - 1; ++ } ++ } ++ ++ if (!is_ret_trace(&statement)) { ++ out[o++] = statement; ++ continue; ++ } ++ ++ { ++ const size_t ip = offsetof(struct seccomp_data, instruction_pointer); ++ const uint32_t hi = (uint32_t) (FASTPATH_STUB_ADDR >> 32); ++ const uint32_t lo = (uint32_t) FASTPATH_STUB_ADDR; ++ struct sock_filter tail[LENGTH_FASTPATH_TAIL] = { ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, ip + sizeof(uint32_t)), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, hi, 0, 4), ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, ip), ++ BPF_JUMP(BPF_JMP + BPF_JGE + BPF_K, lo, 0, 2), ++ BPF_JUMP(BPF_JMP + BPF_JGE + BPF_K, lo + (uint32_t) FASTPATH_STUB_SIZE, 1, 0), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW), ++ statement, ++ }; ++ memcpy(&out[o], tail, sizeof(tail)); ++ o += LENGTH_FASTPATH_TAIL; ++ } ++ } ++#undef NEW_TARGET ++ ++ return add_statements_raw(program, o, out); ++} ++ ++static int add_statements_raw(struct sock_fprog *program, size_t nb_statements, ++ struct sock_filter *statements) ++{ + size_t length; + void *tmp; + size_t i; +@@ -211,8 +298,16 @@ + return add_statements(program, LENGTH_TRACE_IOCTL, statements); + } + ++static size_t trace_length_raw(Sysnum sysnum); ++ + static size_t trace_length(Sysnum sysnum) + { ++ /* Each trace block has exactly one RET_TRACE (see add_statements). */ ++ return trace_length_raw(sysnum) + (fastpath_enabled() ? LENGTH_FASTPATH_TAIL - 1 : 0); ++} ++ ++static size_t trace_length_raw(Sysnum sysnum) ++{ + switch (sysnum) { + case PR_prctl: + case PR_setrlimit: +--- a/src/tracee/event.c ++++ b/src/tracee/event.c +@@ -98,6 +98,15 @@ + if (getenv("PROOT_NO_SECCOMP") == NULL) + (void) enable_syscall_filtering(tracee); + ++ /* The fast path resolves relative paths against the ++ * kernel's cwd: start it at the guest's (see chdir in ++ * syscall/enter.c). */ ++ if (getenv("PROOT_FASTPATH") != NULL) { ++ char host_cwd[PATH_MAX]; ++ if (translate_path(tracee, host_cwd, AT_FDCWD, tracee->fs->cwd, true) >= 0) ++ (void) chdir(host_cwd); ++ } ++ + /* Now process is ptraced, so the current rootfs is already the + * guest rootfs. Note: Valgrind can't handle execve(2) on + * "foreign" binaries (ENOEXEC) but can handle execvp(3) on such