Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ object ConfigCache {

Log.d(TAG, "Cache Update Complete. Map Swap successful.")

// Published right after the swap, because it describes the same set of scopes the swap just
// made current: a HyperOS process forked a moment from now has to see the configuration the
// daemon is already answering with, not the one it replaced.
publishHyosRuntimeIndex(newScopes)

// Targets are removed only after the module set has been published.
(oldState.modules.keys - newModules.keys).forEach {
FrameworkService.forgetHotReloadTargets(it)
Expand All @@ -456,6 +461,63 @@ object ConfigCache {
}
}

/**
* Writes the index a process spawned by the HyperOS Rust Runtime's spawner reads to find the
* modules in its scope, and stages the libraries it names.
*
* This is the only one of the two ways a process learns what to load that does not go through the
* daemon's binder: those processes have no JVM, so no daemon service can exist in them, and no
* module list can be fetched over IPC. The scope map is the same one every other reader gets, so
* what a HyperOS process sees is exactly what an ART process of the same name would.
*
* Nothing happens on a device without that runtime, and nothing happens for a module that
* declares no native libraries — there is no `native_init` to call and so nothing to load.
*/
private fun publishHyosRuntimeIndex(scopes: Map<ProcessScope, List<LoadedModule>>) {
if (!FileSystem.hasHyosRuntime()) return

setupMiscPath()
val misc = state.miscPath ?: return

// Each module is extracted once rather than once per process it is scoped to: extraction is the
// expensive half of this, and every reader gets the same file names from the same copy. A null
// is a module that ships nothing this ABI can load — an ordinary answer, and one worth
// remembering so the next scope does not ask again.
val staged =
scopes.values
.asSequence()
.flatten()
.filter { !it.code?.moduleLibraryNames.isNullOrEmpty() }
.distinctBy { it.packageName }
.associate { module ->
module.packageName to
FileSystem.stageHyosNativeLibraries(misc, module.packageName, module.apkPath)
}

val index = mutableMapOf<String, List<HyosModuleLibrary>>()
scopes.forEach { (scope, modules) ->
val libraries =
modules.flatMap { module ->
val dir = staged[module.packageName] ?: return@flatMap emptyList()
module.code?.moduleLibraryNames.orEmpty().mapNotNull { name ->
val path = Paths.get(dir, name)
if (Files.isReadable(path)) {
HyosModuleLibrary(module.packageName, name, path.toString())
} else {
// The module named a library its APK does not carry for this ABI. It still loads
// everywhere else; only this half of it has nothing to load.
Log.w(TAG, "Module ${module.packageName} declares $name, which is not in $dir")
null
}
}
}
if (libraries.isNotEmpty()) index[scope.processName] = libraries
}

FileSystem.pruneHyosNativeLibraries(misc, staged.filterValues { it != null }.keys)
FileSystem.publishHyosIndex(index)
}

fun getModulesForProcess(processName: String, uid: Int): List<LoadedModule> {
ensureCacheReady()
if (processName == "system_server") {
Expand Down
172 changes: 168 additions & 4 deletions daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.os.Process
import android.os.RemoteException
import android.os.SELinux
import android.os.SharedMemory
import android.os.SystemProperties
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
Expand Down Expand Up @@ -50,6 +51,52 @@ private const val TAG = "VectorFileSystem"
*/
private const val SYSTEM_FILE_CONTEXT = "u:object_r:system_file:s0"

/**
* The properties the HyperOS Rust Runtime advertises itself with.
*
* Read rather than looking for `/system_ext/bin/hyos_spawner`: the runtime can be switched off on a
* device that still ships that binary, and an index nothing will ever read is work done for
* nothing. These are the same two properties LSPosed 2.2.0 reads for the same decision, in its
* daemon's `ILSPManagerService` transaction 67; they were recovered from its released daemon.
*/
private const val HYOS_ACTIVE_PROPERTY = "rust.runtime_active"
private const val HYOS_VERSION_PROPERTY = "rust.runtime_version"

/**
* The index of what each HyperOS Runtime process should load.
*
* It sits inside Vector's own tree rather than in the shared /data/misc, because nothing but the
* companion reads it: the per-process libraries are looked up by the companion and handed to the
* spawner before it forks, so the file never has to be reachable by the application it describes.
* That is the whole reason there is no pointer file any more and no path published anywhere — the
* channel is the companion connection, not the filesystem.
*
* Magisk holds /data/adb at mode 0700 owned by root, so this subtree is root-only by construction.
*/
private const val HYOS_INDEX_DIR = "hyos"
private const val HYOS_GENERATION_NAME = ".generation"

/**
* The first line of every index, and the version of the format it describes.
*
* A file that does not start with it is not one of ours, which is the only thing between a
* half-written file and the spawner acting on it. The two sides of this format are this file and
* `zygisk/src/main/cpp/hyos_runtime.cpp`, and nothing else reads it.
*/
private const val HYOS_INDEX_MAGIC = "vector-hyos 1"

/**
* The subdirectory of the misc root holding copies of module libraries that a process cannot map
* out of the module's APK.
*
* Two of them, because the two readers have different lifetimes and each prunes what it no longer
* needs. system_server's copies are pruned down to the modules still bound for system_server; the
* HyperOS Runtime's, to the modules still in some scope. Sharing one directory would have each
* pass delete the other's copies, leaving whichever reader ran first with nothing to load.
*/
private const val STAGED_LIBRARY_DIR = "lib"
private const val HYOS_LIBRARY_DIR = "libhyos"

/**
* What came of trying to load a module APK.
*
Expand All @@ -74,6 +121,16 @@ sealed interface ModuleLoad {
val ModuleLoad.apkOrNull: ModuleCode?
get() = (this as? ModuleLoad.Loaded)?.apk

/**
* One native library the index names for one process: which module asked for it, the name that
* module declared, and the absolute path the process should load it from.
*/
data class HyosModuleLibrary(
val modulePackage: String,
val libraryName: String,
val libraryPath: String,
)

object FileSystem {
val basePath: Path = Paths.get("/data/adb/lspd")
val logDirPath: Path = basePath.resolve("log")
Expand Down Expand Up @@ -488,11 +545,19 @@ object FileSystem {
*
* Returns null when the module ships nothing for this ABI or the copy failed, in which case the
* module still loads and only its native part fails, exactly as it does today.
*
* [dirName] selects which of the readers the copy is for; see [STAGED_LIBRARY_DIR]. The two sets
* are kept apart because each is pruned against a different notion of "still needed".
*/
fun stageNativeLibraries(root: Path, packageName: String, apkPath: String): String? =
fun stageNativeLibraries(
root: Path,
packageName: String,
apkPath: String,
dirName: String = STAGED_LIBRARY_DIR,
): String? =
runCatching {
val apk = File(apkPath)
val dir = root.resolve("lib").resolve(packageName)
val dir = root.resolve(dirName).resolve(packageName)

// Re-extract only when the APK behind the copy changed. Getting this wrong in the
// lenient direction would leave system_server running a module's superseded native
Expand Down Expand Up @@ -546,10 +611,14 @@ object FileSystem {
* Drops staged libraries belonging to modules that are no longer bound for system_server, so an
* uninstalled or rescoped module does not leave a copy of its native code behind for good.
*/
fun pruneStagedNativeLibraries(root: Path?, keep: Set<String>) {
fun pruneStagedNativeLibraries(
root: Path?,
keep: Set<String>,
dirName: String = STAGED_LIBRARY_DIR,
) {
if (root == null) return
runCatching {
val libRoot = root.resolve("lib")
val libRoot = root.resolve(dirName)
if (!libRoot.isDirectory()) return
Files.list(libRoot).use { stream ->
stream
Expand All @@ -560,6 +629,101 @@ object FileSystem {
.onFailure { Log.e(TAG, "Failed to prune staged native libraries", it) }
}

/** Whether this device runs applications on the HyperOS Rust Runtime at all. */
fun hasHyosRuntime(): Boolean =
SystemProperties.getBoolean(HYOS_ACTIVE_PROPERTY, false) &&
SystemProperties.get(HYOS_VERSION_PROPERTY).orEmpty().isNotEmpty()

/**
* Stages a module's native libraries for the HyperOS Runtime's reader rather than system_server's.
*
* A process there cannot be told a search path the way an injected ART process can, so it is
* handed absolute paths in the index below and needs the libraries to exist at those paths. The
* APK itself would do — /data/app is readable and mappable by any app domain — but the entry has
* to be STORED inside the zip for the loader to open it, and a module that compressed its
* libraries would silently lose its native part. A copy has neither problem.
*/
fun stageHyosNativeLibraries(root: Path, packageName: String, apkPath: String): String? =
stageNativeLibraries(root, packageName, apkPath, HYOS_LIBRARY_DIR)

/** Drops HyperOS-staged libraries of modules that are in no scope any more. */
fun pruneHyosNativeLibraries(root: Path?, keep: Set<String>) {
pruneStagedNativeLibraries(root, keep, HYOS_LIBRARY_DIR)
}

/**
* Publishes what each HyperOS Runtime process should load.
*
* Written 0600 inside Vector's own root-owned tree: the only reader is the companion, which runs
* as root, and the only consumer is the spawner, which inherits the answer through the fork. No
* application is meant to see this, and none can reach the directory to try.
*
* A process with no file is in nobody's scope. Files for processes that stopped being in scope
* are removed, and the generation stamp is rewritten last, so a reader that refreshes on a
* changed generation never sees a half-written set.
*/
fun publishHyosIndex(index: Map<String, List<HyosModuleLibrary>>) {
runCatching {
val dir = basePath.resolve(HYOS_INDEX_DIR)
Files.createDirectories(dir)
Os.chmod(dir.toString(), "700".toInt(8))

val written = mutableSetOf<String>()
index.forEach { (processName, libraries) ->
// A process name is a manifest string that becomes a file name here. Android's own
// validation is not something to lean on for a write performed as root, so anything
// that is not a plain name is refused rather than resolved.
if (processName.isEmpty() || processName.contains('/') || processName == "." ||
processName == "..") {
Log.w(TAG, "Refusing to publish an index under the name '$processName'")
return@forEach
}
val target = dir.resolve(processName)
val text =
buildString {
appendLine(HYOS_INDEX_MAGIC)
libraries.forEach {
append(it.modulePackage)
append('\t')
append(it.libraryName)
append('\t')
append(it.libraryPath)
append('\n')
}
}
Files.writeString(
target,
text,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
Os.chmod(target.toString(), "600".toInt(8))
written.add(processName)
}

Files.list(dir).use { stream ->
stream
.filter {
val name = it.fileName.toString()
name != HYOS_GENERATION_NAME && name !in written
}
.forEach { it.toFile().delete() }
}

// Written last, and only after the entries are in place: it is what a reader keys on, so
// a generation that has moved promises a set that is already complete.
val generation = dir.resolve(HYOS_GENERATION_NAME)
Files.writeString(
generation,
System.currentTimeMillis().toString(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
Os.chmod(generation.toString(), "600".toInt(8))
}
.onFailure { Log.e(TAG, "Failed to publish the HyperOS Runtime index", it) }
}

fun toGlobalNamespace(path: String): File {
return if (path.startsWith("/")) File("/proc/1/root", path) else File("/proc/1/root/$path")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ data class AppInfo(
val appName: String,
val isSystemApp: Boolean,
val isGame: Boolean,
/**
* Whether the app announced itself as one that runs on the HyperOS Rust Runtime.
*
* HyperOS applications carry the name of the runtime library they want loaded in the
* `hyperos_app_lib_name` metadata entry of their manifest, and the entry's presence is what
* marks them — its value is for the runtime, not for us. That is the same test LSPosed 2.2.0's
* manager applies for the same label, recovered from its released build.
*
* It says nothing about whether the runtime is actually being injected into them; that is a
* property of the device, not of the app, and is answered separately.
*/
val isHyperOsRuntime: Boolean = false,
val isSelectedInScope: Boolean,
/**
* In the scope without anyone having put it there, and not removable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import org.matrix.vector.manager.data.model.versionCodeCompat
import org.matrix.vector.manager.ipc.DaemonClient
import org.matrix.vector.manager.logW

/**
* The manifest metadata entry a HyperOS application uses to name its Rust runtime library.
*
* Recovered from LSPosed 2.2.0's manager, which reads this same key for the same label. The entry
* is written by the application, so what it says is a claim rather than a fact; the framework only
* ever shows it next to the app, which is why that is an acceptable way to learn it.
*/
private const val HYPEROS_APP_LIB_NAME = "hyperos_app_lib_name"

/** Fetches and caches the list of installed applications from the daemon. */
class AppRepository(
private val daemonClient: DaemonClient,
Expand Down Expand Up @@ -122,12 +131,18 @@ class AppRepository(

val userId = appInfo.uid / PER_USER_RANGE

// GET_META_DATA is part of the flags the daemon is asked with, so the bundle is
// already here and reading it costs nothing. An absent entry and an empty one mean
// the same thing, and both are the ordinary case.
val hyperOsLibrary = appInfo.metaData?.getString(HYPEROS_APP_LIB_NAME)

AppInfo(
packageName = pkg.packageName,
userId = userId,
appName = appInfo.loadLabel(packageManager).toString(),
isSystemApp = isSystem,
isGame = isGame,
isHyperOsRuntime = !hyperOsLibrary.isNullOrEmpty(),
isSelectedInScope = false, // To be merged later in the ViewModel
isRecommended = false,
lastUpdateTime = pkg.lastUpdateTime,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,20 @@ private fun AppRow(
color = ring,
)
}
// A property of the app rather than of the scope, so it is stated in its own colour
// and on its own line: the origin label above says why the row is in the list, and
// this says what the row cannot be expected to behave like. A HyperOS application
// runs on the Rust runtime, where none of the Java side of the framework exists, so
// what a module hooks there is its native half and nothing else — which is a
// difference worth stating before the user wonders why nothing happened.
if (app.isHyperOsRuntime) {
Text(
text = stringResource(R.string.scope_hyperos_runtime),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
// Why this row does not behave like the rest: the framework is one process shared
// by every user, and a legacy module's own app is in the scope without anyone
// having put it there. Both are things a checkbox cannot say.
Expand Down
2 changes: 2 additions & 0 deletions manager/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@
<string name="scope_no_match">No app matches that search or filter.</string>
<string name="scope_system_apps">System apps</string>
<string name="scope_games">Games</string>
<!-- Shown on an app whose manifest declares the HyperOS Rust Runtime's library entry. -->
<string name="scope_hyperos_runtime">HyperOS</string>
<string name="scope_recommended">Requested by this module</string>
<string name="scope_static">This module declares a fixed scope and cannot be pointed at other apps.</string>
<!-- e.g. "3 to add, 1 to remove" -->
Expand Down
2 changes: 1 addition & 1 deletion native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This module defines the central abstractions and manages the runtime state. It's

- **`Context`**: An abstract base class that defines the injection lifecycle. It contains pure virtual methods like `LoadDex` and `SetupEntryClass`. The consumer of this library (e.g., the Zygisk module) must inherit from `Context` and provide the concrete implementations for these steps.
- **`ConfigBridge`**: A simple, native-side singleton that acts as a cache for configuration data (specifically, the obfuscation map) that is fetched and provided by the consumer.
- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks.
- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks. The library names normally arrive over JNI from the injected framework, and the hook engine behind the API is Dobby, but neither is required: `SetHookBackend` lets a runtime supply its own primitives, which is how the HyperOS Rust Runtime path (see `zygisk/src/main/cpp/hyos_runtime.cpp`) drives this same code from a process that has no JVM at all.

### `elf` - Symbol Resolution

Expand Down
Loading