From 911b10f258311c7c46f3313ac06d298b55880357 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 00:07:38 +0800 Subject: [PATCH 01/24] ksud: Let external Zygisk providers win and drop module install gating Provider detection no longer gates module installation or activation: ksud installs archives through the upstream path, with no Zygisk lock and no conflict or marker checks. An active external Zygisk provider simply takes precedence, and the built-in engine steps aside at boot by reading the provider set before touching the boot guard. The Manager drops preflight validation and reports the effective provider instead. Enable persists the configuration before scanning providers, so a failing scan can no longer turn a completed enable into a CLI error. --- README.md | 2 +- README_CN.md | 2 +- .../data/model/FolkZygiskPreflight.kt | 28 - .../data/model/ModuleMutationResult.kt | 17 - .../data/repository/SettingsRepository.kt | 2 +- .../data/repository/SettingsRepositoryImpl.kt | 12 +- .../kernelsu/ui/screen/flash/FlashUtils.kt | 21 +- .../ui/screen/module/ModuleMaterial.kt | 2 - .../kernelsu/ui/screen/module/ModuleScreen.kt | 1 - .../ui/screen/module/ModuleUiState.kt | 3 - .../screen/settings/FolkZygiskStatusItem.kt | 33 +- .../ui/screen/settings/SettingsUiState.kt | 2 +- .../java/me/weishu/kernelsu/ui/util/KsuCli.kt | 78 +- .../kernelsu/ui/util/ModulePropUtils.kt | 46 +- .../kernelsu/ui/viewmodel/HomeViewModel.kt | 18 +- .../kernelsu/ui/viewmodel/ModuleViewModel.kt | 67 +- .../ui/viewmodel/SettingsViewModel.kt | 4 +- .../app/src/main/res/values-ar/folkzygisk.xml | 18 +- .../app/src/main/res/values-es/folkzygisk.xml | 18 +- .../app/src/main/res/values-in/folkzygisk.xml | 18 +- .../app/src/main/res/values-ja/folkzygisk.xml | 18 +- .../app/src/main/res/values-ko/folkzygisk.xml | 18 +- .../src/main/res/values-mgl/folkzygisk.xml | 18 +- .../app/src/main/res/values-pl/folkzygisk.xml | 18 +- .../src/main/res/values-pt-rBR/folkzygisk.xml | 18 +- .../app/src/main/res/values-ru/folkzygisk.xml | 18 +- .../app/src/main/res/values-tr/folkzygisk.xml | 18 +- .../app/src/main/res/values-vi/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rAG/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rAT/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rCK/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rCN/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rMC/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rTW/folkzygisk.xml | 18 +- .../src/main/res/values-zh-rWC/folkzygisk.xml | 18 +- .../app/src/main/res/values/folkzygisk.xml | 18 +- .../data/model/FolkZygiskPreflightTest.kt | 29 - .../data/model/ModuleMutationResultTest.kt | 22 - .../ui/util/FlashOutputCollectorTest.kt | 20 +- userspace/folkzygisk/UPSTREAM.md | 50 +- userspace/ksud/src/cli.rs | 2 +- userspace/ksud/src/main.rs | 3 + userspace/ksud/src/module.rs | 43 +- userspace/ksud/src/module_markers.rs | 87 +++ userspace/ksud/src/zygisk.rs | 668 ++---------------- 45 files changed, 346 insertions(+), 1258 deletions(-) delete mode 100644 manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkZygiskPreflight.kt delete mode 100644 manager/app/src/main/java/me/weishu/kernelsu/data/model/ModuleMutationResult.kt delete mode 100644 manager/app/src/test/java/me/weishu/kernelsu/data/model/FolkZygiskPreflightTest.kt delete mode 100644 manager/app/src/test/java/me/weishu/kernelsu/data/model/ModuleMutationResultTest.kt create mode 100644 userspace/ksud/src/module_markers.rs diff --git a/README.md b/README.md index b16a4e296..018cc01c0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Standard KernelSU modules are managed from the manager. A single active metamodu ### FolkZygisk -FolkZygisk is a built-in Zygisk engine. The manager exposes its installation and activation controls, and `ksud` handles provider registration without conflicting with an existing Zygisk provider. +FolkZygisk is a built-in Zygisk engine. The manager exposes its installation and activation controls, and `ksud` steps the built-in engine aside whenever an external Zygisk provider is active, so provider modules install normally. ### SUSFS diff --git a/README_CN.md b/README_CN.md index 283a4bc8c..b47c97a35 100644 --- a/README_CN.md +++ b/README_CN.md @@ -49,7 +49,7 @@ FinaSU 提供: ### FolkZygisk -FolkZygisk 是内置的 Zygisk 引擎。管理器提供安装与激活控制,`ksud` 负责注册 provider,并与已有的 Zygisk provider 互不冲突。 +FolkZygisk 是内置的 Zygisk 引擎。管理器提供安装与激活控制;已有外置 Zygisk provider 时 `ksud` 会让内置引擎自动让位,因此 provider 模块可正常安装。 ### SUSFS diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkZygiskPreflight.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkZygiskPreflight.kt deleted file mode 100644 index 606b53e36..000000000 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkZygiskPreflight.kt +++ /dev/null @@ -1,28 +0,0 @@ -package me.weishu.kernelsu.data.model - -import org.json.JSONObject - -data class FolkZygiskPreflight( - val builtinEnabled: Boolean, val providerIds: List, val conflict: Boolean, - val externalProviders: List, val blocked: Boolean, val stateToken: String, -) { - companion object { - fun fromJson(json: JSONObject): FolkZygiskPreflight { - check(json.get("schema_version") == 2) { "Unsupported preflight schema" } - val enabled = json.get("builtin_enabled") as? Boolean ?: error("Invalid builtin_enabled") - val conflict = json.get("conflict") as? Boolean ?: error("Invalid conflict") - val array = json.getJSONArray("provider_ids") - val ids = List(array.length()) { array.get(it) as? String ?: error("Invalid provider ID") } - check(ids.all { it.isNotBlank() } && ids.distinct().size == ids.size) { "Invalid provider IDs" } - check(conflict == (enabled && ids.isNotEmpty())) { "Inconsistent preflight result" } - val externalArray = json.getJSONArray("external_providers") - val external = List(externalArray.length()) { externalArray.get(it) as? String ?: error("Invalid external ID") } - check(external.all { it.isNotBlank() } && external.distinct().size == external.size) - val blocked = json.get("blocked") as? Boolean ?: error("Invalid blocked") - check(blocked || (ids + external).distinct().size <= 1) { "Inconsistent blocked result" } - val token = json.get("state_token") as? String ?: error("Invalid state token") - check(token.matches(Regex("[0-9a-f]{64}"))) { "Invalid state token" } - return FolkZygiskPreflight(enabled, ids, conflict, external, blocked, token) - } - } -} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/ModuleMutationResult.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/ModuleMutationResult.kt deleted file mode 100644 index a45aa21d4..000000000 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/ModuleMutationResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.weishu.kernelsu.data.model - -/** The backend, not a duplicated provider allowlist, decides activation conflicts. */ -data class ModuleMutationResult(val success: Boolean, val zygiskConflict: Boolean, val externalConflict: Boolean = false) { - companion object { - fun fromOutput(success: Boolean, output: List): ModuleMutationResult = - ModuleMutationResult( - success = success, - zygiskConflict = !success && output.any { - it.trimStart().removePrefix("Error: ").startsWith("FOLKZYGISK_CONFLICT:") - }, - externalConflict = !success && output.any { - it.trimStart().removePrefix("Error: ").startsWith("FOLKZYGISK_EXTERNAL_CONFLICT:") - }, - ) - } -} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt index 6ea414702..dea799d3e 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt @@ -82,7 +82,7 @@ interface SettingsRepository { suspend fun getFolkMountStatus(): Result suspend fun getFolkZygiskStatus(): Result - suspend fun setFolkZygiskEnabled(enabled: Boolean, disableExternal: Boolean, expectedExternal: List): Result + suspend fun setFolkZygiskEnabled(enabled: Boolean): Result suspend fun setFolkMountMode(mode: FolkMountMode): Result suspend fun getAdbRootStatus(): String diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt index 2217c771e..7eda7f223 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt @@ -313,7 +313,7 @@ class SettingsRepositoryImpl : SettingsRepository { } } - override suspend fun setFolkZygiskEnabled(enabled: Boolean, disableExternal: Boolean, expectedExternal: List): Result = + override suspend fun setFolkZygiskEnabled(enabled: Boolean): Result = withContext(Dispatchers.IO) { runCatching { if (enabled) { @@ -322,15 +322,7 @@ class SettingsRepositoryImpl : SettingsRepository { check(execKsudResult("zygisk install-engine").isSuccess) { "Engine installation failed" } } } - val command = if (!enabled) { - "disable" - } else { - "enable" + if (disableExternal) { - " --disable-external" + expectedExternal.joinToString("") { - " --expected-external '" + it.replace("'", "'\"'\"'") + "'" - } - } else "" - } + val command = if (enabled) "enable" else "disable" check(execKsudResult("zygisk $command").isSuccess) { "FolkZygisk configuration failed" } } } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/flash/FlashUtils.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/flash/FlashUtils.kt index 20955e241..69b5f3daa 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/flash/FlashUtils.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/flash/FlashUtils.kt @@ -27,9 +27,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.weishu.kernelsu.ui.component.dialog.ConfirmResult -import me.weishu.kernelsu.ui.component.dialog.rememberConfirmDialog -import androidx.compose.ui.platform.LocalResources import kotlinx.parcelize.Parcelize import me.weishu.kernelsu.R import me.weishu.kernelsu.data.backup.BackupKind @@ -119,16 +116,14 @@ suspend fun flashModulesSequentially( uris: List, onStdout: (String) -> Unit, onStderr: (String) -> Unit, - confirmExternal: suspend (List, Boolean) -> Boolean = { _, _ -> false }, ): FlashResult { - return flashModuleBatch(uris, onStdout, onStderr, confirmExternal) + return flashModuleBatch(uris, onStdout, onStderr) } suspend fun flashIt( flashIt: FlashIt, onStdout: (String) -> Unit, onStderr: (String) -> Unit, - confirmExternal: suspend (List, Boolean) -> Boolean = { _, _ -> false }, ): FlashResult { snapshotBeforeFlash(flashIt, onStdout) return when (flashIt) { @@ -161,7 +156,7 @@ suspend fun flashIt( ) is FlashIt.FlashModules -> { - flashModulesSequentially(flashIt.uris, onStdout, onStderr, confirmExternal) + flashModulesSequentially(flashIt.uris, onStdout, onStderr) } FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr) @@ -244,8 +239,6 @@ fun FlashEffect( onFlashingStatusChange: (FlashingStatus) -> Unit, enabled: Boolean = true ) { - val dialog = rememberConfirmDialog() - val resources = LocalResources.current LaunchedEffect(enabled) { if (!enabled || text.isNotEmpty()) { return@LaunchedEffect @@ -266,16 +259,6 @@ fun FlashEffect( logContent.append(it).append("\n") }, onStderr = { logContent.append(it).append("\n") - }, confirmExternal = { ids, blocked -> - // Suspend without holding the backend lock or blocking an IO worker. - withContext(Dispatchers.Main) { - dialog.awaitConfirm( - title = resources.getString(R.string.folkzygisk_switch_title), - content = resources.getString(if (blocked) R.string.folkzygisk_multiple_external - else R.string.folkzygisk_install_switch_message, ids.joinToString(", ")), - confirm = resources.getString(if (blocked) android.R.string.ok else R.string.folkzygisk_switch_install), - ) == ConfirmResult.Confirmed && !blocked - } }).apply { if (code != 0) { currentText += "Error code: $code.\n $err Please save and check the log.\n" diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleMaterial.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleMaterial.kt index 12a5165c8..eeadffdbd 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleMaterial.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleMaterial.kt @@ -197,8 +197,6 @@ fun ModulePagerMaterial( when (val request = confirmDialogState?.request) { is ModuleConfirmRequest.Uninstall -> actions.onUninstallModule(request.module) is ModuleConfirmRequest.Update -> actions.onConfirmUpdate(request) - is ModuleConfirmRequest.SwitchExternal -> actions.onConfirmSwitchExternal(request) - ModuleConfirmRequest.ExternalConflict -> actions.onDismissConfirmRequest() null -> Unit } }, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleScreen.kt index 9428ba9f9..eb30a8dae 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleScreen.kt @@ -139,7 +139,6 @@ fun ModulePager( viewModel.dismissConfirmRequest() }, onOpenRepo = { navigator.push(Route.ModuleRepo) }, - onConfirmSwitchExternal = { viewModel.confirmSwitchExternal(it) }, onSetSortGroup = { group, selected -> viewModel.setSortGroup(group, selected) }, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleUiState.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleUiState.kt index 109c35644..aab75d5db 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleUiState.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/module/ModuleUiState.kt @@ -9,8 +9,6 @@ import me.weishu.kernelsu.data.model.ModuleUpdateInfo import me.weishu.kernelsu.ui.component.SearchStatus sealed interface ModuleConfirmRequest { - data object ExternalConflict : ModuleConfirmRequest - data class SwitchExternal(val module: Module, val undoUninstall: Boolean) : ModuleConfirmRequest data class Update( val module: Module, val downloadUrl: String, @@ -74,7 +72,6 @@ data class ModuleActions( val onRequestUninstallConfirmation: (Module) -> Unit, val onDismissConfirmRequest: () -> Unit, val onConfirmUpdate: (ModuleConfirmRequest.Update) -> Unit, - val onConfirmSwitchExternal: (ModuleConfirmRequest.SwitchExternal) -> Unit, val onOpenRepo: () -> Unit, val onSetSortGroup: (ModuleSortGroup, Boolean) -> Unit, val onSetSortEnabledFirst: (Boolean) -> Unit, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/FolkZygiskStatusItem.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/FolkZygiskStatusItem.kt index 15bb536a6..674bcd63f 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/FolkZygiskStatusItem.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/FolkZygiskStatusItem.kt @@ -5,18 +5,10 @@ import androidx.compose.ui.res.stringResource import me.weishu.kernelsu.R import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Code -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalResources -import kotlinx.coroutines.launch -import me.weishu.kernelsu.ui.component.dialog.ConfirmResult -import me.weishu.kernelsu.ui.component.dialog.rememberConfirmDialog import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem @Composable internal fun FolkZygiskStatusItem(state: SettingsUiState, actions: SettingsScreenActions) { - val resources = LocalResources.current - val scope = rememberCoroutineScope() - val dialog = rememberConfirmDialog() val status = state.folkZygiskStatus val summary = when { state.isFolkZygiskLoading -> stringResource(R.string.folkzygisk_loading) @@ -29,6 +21,7 @@ internal fun FolkZygiskStatusItem(state: SettingsUiState, actions: SettingsScree "failed" -> R.string.folkzygisk_failed else -> R.string.folkzygisk_unverified }) + val external = status.externalProviders val selection = stringResource(if (status.enabled) R.string.folkzygisk_next_enabled else R.string.folkzygisk_next_disabled) val blockers = status.enableBlockers.map { reason -> @@ -36,7 +29,6 @@ internal fun FolkZygiskStatusItem(state: SettingsUiState, actions: SettingsScree "license_review_required" -> R.string.folkzygisk_license_gate "device_acceptance_required" -> R.string.folkzygisk_device_gate "engine_missing" -> R.string.folkzygisk_engine_missing - "external_provider_conflict" -> R.string.folkzygisk_external_conflict "startup_protection" -> R.string.folkzygisk_startup_protection else -> R.string.folkzygisk_unknown_gate }) @@ -44,10 +36,16 @@ internal fun FolkZygiskStatusItem(state: SettingsUiState, actions: SettingsScree buildList { add(stringResource(R.string.folkzygisk_current, injection)) add(selection) - if (status.rebootRequired == true || state.folkZygiskRebootPending) add(stringResource(R.string.folkzygisk_reboot_required)) - if (status.externalProviders.isNotEmpty()) { - add(stringResource(R.string.folkzygisk_external, status.externalProviders.joinToString(", "))) + if (external.isNotEmpty()) { + val names = external.joinToString(", ") + // The built-in engine only yields to a provider at boot, so until a reboot the + // injection state above is still current; this line states next-boot precedence. + add( + if (status.enabled) stringResource(R.string.folkzygisk_external_precedence, names) + else stringResource(R.string.folkzygisk_external, names) + ) } + if (status.rebootRequired == true || state.folkZygiskRebootPending) add(stringResource(R.string.folkzygisk_reboot_required)) if (blockers.isNotEmpty()) add(stringResource(R.string.folkzygisk_blockers, blockers.joinToString("; "))) }.joinToString("\n") } @@ -58,15 +56,6 @@ internal fun FolkZygiskStatusItem(state: SettingsUiState, actions: SettingsScree summary = summary, checked = status?.enabled == true, enabled = status != null && !state.folkZygiskReadError && !state.isFolkZygiskLoading && !state.isFolkZygiskWriting, - onCheckedChange = { enabled -> - if (enabled && status != null && status.externalProviders.isNotEmpty()) { - if (!dialog.isShown) scope.launch { - if (dialog.awaitConfirm( - title = resources.getString(R.string.folkzygisk_switch_title), - content = resources.getString(R.string.folkzygisk_builtin_switch_message, status.externalProviders.joinToString(", ")), - ) == ConfirmResult.Confirmed) actions.onSetFolkZygiskEnabled(true, true, status.externalProviders) - } - } else actions.onSetFolkZygiskEnabled(enabled, false, emptyList()) - }, + onCheckedChange = { actions.onSetFolkZygiskEnabled(it) }, ) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt index dbacfc092..9e64abe9c 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt @@ -101,7 +101,7 @@ data class SettingsScreenActions( val onSetSulogEnabled: (Boolean) -> Unit, val onSetAvcSpoofEnabled: (Boolean) -> Unit, val onSetFolkMountMode: (FolkMountMode) -> Unit, - val onSetFolkZygiskEnabled: (Boolean, Boolean, List) -> Unit, + val onSetFolkZygiskEnabled: (Boolean) -> Unit, val onSetAdbRootEnabled: (Boolean) -> Unit, val onSetShellSuAdbOnlyEnabled: (Boolean) -> Unit, val onSetDefaultUmountModules: (Boolean) -> Unit, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt index 942dadeba..50783f971 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ShellUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -24,7 +25,6 @@ import me.weishu.kernelsu.core.tasks.BootKernelVersion import me.weishu.kernelsu.core.tasks.ExtractImage import me.weishu.kernelsu.core.tasks.ProbeResult import me.weishu.kernelsu.core.utils.DataSourceChannel -import me.weishu.kernelsu.data.model.ModuleMutationResult import me.weishu.kernelsu.ksuApp import okhttp3.OkHttpClient import org.json.JSONArray @@ -266,21 +266,13 @@ fun mutateModuleActivation( id: String, undoUninstall: Boolean = false, enable: Boolean = true, - switchExternal: Boolean = false, -): ModuleMutationResult { +): Boolean { val action = if (undoUninstall) "undo-uninstall" else if (enable) "enable" else "disable" val quotedId = "'" + id.replace("'", "'\"'\"'") + "'" - val output = ArrayList() - val errors = ArrayList() - val result = withNewRootShell { - val command = if (switchExternal) { - "zygisk switch-external $quotedId" + if (undoUninstall) " --undo-uninstall" else "" - } else { - "module $action $quotedId" - } - newJob().add("${getKsuDaemonPath()} $command").to(output, errors).exec() - } - return ModuleMutationResult.fromOutput(result.isSuccess, output + errors) + val cmd = "module $action $quotedId" + val result = execKsud(cmd, true) + Log.i(TAG, "$cmd result: $result") + return result } fun undoUninstallModule(id: String): Boolean { @@ -345,65 +337,35 @@ suspend fun flashModule( return flashModuleBatch(listOf(uri), onStdout, onStderr) } -/** Stage the entire batch before preflight; install the exact staged archives. */ +/** Install every selected archive in order, stopping at the first failure. */ suspend fun flashModuleBatch( uris: List, onStdout: (String) -> Unit, onStderr: (String) -> Unit, - confirmExternal: suspend (List, Boolean) -> Boolean = { _, _ -> false }, ): FlashResult { val files = mutableListOf() - var installed = false fun quote(value: String) = "'" + value.replace("'", "'\"'\"'") + "'" + var installed = false return try { require(uris.isNotEmpty()) { "No module archives selected" } for (uri in uris) { - val file = File.createTempFile("module-batch-", ".zip", ksuApp.cacheDir) + val file = File.createTempFile("module-", ".zip", ksuApp.cacheDir) files.add(file) requireNotNull(ksuApp.contentResolver.openInputStream(uri)) { "Cannot read module archive" } .use { source -> file.outputStream().use { source.copyTo(it) } } - } - val result = flashWithIO( - "${quote(getKsuDaemonPath())} zygisk preflight ${files.joinToString(" ") { quote(it.absolutePath) }}", - {}, onStderr, - ) - check(result.isSuccess) { ksuApp.getString(R.string.folkzygisk_preflight_failed) } - val lines = result.out.filter { it.isNotBlank() } - check(lines.size == 1) { ksuApp.getString(R.string.folkzygisk_preflight_failed) } - val status = me.weishu.kernelsu.data.model.FolkZygiskPreflight.fromJson(JSONObject(lines.single())) - if (status.blocked) { - val message = ksuApp.getString(R.string.folkzygisk_multiple_external, (status.providerIds + status.externalProviders).distinct().joinToString(", ")) - confirmExternal((status.providerIds + status.externalProviders).distinct(), true) - onStdout(message) - FlashResult(-1, message, false) - } else if (status.conflict && !confirmExternal(status.providerIds, false)) { - val message = ksuApp.getString(R.string.folkzygisk_install_canceled) - onStdout(message) - FlashResult(-1, message, false) - } else { - val install = flashWithIO( - "${quote(getKsuDaemonPath())} zygisk install-batch --expected-state ${quote(status.stateToken)}" + - (if (status.conflict) " --switch-external" else "") + - " ${files.joinToString(" ") { quote(it.absolutePath) }}", - { line -> - if (line != "FOLKZYGISK_BATCH_CHANGED") onStdout(line) - }, onStderr, + val result = flashWithIO( + "${quote(getKsuDaemonPath())} module install ${quote(file.absolutePath)}", + onStdout, onStderr, ) - installed = install.out.any { it == "FOLKZYGISK_BATCH_CHANGED" } - val stateChanged = install.err.any { - it.trimStart().removePrefix("Error: ").startsWith("FOLKZYGISK_STATE_CHANGED:") - } - if (stateChanged) { - val message = ksuApp.getString(R.string.folkzygisk_state_changed) - onStdout(message) - FlashResult(install.code, message, installed) - } else FlashResult(install, installed) + if (!result.isSuccess) return FlashResult(result, installed) + installed = true } - } catch (error: Exception) { - if (error is kotlinx.coroutines.CancellationException) throw error - val message = ksuApp.getString(R.string.folkzygisk_preflight_failed) - onStderr(error.toString()) - FlashResult(-1, message, installed) + FlashResult(0, "", true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onStderr(e.toString()) + FlashResult(-1, e.message ?: e.toString(), installed) } finally { files.forEach { it.delete() } } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/ModulePropUtils.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/ModulePropUtils.kt index 175e1331e..bb2a80803 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/ModulePropUtils.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/ModulePropUtils.kt @@ -1,24 +1,44 @@ package me.weishu.kernelsu.ui.util -import com.topjohnwu.superuser.ShellUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** Only real module ids reach the shell, so the id can never inject arguments. */ private val ModuleIdPattern = Regex("[A-Za-z0-9._-]+") /** * Reads the `name=` field of an installed module's `module.prop` through a root shell, or null when - * the id is not a plausible module name or no prop is found. Safe to call from any thread. + * the id is not a plausible module name, no prop is found, or root is not available yet. + * + * The shell is awaited on [Dispatchers.IO], because a non-root fallback shell cannot read + * `/data/adb`; callers may show the id itself while root is still being established. The whole prop + * is collected through a job's stdout: `ShellUtils.fastCmd` returns a single output line, and + * `name=` is not the first line of a `module.prop`. */ -fun getModuleDisplayName(moduleId: String): String? { +suspend fun getModuleDisplayName(moduleId: String): String? { if (!ModuleIdPattern.matches(moduleId)) return null - val output = ShellUtils.fastCmd( - "cat /data/adb/modules/$moduleId/module.prop 2>/dev/null; " + - "cat /data/adb/modules_update/$moduleId/module.prop 2>/dev/null" - ) - return output.lineSequence() - .map { it.trim() } - .firstOrNull { it.startsWith("name=") } - ?.substringAfter("name=") - ?.trim() - ?.takeIf { it.isNotEmpty() } + return withContext(Dispatchers.IO) { + val shell = try { + RootShell.awaitRoot() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return@withContext null + } + val out = arrayListOf() + shell.newJob() + .add( + "cat /data/adb/modules/$moduleId/module.prop 2>/dev/null; " + + "cat /data/adb/modules_update/$moduleId/module.prop 2>/dev/null" + ) + .to(out) + .exec() + out.asSequence() + .map { it.trim() } + .firstOrNull { it.startsWith("name=") } + ?.substringAfter("name=") + ?.trim() + ?.takeIf { it.isNotEmpty() } + } } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/HomeViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/HomeViewModel.kt index 59f8832f7..8a3bdfb20 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/HomeViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/HomeViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.weishu.kernelsu.BuildConfig import me.weishu.kernelsu.Natives +import me.weishu.kernelsu.R import me.weishu.kernelsu.data.HomeMetrics import me.weishu.kernelsu.data.MetricsHistory import me.weishu.kernelsu.data.SystemMetricsCollector @@ -168,8 +169,9 @@ class HomeViewModel( } /** - * Resolves the active Zygisk implementation for the home card: the built-in engine when it is - * enabled, otherwise the first active external provider (by its module name). Null hides the row. + * Resolves the active Zygisk implementation for the home card: any active external provider + * wins and is named by its module name; otherwise the built-in engine when it is enabled and + * installed. Null hides the row. */ fun refreshZygisk() { val request = ++zygiskToken @@ -187,9 +189,15 @@ class HomeViewModel( private suspend fun resolveZygiskProvider(): String? { val status = settingsRepo.getFolkZygiskStatus().getOrNull() ?: return null - if (status.enabled && status.engineAvailable) return "Built-In" - val external = status.externalProviders.firstOrNull() ?: return null - return getModuleDisplayName(external) ?: external + val injecting = status.injectionState == "injected" || + status.injectionState == "partial" || + status.injectionState == "starting" + // The built-in engine only steps aside at boot, so while it is the one injecting it stays + // the effective implementation even after an external provider module is installed. + if (status.enabled && injecting) return ksuApp.getString(R.string.folkzygisk_builtin_active) + status.externalProviders.firstOrNull()?.let { return getModuleDisplayName(it) ?: it } + if (status.enabled && status.engineAvailable) return ksuApp.getString(R.string.folkzygisk_builtin_active) + return null } private fun buildState(): HomeUiState { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt index e0364d317..d2f5aeb20 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt @@ -418,16 +418,12 @@ class ModuleViewModel( fun toggleModule(module: Module) { viewModelScope.launch { val res = ksuApp.resources - val result = withContext(Dispatchers.IO) { + val success = withContext(Dispatchers.IO) { mutateModuleActivation(module.id, enable = !module.enabled) } - if (result.success) { + if (success) { fetchModuleList(checkUpdate = true, resort = false) emitEffect(ModuleEffect.SnackBar(res.getString(R.string.reboot_to_apply))) - } else if (result.zygiskConflict) { - requestExternalSwitch(module, undoUninstall = false) - } else if (result.externalConflict) { - showExternalConflict(module) } else { val message = if (module.enabled) R.string.module_failed_to_disable else R.string.module_failed_to_enable emitEffect(ModuleEffect.SnackBar(res.getString(message).format(module.name))) @@ -458,75 +454,22 @@ class ModuleViewModel( fun undoUninstallModule(module: Module) { viewModelScope.launch { val res = ksuApp.resources - val result = withContext(Dispatchers.IO) { + val success = withContext(Dispatchers.IO) { mutateModuleActivation(module.id, undoUninstall = true) } - if (result.success) { + if (success) { fetchModuleList(checkUpdate = true, resort = false) } - if (result.zygiskConflict) { - requestExternalSwitch(module, undoUninstall = true) - return@launch - } - if (result.externalConflict) { - showExternalConflict(module) - return@launch - } emitEffect( ModuleEffect.SnackBar( res.getString( - if (result.success) R.string.module_undo_uninstall_success else R.string.module_undo_uninstall_failed + if (success) R.string.module_undo_uninstall_success else R.string.module_undo_uninstall_failed ).format(module.name) ) ) } } - private fun requestExternalSwitch(module: Module, undoUninstall: Boolean) { - val res = ksuApp.resources - _uiState.update { - it.copy(confirmDialogState = ModuleConfirmDialogState( - request = ModuleConfirmRequest.SwitchExternal(module, undoUninstall), - title = res.getString(R.string.folkzygisk_switch_title), - content = res.getString(R.string.folkzygisk_switch_message, module.name), - confirm = res.getString(R.string.folkzygisk_switch_confirm), - dismiss = res.getString(android.R.string.cancel), - )) - } - } - - private fun showExternalConflict(module: Module) { - _uiState.update { it.copy(confirmDialogState = ModuleConfirmDialogState( - request = ModuleConfirmRequest.ExternalConflict, - title = ksuApp.getString(R.string.folkzygisk_switch_title), - content = ksuApp.getString(R.string.folkzygisk_multiple_external, module.name), - confirm = ksuApp.getString(android.R.string.ok), - dismiss = ksuApp.getString(android.R.string.cancel), - )) } - } - - fun confirmSwitchExternal(request: ModuleConfirmRequest.SwitchExternal) { - // Consume the request before launching: repeated confirmation cannot run it twice. - if (_uiState.value.confirmDialogState?.request != request) return - dismissConfirmRequest() - viewModelScope.launch { - val result = withContext(Dispatchers.IO) { - mutateModuleActivation(request.module.id, undoUninstall = request.undoUninstall, switchExternal = true) - } - if (result.externalConflict) { - showExternalConflict(request.module) - return@launch - } - if (result.success) fetchModuleList(checkUpdate = true, resort = false) - val message = if (result.success) { - ksuApp.getString(R.string.reboot_to_apply) - } else { - ksuApp.getString(R.string.folkzygisk_switch_failed) - } - emitEffect(ModuleEffect.SnackBar(message)) - } - } - private suspend fun buildUpdateConfirmDialogState( module: Module, updateInfo: ModuleUpdateInfo, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt index 13f46d6a5..2b6edab88 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt @@ -145,12 +145,12 @@ class SettingsViewModel( } } - fun setFolkZygiskEnabled(enabled: Boolean, disableExternal: Boolean, expectedExternal: List) { + fun setFolkZygiskEnabled(enabled: Boolean) { if (_uiState.value.isFolkZygiskWriting || _uiState.value.isFolkZygiskLoading) return ++refreshGeneration _uiState.update { it.copy(isFolkZygiskWriting = true) } viewModelScope.launch { - val result = repo.setFolkZygiskEnabled(enabled, disableExternal, expectedExternal) + val result = repo.setFolkZygiskEnabled(enabled) val status = repo.getFolkZygiskStatus() _uiState.update { it.copy(isFolkZygiskWriting = false, folkZygiskStatus = status.getOrNull(), diff --git a/manager/app/src/main/res/values-ar/folkzygisk.xml b/manager/app/src/main/res/values-ar/folkzygisk.xml index f330f6c97..ae6286fc4 100644 --- a/manager/app/src/main/res/values-ar/folkzygisk.xml +++ b/manager/app/src/main/res/values-ar/folkzygisk.xml @@ -1,19 +1,6 @@ - تغيّر اختيار Zygisk بعد التأكيد. لم يتم تثبيت أي شيء؛ حدّث الحالة واختر الأرشيفات مرة أخرى. - تعطيل FolkZygisk المدمج للإقلاع التالي وتثبيت المزوّد الخارجي %1$s؟ الإلغاء لا يثبّت شيئًا. لن يتغيّر الحقن الحالي حتى إعادة التشغيل. لا يمكن التراجع بالكامل عن الآثار الجانبية للمثبّت؛ عند الفشل ستُعطَّل المزوّدات الجزئية قبل محاولة استعادة الاختيار السابق. - التبديل والتثبيت - تم الإلغاء. لم تُثبَّت أي وحدات ولم يتغيّر إعداد Zygisk. - تعذّر المتابعة: يوجد عدة مزوّدي Zygisk خارجيين (%1$s). عطّل المزوّدات الأخرى واترك واحدًا فقط. - تعطيل المزوّدات الخارجية (%1$s) وتفعيل FolkZygisk المدمج للإقلاع التالي؟ لن يتغيّر الحقن الحالي حتى إعادة التشغيل. - فشل إعداد FolkZygisk. حدّث الحالة وتحقق من المحرّك وتعارضات المزوّدات وحماية الإقلاع. - تبديل مزوّد Zygisk؟ - تعطيل FolkZygisk المدمج للإقلاع التالي وتفعيل %1$s؟ يبقى الحقن الحالي دون تغيير حتى إعادة التشغيل. ستمنع المزوّدات الخارجية النشطة الأخرى هذه العملية. - التبديل والتفعيل - فشل تبديل المزوّد. حدّث قائمة الوحدات وحالة FolkZygisk قبل إعادة المحاولة. - لم يُفعَّل %1$s: يتعارض مع FolkZygisk المدمج. عطّل المزوّد المدمج أولًا؛ تُطبَّق تغييرات الاختيار عند إعادة التشغيل. - فشل التحقق من دفعة الوحدات. لم تُثبَّت وحدات أخرى. راجع السجل. - أُلغي التثبيت قبل تثبيت أي وحدة: يتعارض %1$s مع إعداد FolkZygisk المدمج المفعّل. عطّل المزوّد المدمج قبل تثبيت مزوّد خارجي؛ تتطلب التغييرات إعادة التشغيل. + فشل إعداد FolkZygisk. حدّث الحالة وتحقق من المحرّك وحماية الإقلاع. جارٍ قراءة حالة الحقن… الحالة غير متاحة. تعذّر التحقق من الحقن وإعداد الإقلاع التالي. محقون @@ -26,11 +13,12 @@ إعداد الإقلاع التالي: معطّل يلزم إعادة التشغيل لتطبيق الإعداد. المزوّدات الخارجية: %1$s + الإقلاع التالي: المزوّد الخارجي %1$s له الأولوية. + FolkZygisk المدمج التفعيل غير متاح: %1$s مراجعة الترخيص معلّقة قبول الجهاز معلّق المحرّك غير مثبّت - تعارض في المزوّد الخارجي حماية الإقلاع مفعّلة قيد غير معروف diff --git a/manager/app/src/main/res/values-es/folkzygisk.xml b/manager/app/src/main/res/values-es/folkzygisk.xml index 32fffed8a..ae9134cf2 100644 --- a/manager/app/src/main/res/values-es/folkzygisk.xml +++ b/manager/app/src/main/res/values-es/folkzygisk.xml @@ -1,19 +1,6 @@ - La selección de Zygisk cambió tras la confirmación. No se instaló nada; actualiza el estado y vuelve a seleccionar los archivos. - ¿Desactivar el FolkZygisk integrado para el próximo arranque e instalar el proveedor externo %1$s? Cancelar no instala nada. La inyección actual no cambia hasta reiniciar. Los efectos secundarios del instalador no se pueden revertir por completo; en caso de fallo, los proveedores parciales se desactivarán antes de intentar restaurar la selección anterior. - Cambiar e instalar - Cancelado. No se instaló ningún módulo ni se modificó la configuración de Zygisk. - No se puede continuar: hay varios proveedores externos de Zygisk (%1$s). Desactiva los demás proveedores y conserva solo uno. - ¿Desactivar los proveedores externos (%1$s) y activar el FolkZygisk integrado para el próximo arranque? La inyección actual no cambiará hasta reiniciar. - Error en la configuración de FolkZygisk. Actualiza el estado y comprueba el motor, los conflictos de proveedores y la protección de arranque. - ¿Cambiar de proveedor de Zygisk? - ¿Desactivar el FolkZygisk integrado para el próximo arranque y activar %1$s? La inyección actual no cambia hasta reiniciar. Otros proveedores externos activos bloquearán esta operación. - Cambiar y activar - Error al cambiar de proveedor. Actualiza la lista de módulos y el estado de FolkZygisk antes de reintentar. - %1$s no se activó: entra en conflicto con el FolkZygisk integrado. Desactiva primero el proveedor integrado; los cambios de selección se aplican al reiniciar. - Error en la validación del lote de módulos. No se instalaron más módulos. Revisa el registro. - Instalación cancelada antes de instalar ningún módulo: %1$s entra en conflicto con la configuración activada de FolkZygisk integrado. Desactiva el proveedor integrado antes de instalar un proveedor externo; los cambios requieren reiniciar. + Error en la configuración de FolkZygisk. Actualiza el estado y comprueba el motor y la protección de arranque. Leyendo el estado de inyección… Estado no disponible. No se pudo verificar la inyección ni la configuración del próximo arranque. Inyectado @@ -26,11 +13,12 @@ Configuración del próximo arranque: desactivada Se requiere reiniciar para aplicar la configuración. Proveedores externos: %1$s + Próximo arranque: el proveedor externo %1$s tiene prioridad. + FolkZygisk integrado Activación no disponible: %1$s revisión de licencia pendiente aceptación del dispositivo pendiente motor no instalado - conflicto de proveedor externo protección de arranque activa restricción no reconocida diff --git a/manager/app/src/main/res/values-in/folkzygisk.xml b/manager/app/src/main/res/values-in/folkzygisk.xml index 731920a02..2f88d9b73 100644 --- a/manager/app/src/main/res/values-in/folkzygisk.xml +++ b/manager/app/src/main/res/values-in/folkzygisk.xml @@ -1,19 +1,6 @@ - Pilihan Zygisk berubah setelah konfirmasi. Tidak ada yang dipasang; segarkan status dan pilih kembali arsipnya. - Nonaktifkan FolkZygisk bawaan untuk boot berikutnya dan pasang penyedia eksternal %1$s? Batal tidak memasang apa pun. Injeksi saat ini tidak berubah hingga mulai ulang. Efek samping pemasang tidak dapat dipulihkan sepenuhnya; jika gagal, penyedia yang terpasang sebagian akan dinonaktifkan sebelum mencoba memulihkan pilihan sebelumnya. - Ganti dan pasang - Dibatalkan. Tidak ada modul yang dipasang atau konfigurasi Zygisk yang diubah. - Tidak dapat melanjutkan: ada beberapa penyedia Zygisk eksternal (%1$s). Nonaktifkan penyedia lain dan sisakan hanya satu. - Nonaktifkan penyedia eksternal (%1$s) dan aktifkan FolkZygisk bawaan untuk boot berikutnya? Injeksi saat ini tidak akan berubah hingga mulai ulang. - Konfigurasi FolkZygisk gagal. Segarkan status dan periksa mesin, konflik penyedia, serta perlindungan saat mulai. - Ganti penyedia Zygisk? - Nonaktifkan FolkZygisk bawaan untuk boot berikutnya dan aktifkan %1$s? Injeksi saat ini tidak berubah hingga mulai ulang. Penyedia eksternal lain yang aktif akan menghalangi operasi ini. - Ganti dan aktifkan - Pergantian penyedia gagal. Segarkan daftar modul dan status FolkZygisk sebelum mencoba lagi. - %1$s tidak diaktifkan: bertentangan dengan FolkZygisk bawaan. Nonaktifkan penyedia bawaan terlebih dahulu; perubahan pilihan berlaku setelah mulai ulang. - Validasi batch modul gagal. Tidak ada modul lain yang dipasang. Periksa log. - Pemasangan dibatalkan sebelum ada modul yang dipasang: %1$s bertentangan dengan konfigurasi FolkZygisk bawaan yang aktif. Nonaktifkan penyedia bawaan sebelum memasang penyedia eksternal; perubahan memerlukan mulai ulang. + Konfigurasi FolkZygisk gagal. Segarkan status dan periksa mesin serta perlindungan saat mulai. Membaca status injeksi… Status tidak tersedia. Injeksi dan konfigurasi boot berikutnya tidak dapat diverifikasi. Terinjeksi @@ -26,11 +13,12 @@ Konfigurasi boot berikutnya: nonaktif Mulai ulang diperlukan untuk menerapkan konfigurasi. Penyedia eksternal: %1$s + Boot berikutnya: penyedia eksternal %1$s diprioritaskan. + FolkZygisk bawaan Aktivasi tidak tersedia: %1$s peninjauan lisensi tertunda penerimaan perangkat tertunda mesin tidak terpasang - konflik penyedia eksternal perlindungan saat mulai aktif pembatasan tidak dikenal diff --git a/manager/app/src/main/res/values-ja/folkzygisk.xml b/manager/app/src/main/res/values-ja/folkzygisk.xml index 754db7da7..e63bc3aa3 100644 --- a/manager/app/src/main/res/values-ja/folkzygisk.xml +++ b/manager/app/src/main/res/values-ja/folkzygisk.xml @@ -1,19 +1,6 @@ - 確認後に Zygisk の選択が変更されました。何もインストールされていません。状態を更新し、アーカイブを選び直してください。 - 次回起動時に内蔵 FolkZygisk を無効化し、外部プロバイダ %1$s をインストールしますか?キャンセルすると何もインストールされません。現在の注入は再起動まで変更されません。インストーラーの副作用は完全にはロールバックできません。失敗時は、以前の選択を復元する前に、部分的なプロバイダを無効化します。 - 切り替えてインストール - キャンセルしました。モジュールはインストールされず、Zygisk 設定も変更されていません。 - 続行できません: 複数の外部 Zygisk プロバイダ (%1$s) が存在します。他のプロバイダを無効化し、1 つだけ残してください。 - 外部プロバイダ (%1$s) を無効化し、次回起動時に内蔵 FolkZygisk を有効化しますか?現在の注入は再起動まで変更されません。 - FolkZygisk の設定に失敗しました。状態を更新し、エンジン、プロバイダの競合、起動保護を確認してください。 - Zygisk プロバイダを切り替えますか? - 次回起動時に内蔵 FolkZygisk を無効化し、%1$s を有効化しますか?現在の注入は再起動まで変更されません。他の有効な外部プロバイダが存在する場合、この操作はブロックされます。 - 切り替えて有効化 - プロバイダの切り替えに失敗しました。再試行する前に、モジュール一覧と FolkZygisk の状態を更新してください。 - %1$s は有効化されませんでした: 内蔵 FolkZygisk と競合しています。先に内蔵プロバイダを無効化してください。選択の変更は再起動後に適用されます。 - モジュールの一括検証に失敗しました。以降のモジュールはインストールされていません。ログを確認してください。 - モジュールをインストールする前にキャンセルしました: %1$s は有効な内蔵 FolkZygisk 設定と競合しています。外部プロバイダをインストールする前に内蔵プロバイダを無効化してください。変更には再起動が必要です。 + FolkZygisk の設定に失敗しました。状態を更新し、エンジンと起動保護を確認してください。 注入状態を読み取り中… 状態を取得できません。注入と次回起動時の設定を確認できませんでした。 注入済み @@ -26,11 +13,12 @@ 次回起動時の設定: 無効 設定を適用するには再起動が必要です。 外部プロバイダ: %1$s + 次回起動: 外部プロバイダ %1$s が優先されます。 + 内蔵 FolkZygisk 有効化できません: %1$s ライセンス審査が保留中 実機検証が保留中 エンジンがインストールされていません - 外部プロバイダの競合 起動保護が有効 不明な制限 diff --git a/manager/app/src/main/res/values-ko/folkzygisk.xml b/manager/app/src/main/res/values-ko/folkzygisk.xml index 25847b2db..f97f59be2 100644 --- a/manager/app/src/main/res/values-ko/folkzygisk.xml +++ b/manager/app/src/main/res/values-ko/folkzygisk.xml @@ -1,19 +1,6 @@ - 확인 후 Zygisk 선택이 변경되었습니다. 아무것도 설치되지 않았습니다. 상태를 새로 고치고 아카이브를 다시 선택하세요. - 다음 부팅을 위해 내장 FolkZygisk를 비활성화하고 외부 제공자 %1$s을(를) 설치할까요? 취소하면 아무것도 설치되지 않습니다. 재부팅 전까지 현재 주입은 변경되지 않습니다. 설치 프로그램의 부작용은 완전히 되돌릴 수 없습니다. 실패하면 이전 선택을 복원하기 전에 부분 제공자가 비활성화됩니다. - 전환 및 설치 - 취소되었습니다. 설치된 모듈이나 변경된 Zygisk 구성이 없습니다. - 계속할 수 없습니다: 여러 외부 Zygisk 제공자(%1$s)가 있습니다. 다른 제공자를 비활성화하고 하나만 유지하세요. - 외부 제공자(%1$s)를 비활성화하고 다음 부팅을 위해 내장 FolkZygisk를 활성화할까요? 재부팅 전까지 현재 주입은 변경되지 않습니다. - FolkZygisk 구성에 실패했습니다. 상태를 새로 고치고 엔진, 제공자 충돌 및 시작 보호를 확인하세요. - Zygisk 제공자를 전환할까요? - 다음 부팅을 위해 내장 FolkZygisk를 비활성화하고 %1$s을(를) 활성화할까요? 재부팅 전까지 현재 주입은 변경되지 않습니다. 다른 활성 외부 제공자가 있으면 이 작업이 차단됩니다. - 전환 및 활성화 - 제공자 전환에 실패했습니다. 재시도하기 전에 모듈 목록과 FolkZygisk 상태를 새로 고치세요. - %1$s이(가) 활성화되지 않았습니다: 내장 FolkZygisk와 충돌합니다. 먼저 내장 제공자를 비활성화하세요. 선택 변경은 재부팅 시 적용됩니다. - 모듈 일괄 검증에 실패했습니다. 추가 모듈이 설치되지 않았습니다. 로그를 확인하세요. - 모듈이 설치되기 전에 설치가 취소되었습니다: %1$s이(가) 활성화된 내장 FolkZygisk 구성과 충돌합니다. 외부 제공자를 설치하기 전에 내장 제공자를 비활성화하세요. 변경 사항은 재부팅이 필요합니다. + FolkZygisk 구성에 실패했습니다. 상태를 새로 고치고 엔진과 시작 보호를 확인하세요. 주입 상태 읽는 중… 상태를 사용할 수 없습니다. 주입 및 다음 부팅 구성을 확인할 수 없습니다. 주입됨 @@ -26,11 +13,12 @@ 다음 부팅 구성: 비활성화됨 구성을 적용하려면 다시 시작해야 합니다. 외부 제공자: %1$s + 다음 부팅: 외부 제공자 %1$s이(가) 우선합니다. + 내장 FolkZygisk 활성화할 수 없음: %1$s 라이선스 검토 대기 중 기기 승인 대기 중 엔진이 설치되지 않음 - 외부 제공자 충돌 시작 보호 활성화됨 인식할 수 없는 제한 diff --git a/manager/app/src/main/res/values-mgl/folkzygisk.xml b/manager/app/src/main/res/values-mgl/folkzygisk.xml index 558af6802..433d2ea0a 100644 --- a/manager/app/src/main/res/values-mgl/folkzygisk.xml +++ b/manager/app/src/main/res/values-mgl/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认后 Zygisk 配置已变化,未结下任何魔法阵;请刷新状态后重新选择安装包。 - 封印下次觉醒的内置 FolkZygisk,并结下外置提供者 %1$s?取消不会结下任何魔法阵。当前注入不会改变,重新觉醒后生效。安装器的涟漪无法完全回滚;若变身失败,会先停用残留提供者,再尝试恢复原配置。 - 切换并结下 - 已取消,未结下任何魔法阵,也未改动 Zygisk 配置。 - 无法继续:存在多个外置 Zygisk 提供者(%1$s)。请先封印其他提供者,仅留其一。 - 封印外置提供者(%1$s),并为下次觉醒点亮内置 FolkZygisk?当前注入状态不会改变,重新觉醒后生效。 - FolkZygisk 配置变身失败,请刷新状态并检查引擎、提供者冲突与启动守护。 - 切换 Zygisk 提供者? - 封印下次觉醒的内置 FolkZygisk 并点亮 %1$s?当前注入状态在重新觉醒前不会改变。若存在其他已点亮的外部提供者,此魔法将被拒绝。 - 切换并点亮 - 提供者切换变身失败,请刷新魔法阵列表与 FolkZygisk 状态后再试。 - 未点亮 %1$s:它与内置 FolkZygisk 冲突。请先封印内置提供者,选择变更将在重新觉醒后生效。 - 魔法阵批次校验变身失败,未继续结下魔法阵,请查看魔法记录。 - 已在结下任何魔法阵之前取消:%1$s 与已点亮的内置 FolkZygisk 配置冲突。请先封印内置提供者,再结下外部提供者;配置变更需要重新觉醒。 + FolkZygisk 配置变身失败,请刷新状态并检查引擎与启动守护。 正在读取注入状态… 状态不可用,无法确认注入状态与下次觉醒配置。 已注入 @@ -26,11 +13,12 @@ 下次觉醒配置:封印 需要重新觉醒以应用配置。 外部提供者:%1$s + 下次启动:外置提供者 %1$s 优先。 + 内置 FolkZygisk 暂不可点亮:%1$s 契约许可审查待完成 真机验收待完成 引擎尚未结下 - 存在外部提供者冲突 启动守护已触发 未知限制 diff --git a/manager/app/src/main/res/values-pl/folkzygisk.xml b/manager/app/src/main/res/values-pl/folkzygisk.xml index c4bfd5e43..90c536522 100644 --- a/manager/app/src/main/res/values-pl/folkzygisk.xml +++ b/manager/app/src/main/res/values-pl/folkzygisk.xml @@ -1,19 +1,6 @@ - Wybór Zygisk zmienił się po potwierdzeniu. Niczego nie zainstalowano; odśwież stan i ponownie wybierz archiwa. - Wyłączyć wbudowany FolkZygisk na następne uruchomienie i zainstalować zewnętrznego dostawcę %1$s? Anulowanie nic nie instaluje. Bieżąca iniekcja pozostaje niezmieniona do ponownego uruchomienia. Skutków ubocznych instalatora nie można w pełni cofnąć; w razie niepowodzenia częściowi dostawcy zostaną wyłączeni przed próbą przywrócenia poprzedniego wyboru. - Przełącz i zainstaluj - Anulowano. Nie zainstalowano żadnych modułów ani nie zmieniono konfiguracji Zygisk. - Nie można kontynuować: wielu zewnętrznych dostawców Zygisk (%1$s). Wyłącz pozostałych dostawców i pozostaw tylko jednego. - Wyłączyć zewnętrznych dostawców (%1$s) i włączyć wbudowany FolkZygisk na następne uruchomienie? Bieżąca iniekcja nie zmieni się do ponownego uruchomienia. - Konfiguracja FolkZygisk nie powiodła się. Odśwież stan i sprawdź silnik, konflikty dostawców oraz ochronę rozruchu. - Przełączyć dostawcę Zygisk? - Wyłączyć wbudowany FolkZygisk na następne uruchomienie i aktywować %1$s? Bieżąca iniekcja pozostaje niezmieniona do ponownego uruchomienia. Inni aktywni zewnętrzni dostawcy zablokują tę operację. - Przełącz i aktywuj - Przełączenie dostawcy nie powiodło się. Odśwież listę modułów i stan FolkZygisk przed ponowną próbą. - Nie aktywowano %1$s: koliduje z wbudowanym FolkZygisk. Najpierw wyłącz wbudowanego dostawcę; zmiany wyboru zaczną działać po ponownym uruchomieniu. - Walidacja partii modułów nie powiodła się. Nie zainstalowano już żadnych modułów. Sprawdź dziennik. - Instalację anulowano, zanim zainstalowano jakikolwiek moduł: %1$s koliduje z włączoną wbudowaną konfiguracją FolkZygisk. Wyłącz wbudowanego dostawcę przed zainstalowaniem zewnętrznego dostawcy; zmiany wymagają ponownego uruchomienia. + Konfiguracja FolkZygisk nie powiodła się. Odśwież stan i sprawdź silnik oraz ochronę rozruchu. Odczytywanie stanu iniekcji… Stan niedostępny. Nie można zweryfikować iniekcji ani konfiguracji na następne uruchomienie. Wstrzyknięto @@ -26,11 +13,12 @@ Konfiguracja na następne uruchomienie: wyłączona Wymagane jest ponowne uruchomienie, aby zastosować konfigurację. Zewnętrzni dostawcy: %1$s + Następny rozruch: zewnętrzny dostawca %1$s ma pierwszeństwo. + Wbudowany FolkZygisk Aktywacja niedostępna: %1$s oczekuje weryfikacji licencji oczekuje akceptacji urządzenia silnik nie jest zainstalowany - konflikt zewnętrznego dostawcy ochrona rozruchu aktywna nierozpoznane ograniczenie diff --git a/manager/app/src/main/res/values-pt-rBR/folkzygisk.xml b/manager/app/src/main/res/values-pt-rBR/folkzygisk.xml index 5a25e8b33..9f8cb06c1 100644 --- a/manager/app/src/main/res/values-pt-rBR/folkzygisk.xml +++ b/manager/app/src/main/res/values-pt-rBR/folkzygisk.xml @@ -1,19 +1,6 @@ - A seleção do Zygisk mudou após a confirmação. Nada foi instalado; atualize o estado e selecione os arquivos novamente. - Desativar o FolkZygisk integrado para a próxima inicialização e instalar o provedor externo %1$s? Cancelar não instala nada. A injeção atual permanece inalterada até a reinicialização. Os efeitos colaterais do instalador não podem ser totalmente revertidos; em caso de falha, provedores parciais serão desativados antes de tentar restaurar a seleção anterior. - Alternar e instalar - Cancelado. Nenhum módulo foi instalado nem a configuração do Zygisk foi alterada. - Não é possível continuar: vários provedores Zygisk externos (%1$s). Desative os outros provedores e mantenha apenas um. - Desativar os provedores externos (%1$s) e ativar o FolkZygisk integrado para a próxima inicialização? A injeção atual não mudará até a reinicialização. - Falha na configuração do FolkZygisk. Atualize o status e verifique o mecanismo, os conflitos de provedores e a proteção de inicialização. - Alternar provedor Zygisk? - Desativar o FolkZygisk integrado para a próxima inicialização e ativar %1$s? A injeção atual permanece inalterada até a reinicialização. Outros provedores externos ativos bloquearão esta operação. - Alternar e ativar - Falha ao alternar o provedor. Atualize a lista de módulos e o status do FolkZygisk antes de tentar novamente. - %1$s não foi ativado: há conflito com o FolkZygisk integrado. Desative primeiro o provedor integrado; as alterações de seleção serão aplicadas na reinicialização. - Falha na validação do lote de módulos. Nenhum outro módulo foi instalado. Verifique o registro. - Instalação cancelada antes de qualquer módulo ser instalado: %1$s está em conflito com a configuração do FolkZygisk integrado ativada. Desative o provedor integrado antes de instalar um provedor externo; as alterações exigem reinicialização. + Falha na configuração do FolkZygisk. Atualize o status e verifique o mecanismo e a proteção de inicialização. Lendo o status de injeção… Status indisponível. Não foi possível verificar a injeção nem a configuração da próxima inicialização. Injetado @@ -26,11 +13,12 @@ Configuração da próxima inicialização: desativada É necessário reiniciar para aplicar a configuração. Provedores externos: %1$s + Próxima inicialização: o provedor externo %1$s tem prioridade. + FolkZygisk integrado Ativação indisponível: %1$s revisão de licença pendente aceitação do dispositivo pendente mecanismo não instalado - conflito de provedor externo proteção de inicialização ativa restrição não reconhecida diff --git a/manager/app/src/main/res/values-ru/folkzygisk.xml b/manager/app/src/main/res/values-ru/folkzygisk.xml index 4b61a01bf..690b263ac 100644 --- a/manager/app/src/main/res/values-ru/folkzygisk.xml +++ b/manager/app/src/main/res/values-ru/folkzygisk.xml @@ -1,19 +1,6 @@ - Выбор Zygisk изменился после подтверждения. Ничего не установлено; обновите состояние и снова выберите архивы. - Отключить встроенный FolkZygisk для следующей загрузки и установить внешний провайдер %1$s? Отмена ничего не устанавливает. Текущая инъекция не изменится до перезагрузки. Побочные эффекты установщика невозможно полностью откатить; при сбое частичные провайдеры будут отключены перед попыткой восстановить предыдущий выбор. - Переключить и установить - Отменено. Модули не установлены, конфигурация Zygisk не изменена. - Невозможно продолжить: несколько внешних провайдеров Zygisk (%1$s). Отключите остальные и оставьте только один. - Отключить внешние провайдеры (%1$s) и включить встроенный FolkZygisk для следующей загрузки? Текущая инъекция не изменится до перезагрузки. - Не удалось сохранить конфигурацию FolkZygisk. Обновите состояние и проверьте движок, конфликты провайдеров и защиту при запуске. - Переключить провайдер Zygisk? - Отключить встроенный FolkZygisk для следующей загрузки и активировать %1$s? Текущая инъекция не изменится до перезагрузки. Другие активные внешние провайдеры заблокируют эту операцию. - Переключить и активировать - Не удалось переключить провайдер. Обновите список модулей и состояние FolkZygisk перед повторной попыткой. - %1$s не активирован: конфликт со встроенным FolkZygisk. Сначала отключите встроенный провайдер; изменения выбора вступят в силу после перезагрузки. - Не удалось проверить пакет модулей. Дальнейшие модули не устанавливались. Проверьте журнал. - Установка отменена до установки каких-либо модулей: %1$s конфликтует с включённой конфигурацией встроенного FolkZygisk. Отключите встроенный провайдер перед установкой внешнего; изменения требуют перезагрузки. + Не удалось сохранить конфигурацию FolkZygisk. Обновите состояние и проверьте движок и защиту при запуске. Чтение состояния инъекции… Состояние недоступно. Не удалось проверить инъекцию и конфигурацию следующей загрузки. Внедрено @@ -26,11 +13,12 @@ Конфигурация следующей загрузки: отключена Для применения конфигурации требуется перезагрузка. Внешние провайдеры: %1$s + Следующая загрузка: внешний провайдер %1$s имеет приоритет. + Встроенный FolkZygisk Активация недоступна: %1$s ожидается проверка лицензии ожидается приёмка устройства движок не установлен - конфликт внешнего провайдера активна защита при запуске нераспознанное ограничение diff --git a/manager/app/src/main/res/values-tr/folkzygisk.xml b/manager/app/src/main/res/values-tr/folkzygisk.xml index 6d5f2eb94..6efed6890 100644 --- a/manager/app/src/main/res/values-tr/folkzygisk.xml +++ b/manager/app/src/main/res/values-tr/folkzygisk.xml @@ -1,19 +1,6 @@ - Zygisk seçimi onaydan sonra değişti. Hiçbir şey kurulmadı; durumu yenileyin ve arşivleri tekrar seçin. - Sonraki önyükleme için yerleşik FolkZygisk devre dışı bırakılıp harici sağlayıcı %1$s kurlsun mu? İptal hiçbir şey kurmaz. Yeniden başlatana kadar geçerli enjeksiyon değişmez. Yükleyicinin yan etkileri tamamen geri alınamaz; başarısızlık durumunda, önceki seçim geri yüklenmeye çalışılmadan önce kısmi sağlayıcılar devre dışı bırakılır. - Değiştir ve kur - İptal edildi. Hiçbir modül kurulmadı veya Zygisk yapılandırması değiştirilmedi. - Devam edilemiyor: birden fazla harici Zygisk sağlayıcısı (%1$s). Diğer sağlayıcıları devre dışı bırakın ve yalnızca birini tutun. - Harici sağlayıcılar (%1$s) devre dışı bırakılıp sonraki önyükleme için yerleşik FolkZygisk etkinleştirilsin mi? Yeniden başlatana kadar geçerli enjeksiyon değişmez. - FolkZygisk yapılandırması başarısız oldu. Durumu yenileyin ve motoru, sağlayıcı çakışmalarını ve başlangıç korumasını kontrol edin. - Zygisk sağlayıcısı değiştirilsin mi? - Sonraki önyükleme için yerleşik FolkZygisk devre dışı bırakılıp %1$s etkinleştirilsin mi? Yeniden başlatana kadar geçerli enjeksiyon değişmez. Diğer etkin harici sağlayıcılar bu işlemi engeller. - Değiştir ve etkinleştir - Sağlayıcı değiştirilemedi. Yeniden denemeden önce modül listesini ve FolkZygisk durumunu yenileyin. - %1$s etkinleştirilmedi: yerleşik FolkZygisk ile çakışıyor. Önce yerleşik sağlayıcıyı devre dışı bırakın; seçim değişiklikleri yeniden başlatmada uygulanır. - Modül toplu doğrulaması başarısız oldu. Başka modül kurulmadı. Günlüğü kontrol edin. - Herhangi bir modül kurulmadan önce kurulum iptal edildi: %1$s etkin yerleşik FolkZygisk yapılandırmasıyla çakışıyor. Harici bir sağlayıcı kurmadan önce yerleşik sağlayıcıyı devre dışı bırakın; değişiklikler yeniden başlatma gerektirir. + FolkZygisk yapılandırması başarısız oldu. Durumu yenileyin ve motoru ve başlangıç korumasını kontrol edin. Enjeksiyon durumu okunuyor… Durum kullanılamıyor. Enjeksiyon ve sonraki önyükleme yapılandırması doğrulanamadı. Enjekte edildi @@ -26,11 +13,12 @@ Sonraki önyükleme yapılandırması: devre dışı Yapılandırmayı uygulamak için yeniden başlatma gerekir. Harici sağlayıcılar: %1$s + Sonraki önyükleme: harici sağlayıcı %1$s önceliklidir. + Yerleşik FolkZygisk Etkinleştirme kullanılamıyor: %1$s lisans incelemesi bekliyor cihaz kabulü bekliyor motor kurulu değil - harici sağlayıcı çakışması başlangıç koruması etkin tanınmayan kısıtlama diff --git a/manager/app/src/main/res/values-vi/folkzygisk.xml b/manager/app/src/main/res/values-vi/folkzygisk.xml index c961df27d..d99dd3169 100644 --- a/manager/app/src/main/res/values-vi/folkzygisk.xml +++ b/manager/app/src/main/res/values-vi/folkzygisk.xml @@ -1,19 +1,6 @@ - Lựa chọn Zygisk đã thay đổi sau khi xác nhận. Không có gì được cài đặt; hãy làm mới trạng thái và chọn lại các tệp lưu trữ. - Vô hiệu hoá FolkZygisk tích hợp cho lần khởi động tiếp theo và cài đặt trình cung cấp bên ngoài %1$s? Huỷ sẽ không cài đặt gì. Tiến trình tiêm hiện tại không thay đổi cho đến khi khởi động lại. Các tác động phụ của trình cài đặt không thể được khôi phục hoàn toàn; khi thất bại, các trình cung cấp dở dang sẽ bị vô hiệu hoá trước khi cố gắng khôi phục lựa chọn trước đó. - Chuyển và cài đặt - Đã huỷ. Không có module nào được cài đặt và cấu hình Zygisk không thay đổi. - Không thể tiếp tục: có nhiều trình cung cấp Zygisk bên ngoài (%1$s). Hãy vô hiệu hoá các trình cung cấp khác và chỉ giữ lại một. - Vô hiệu hoá các trình cung cấp bên ngoài (%1$s) và bật FolkZygisk tích hợp cho lần khởi động tiếp theo? Tiến trình tiêm hiện tại sẽ không thay đổi cho đến khi khởi động lại. - Cấu hình FolkZygisk thất bại. Hãy làm mới trạng thái và kiểm tra engine, xung đột trình cung cấp cùng bảo vệ khởi động. - Chuyển trình cung cấp Zygisk? - Vô hiệu hoá FolkZygisk tích hợp cho lần khởi động tiếp theo và kích hoạt %1$s? Tiến trình tiêm hiện tại không thay đổi cho đến khi khởi động lại. Các trình cung cấp bên ngoài đang hoạt động khác sẽ chặn thao tác này. - Chuyển và kích hoạt - Chuyển trình cung cấp thất bại. Hãy làm mới danh sách module và trạng thái FolkZygisk trước khi thử lại. - %1$s chưa được kích hoạt: nó xung đột với FolkZygisk tích hợp. Hãy vô hiệu hoá trình cung cấp tích hợp trước; các thay đổi lựa chọn có hiệu lực khi khởi động lại. - Xác thực hàng loạt module thất bại. Không có module nào khác được cài đặt. Hãy kiểm tra nhật ký. - Đã huỷ cài đặt trước khi có module nào được cài: %1$s xung đột với cấu hình FolkZygisk tích hợp đang bật. Hãy vô hiệu hoá trình cung cấp tích hợp trước khi cài đặt trình cung cấp bên ngoài; các thay đổi cần khởi động lại. + Cấu hình FolkZygisk thất bại. Hãy làm mới trạng thái và kiểm tra engine cùng bảo vệ khởi động. Đang đọc trạng thái tiêm… Không có trạng thái. Không thể xác minh tiến trình tiêm và cấu hình cho lần khởi động tiếp theo. Đã tiêm @@ -26,11 +13,12 @@ Cấu hình cho lần khởi động tiếp theo: đã tắt Cần khởi động lại để áp dụng cấu hình. Trình cung cấp bên ngoài: %1$s + Lần khởi động tới: trình cung cấp bên ngoài %1$s được ưu tiên. + FolkZygisk tích hợp Không thể kích hoạt: %1$s đang chờ xem xét giấy phép đang chờ chấp nhận thiết bị chưa cài đặt engine - xung đột trình cung cấp bên ngoài bảo vệ khởi động đang hoạt động hạn chế không nhận dạng diff --git a/manager/app/src/main/res/values-zh-rAG/folkzygisk.xml b/manager/app/src/main/res/values-zh-rAG/folkzygisk.xml index 6b48c8e8c..4a96f9c0f 100644 --- a/manager/app/src/main/res/values-zh-rAG/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rAG/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认之后 Zygisk 配置变啦喵,可没装任何模块哦;刷新一下状态,再重新选安装包嘛 - 关掉下次启动的内置 FolkZygisk,再装外置提供者 %1$s 喵?取消的话就什么都不装。当前注入不会变,重启后才生效。安装器的副作用没法完全回滚喵;失败时会试着停掉残留的提供者,把原配置恢复回来。 - 切换并安装喵 - 已经取消啦喵,没装任何模块,也没动 Zygisk 配置。 - 没法继续喵:有好几个外置 Zygisk 提供者(%1$s)。先把其他的停掉,只留一个哦 - 停掉外置提供者(%1$s),给下次启动开上内置 FolkZygisk 喵?当前注入状态不会变,重启后生效哦 - FolkZygisk 配置失败啦喵,刷新下状态,检查一下引擎、提供者冲突和启动保护嘛 - 要切换 Zygisk 提供者吗喵? - 关掉下次启动的内置 FolkZygisk,改成启用 %1$s 喵?当前注入状态在重启前不会变。要是有别的外置提供者还开着,人家就会拒绝操作哦 - 切换并启用喵 - 提供者切换失败啦喵,刷新下模块列表和 FolkZygisk 状态再试嘛 - 没启用 %1$s 喵:它跟内置 FolkZygisk 冲突。先关掉内置提供者嘛,选择变更重启后才生效哦 - 模块批次校验失败了喵,没继续装模块,去看看日志嘛 - 还没装任何模块就先取消了喵:%1$s 跟已开启的内置 FolkZygisk 配置冲突。先关掉内置提供者,再装外部提供者啦,配置变更得重启才行哦 + FolkZygisk 配置失败啦喵,刷新下状态,检查一下引擎和启动保护嘛 正在读取注入状态喵… 状态拿不到喵,没法确认注入状态和下次启动配置… 已注入喵 @@ -26,11 +13,12 @@ 下次启动配置喵:关闭 得重启一下才能应用配置喵 外部提供者喵:%1$s + 下次开机喵:外置提供者 %1$s 优先喵 + 内置 FolkZygisk 喵 暂时开不了喵:%1$s 许可证审查还没做完喵 真机验收还没做完喵 引擎还没装喵 - 有外部提供者冲突喵 启动保护触发啦喵 不知道是啥限制喵… diff --git a/manager/app/src/main/res/values-zh-rAT/folkzygisk.xml b/manager/app/src/main/res/values-zh-rAT/folkzygisk.xml index a95cc1266..19d31e9fb 100644 --- a/manager/app/src/main/res/values-zh-rAT/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rAT/folkzygisk.xml @@ -1,19 +1,6 @@ - 神谕确认后 Zygisk 配置已变,未蒙恩任何圣光阵;请更新状态后再遴选圣匣。 - 封印下次圣启之内建 FolkZygisk,并蒙恩外置圣光提供者 %1$s?罢手则不蒙恩任何圣光阵。当前降灵于神翼再生前不变。安装符咒之余波不可尽数归元;若败陨,将先封印残留提供者,再图归元旧选。 - 切换并蒙恩 - 已罢手,未蒙恩任何圣光阵,亦未更改 Zygisk 配置。 - 无法续行:存有多位外置 Zygisk 提供者(%1$s)。请先封印其余提供者,唯留其一。 - 封印外置提供者(%1$s),并为下次圣启唤醒内建 FolkZygisk?当前降灵之态不变,神翼再生后生效。 - FolkZygisk 配置未至,请更新状态并检视圣光引擎、提供者冲突与圣启庇护。 - 切换 Zygisk 提供者? - 封印下次圣启之内建 FolkZygisk 并唤醒 %1$s?当前降灵之态于神翼再生前不变。若存其他已唤醒之外置提供者,此术将被拒斥。 - 切换并唤醒 - 提供者切换未至,请更新圣光阵名录与 FolkZygisk 状态后再祈。 - %1$s 未得唤醒:与内建 FolkZygisk 相冲。请先封印内建提供者,选择之变更将在神翼再生后生效。 - 圣光阵批次校验未至,未续蒙恩圣光阵,请查阅神谕日志。 - 已于蒙恩任何圣光阵之前罢手:%1$s 与已开启之内建 FolkZygisk 配置相冲。请先封印内建提供者再蒙恩外置提供者,配置变更需神翼再生。 + FolkZygisk 配置未至,请更新状态并检视圣光引擎与圣启庇护。 正在读取降灵之态… 状态不可用,无法确认降灵之态及下次圣启配置。 已降灵 @@ -26,11 +13,12 @@ 下次圣启配置:封印 需神翼再生以施展配置。 外置提供者:%1$s + 下次开机:外置提供者 %1$s 当先。 + 内建 FolkZygisk 暂不可开启:%1$s 圣契审查待成 圣器验收待成 圣光引擎尚未蒙恩 - 存有外置提供者冲突 圣启庇护已触发 不可知之禁锢 diff --git a/manager/app/src/main/res/values-zh-rCK/folkzygisk.xml b/manager/app/src/main/res/values-zh-rCK/folkzygisk.xml index f4c17c438..0a5ff1358 100644 --- a/manager/app/src/main/res/values-zh-rCK/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rCK/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认后 Zygisk 配置变了,招牌菜一道没挂;刷下状态,重挑安装包。 - 关了下次开火的内建 FolkZygisk,换上外头提供者 %1$s?取消就啥也不挂。眼下注入不变,重新开火才认。安装器的幺蛾子没法全收回;翻车时会先停了残留提供者,再想法恢复原配置。 - 换了并挂上 - 算了,招牌菜一道没挂,Zygisk 配置也没动。 - 接着来不了:外头 Zygisk 提供者有好几家(%1$s)。先停了别的,只留一家。 - 停了外头提供者(%1$s),下次开火换上内建 FolkZygisk?眼下注入不变,重新开火才认。 - FolkZygisk 配置翻车了,刷下状态,瞧瞧家伙、提供者冲突和启动保护。 - 换家 Zygisk 提供者? - 关了下次开火的内建 FolkZygisk,换上 %1$s?眼下注入在重新开火前不变。还有别的外头提供者开着的话,这手儿不给干。 - 换了并开火 - 换提供者翻车了,刷刷招牌菜单子和 FolkZygisk 状态再试。 - %1$s 没开成火:它跟内建 FolkZygisk 撞了。先关了内建提供者,这选择重新开火才认。 - 招牌菜这批没过校验,没往下挂,翻翻账本。 - 还没挂任何招牌菜就停了:%1$s 跟开着的内建 FolkZygisk 配置撞了。先关了内建提供者再挂外头提供者,配置得重新开火才认。 + FolkZygisk 配置翻车了,刷下状态,瞧瞧家伙和启动保护。 正瞧注入状态… 状态用不了,注入情况和下次开火配置都瞧不准。 注进去了 @@ -26,11 +13,12 @@ 下次开火配置:关 得重新开火才认这配置。 外头提供者:%1$s + 下回开机:外头提供者 %1$s 先来。 + 内建 FolkZygisk 眼下开不了:%1$s 许可审查还欠着 真机验收还欠着 家伙还没装上 - 外头提供者撞了车 启动保护被碰响了 不知道的拦路虎 diff --git a/manager/app/src/main/res/values-zh-rCN/folkzygisk.xml b/manager/app/src/main/res/values-zh-rCN/folkzygisk.xml index ef9bc57d3..d5b3ae5b2 100644 --- a/manager/app/src/main/res/values-zh-rCN/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rCN/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认后 Zygisk 配置已变化,未安装任何模块;请刷新状态后重新选择安装包。 - 关闭下次启动的内置 FolkZygisk,并安装外置提供者 %1$s?取消不会安装任何模块。当前注入不会改变,重启后生效。安装器的副作用无法完全回滚;失败时会尝试停用残留提供者并恢复原配置。 - 切换并安装 - 已取消,未安装任何模块,也未修改 Zygisk 配置。 - 无法继续:存在多个外置 Zygisk 提供者(%1$s)。请先停用其他提供者,仅保留一个。 - 停用外置提供者(%1$s),并为下次启动启用内置 FolkZygisk?当前注入状态不会改变,重启后生效。 - FolkZygisk 配置失败,请刷新状态并检查引擎、提供者冲突与启动保护。 - 切换 Zygisk 提供者? - 关闭下次启动的内置 FolkZygisk 并启用 %1$s?当前注入状态在重启前不会改变。存在其他已启用的外部提供者时,操作将被拒绝。 - 切换并启用 - 提供者切换失败,请刷新模块列表与 FolkZygisk 状态后再试。 - 未启用 %1$s:它与内置 FolkZygisk 冲突。请先关闭内置提供者,选择变更将在重启后生效。 - 模块批次校验失败,未继续安装模块,请查看日志。 - 已在安装任何模块之前取消:%1$s 与已开启的内置 FolkZygisk 配置冲突。请先关闭内置提供者再安装外部提供者,配置变更需要重启。 + FolkZygisk 配置失败,请刷新状态并检查引擎与启动保护。 正在读取注入状态… 状态不可用,无法确认注入状态及下次启动配置。 已注入 @@ -26,11 +13,12 @@ 下次启动配置:关闭 需要重启以应用配置。 外部提供者:%1$s + 下次启动:外部提供者 %1$s 优先。 + 内置 FolkZygisk 暂不可开启:%1$s 许可证审查待完成 真机验收待完成 引擎尚未安装 - 存在外部提供者冲突 启动保护已触发 未知限制 diff --git a/manager/app/src/main/res/values-zh-rMC/folkzygisk.xml b/manager/app/src/main/res/values-zh-rMC/folkzygisk.xml index bc2217476..4cb5b0ee2 100644 --- a/manager/app/src/main/res/values-zh-rMC/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rMC/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认后 Zygisk 配置已变化,未合成任何模组;请刷新状态后重新选择合成包。 - 关闭下次启动的内置 FolkZygisk,并合成外置提供者 %1$s?取消不会合成任何模组。当前注入不会改变,重生后生效。合成器的副作用无法完全回滚;失败时会尝试停用残留提供者并恢复原配置。 - 切换并合成 - 已取消,未合成任何模组,也未修改 Zygisk 配置。 - 无法继续:存在多个外置 Zygisk 提供者(%1$s)。请先停用其他提供者,仅保留一个。 - 停用外置提供者(%1$s),并为下次启动启用内置 FolkZygisk?当前注入状态不会改变,重生后生效。 - FolkZygisk 配置失败,请刷新状态并检查引擎、提供者冲突与启动保护。 - 切换 Zygisk 提供者? - 关闭下次启动的内置 FolkZygisk 并启用 %1$s?当前注入状态在重生前不会改变。存在其他已启用的外部提供者时,操作将被拒绝。 - 切换并启用 - 提供者切换失败,请刷新模组列表与 FolkZygisk 状态后再试。 - 未启用 %1$s:它与内置 FolkZygisk 冲突。请先关闭内置提供者,选择变更将在重生后生效。 - 模组批次校验失败,未继续合成模组,请查看日志。 - 已在合成任何模组之前取消:%1$s 与已开启的内置 FolkZygisk 配置冲突。请先关闭内置提供者再合成外部提供者,配置变更需要重生。 + FolkZygisk 配置失败,请刷新状态并检查引擎与启动保护。 正在读取注入状态… 状态不可用,无法确认注入状态及下次启动配置。 已注入 @@ -26,11 +13,12 @@ 下次启动配置:关闭 需要重生以应用配置。 外部提供者:%1$s + 下次启动:外部提供者 %1$s 优先。 + 内置 FolkZygisk 暂不可开启:%1$s 许可证审查待完成 真机验收待完成 引擎尚未合成 - 存在外部提供者冲突 启动保护已触发 未知限制 diff --git a/manager/app/src/main/res/values-zh-rTW/folkzygisk.xml b/manager/app/src/main/res/values-zh-rTW/folkzygisk.xml index c44afdec2..c1011bbc2 100644 --- a/manager/app/src/main/res/values-zh-rTW/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rTW/folkzygisk.xml @@ -1,19 +1,6 @@ - 確認後 Zygisk 選項已變更。未安裝任何項目;請重新整理狀態並重新選擇封存檔。 - 要為下次開機停用內建 FolkZygisk 並安裝外部提供者 %1$s 嗎?取消則不會安裝任何項目。重新啟動前,目前的注入維持不變。安裝程式的副作用無法完全復原;若失敗,會先停用部分提供者,再嘗試還原先前的選項。 - 切換並安裝 - 已取消。未安裝任何模組,也未變更 Zygisk 設定。 - 無法繼續:有多個外部 Zygisk 提供者(%1$s)。請停用其他提供者,僅保留一個。 - 要為下次開機停用外部提供者(%1$s)並啟用內建 FolkZygisk 嗎?重新啟動前,目前的注入不會改變。 - FolkZygisk 設定失敗。請重新整理狀態,並檢查引擎、提供者衝突與啟動保護。 - 要切換 Zygisk 提供者嗎? - 要為下次開機停用內建 FolkZygisk 並啟用 %1$s 嗎?重新啟動前,目前的注入維持不變。其他啟用中的外部提供者會阻擋此操作。 - 切換並啟用 - 提供者切換失敗。重試前請重新整理模組清單與 FolkZygisk 狀態。 - 未啟用 %1$s:它與內建 FolkZygisk 衝突。請先停用內建提供者;選項變更於重新啟動後生效。 - 模組批次驗證失敗。未再安裝其他模組。請檢查日誌。 - 在任何模組安裝前已取消安裝:%1$s 與已啟用的內建 FolkZygisk 設定衝突。安裝外部提供者前,請先停用內建提供者;變更需要重新啟動。 + FolkZygisk 設定失敗。請重新整理狀態,並檢查引擎與啟動保護。 正在讀取注入狀態… 無法取得狀態。無法驗證注入與下次開機設定。 已注入 @@ -26,11 +13,12 @@ 下次開機設定:已停用 需要重新啟動才能套用設定。 外部提供者:%1$s + 下次啟動:外部提供者 %1$s 優先。 + 內建 FolkZygisk 無法啟用:%1$s 授權審查待處理 裝置驗收待處理 尚未安裝引擎 - 外部提供者衝突 啟動保護已啟用 無法辨識的限制 diff --git a/manager/app/src/main/res/values-zh-rWC/folkzygisk.xml b/manager/app/src/main/res/values-zh-rWC/folkzygisk.xml index cb5b29e22..3c5b184e3 100644 --- a/manager/app/src/main/res/values-zh-rWC/folkzygisk.xml +++ b/manager/app/src/main/res/values-zh-rWC/folkzygisk.xml @@ -1,19 +1,6 @@ - 确认后 Zygisk 之择已易,未炼化任何仙符;请刷新状态,再择卷宗。 - 禁下次启道之内置 FolkZygisk,并炼化外道提供者 %1$s?罢了则一无所炼。当前注入于重铸前不变。炼器之余波无法尽复;若败陨,将先禁残留之提供者,再图复还旧择。 - 切换并炼化 - 已罢了,未炼化仙符,亦未易 Zygisk 之设。 - 无法继行:存有多个外道 Zygisk 提供者(%1$s)。请先禁其余,唯留其一。 - 禁外道提供者(%1$s),并为下次启道启用内置 FolkZygisk?当前注入于重铸前不变。 - FolkZygisk 设符败陨,请刷新状态并察法门、提供者之冲突与启道护持。 - 切换 Zygisk 提供者? - 禁下次启道之内置 FolkZygisk 并启用 %1$s?当前注入于重铸前不变。存有他启之外道提供者时,此术将被拒。 - 切换并启用 - 提供者切换败陨,请刷新仙符名录与 FolkZygisk 状态再试。 - 未启 %1$s:其与内置 FolkZygisk 相冲突。请先禁内置提供者,所择之变将于重铸后生效。 - 仙符批次校验败陨,未续炼化仙符,请观仙录。 - 已于炼化任何仙符前止之:%1$s 与已启之内置 FolkZygisk 设符相冲突。请先禁内置提供者,再炼外道提供者;设符之变需重铸。 + FolkZygisk 设符败陨,请刷新状态并察法门与启道护持。 正读取注入状态… 状态不可用,无法验明注入与下次启道之设。 已注入 @@ -26,11 +13,12 @@ 下次启道之设:闭关 需重铸仙躯以施行此设。 外道提供者:%1$s + 下次开机:外道提供者 %1$s 当先。 + 内置 FolkZygisk 暂不可启:%1$s 仙门允准待审 真机验收待成 法门尚未炼入 - 存有外道提供者冲突 启道护持已动 未知之限 diff --git a/manager/app/src/main/res/values/folkzygisk.xml b/manager/app/src/main/res/values/folkzygisk.xml index a0d4ff1a4..750ee8076 100644 --- a/manager/app/src/main/res/values/folkzygisk.xml +++ b/manager/app/src/main/res/values/folkzygisk.xml @@ -1,19 +1,6 @@ - Zygisk selection changed after confirmation. Nothing was installed; refresh the state and select the archives again. - Disable built-in FolkZygisk for the next boot and install external provider %1$s? Cancel installs nothing. Current injection is unchanged until reboot. Installer side effects cannot be fully rolled back; on failure, partial providers will be disabled before attempting to restore the previous selection. - Switch and install - Canceled. No modules installed or Zygisk configuration changed. - Cannot continue: multiple external Zygisk providers (%1$s). Disable the other providers and keep only one. - Disable external providers (%1$s) and enable built-in FolkZygisk for the next boot? Current injection will not change until reboot. - FolkZygisk configuration failed. Refresh status and check the engine, provider conflicts and startup protection. - Switch Zygisk provider? - Disable built-in FolkZygisk for the next boot and activate %1$s? The current injection is unchanged until reboot. Other active external providers will block this operation. - Switch and activate - Provider switch failed. Refresh the module list and FolkZygisk status before retrying. - %1$s was not activated: it conflicts with built-in FolkZygisk. Disable the built-in provider first; selection changes apply on reboot. - Module batch validation failed. No further modules were installed. Check the log. - Installation cancelled before any module was installed: %1$s conflicts with the enabled built-in FolkZygisk configuration. Disable the built-in provider before installing an external provider; changes require a reboot. + FolkZygisk configuration failed. Refresh status and check the engine and startup protection. Reading injection status… Status unavailable. Injection and next-boot configuration could not be verified. Injected @@ -26,11 +13,12 @@ Next-boot configuration: disabled Restart required to apply the configuration. External providers: %1$s + Next boot: external provider %1$s takes precedence. + Built-in FolkZygisk Activation unavailable: %1$s license review pending device acceptance pending engine not installed - external provider conflict startup protection active unrecognized restriction diff --git a/manager/app/src/test/java/me/weishu/kernelsu/data/model/FolkZygiskPreflightTest.kt b/manager/app/src/test/java/me/weishu/kernelsu/data/model/FolkZygiskPreflightTest.kt deleted file mode 100644 index a0cc6a94f..000000000 --- a/manager/app/src/test/java/me/weishu/kernelsu/data/model/FolkZygiskPreflightTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package me.weishu.kernelsu.data.model - -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class FolkZygiskPreflightTest { - private fun payload() = JSONObject("""{"schema_version":2,"builtin_enabled":true,"provider_ids":["rezygisk"],"conflict":true,"external_providers":[],"blocked":false,"state_token":"${"a".repeat(64)}"}""") - - @Test fun parsesConflict() { - assertEquals(listOf("rezygisk"), FolkZygiskPreflight.fromJson(payload()).providerIds) - } - - @Test fun rejectsMalformedOrInconsistentResponses() { - for ((key, value) in listOf("schema_version" to 1, "builtin_enabled" to "true", "conflict" to false, "state_token" to "bad")) { - assertThrows(Exception::class.java) { FolkZygiskPreflight.fromJson(payload().put(key, value)) } - } - assertThrows(Exception::class.java) { FolkZygiskPreflight.fromJson(payload().apply { remove("provider_ids") }) } - } - - @Test fun rejectsUnblockedMultipleProviders() { - val json = payload().put("external_providers", org.json.JSONArray().put("onyxzygisk")) - assertThrows(Exception::class.java) { FolkZygiskPreflight.fromJson(json) } - assertEquals(true, FolkZygiskPreflight.fromJson(json.put("blocked", true)).blocked) - // A duplicate provider archive is blocked by the backend even if IDs deduplicate. - assertEquals(true, FolkZygiskPreflight.fromJson(payload().put("blocked", true)).blocked) - } -} diff --git a/manager/app/src/test/java/me/weishu/kernelsu/data/model/ModuleMutationResultTest.kt b/manager/app/src/test/java/me/weishu/kernelsu/data/model/ModuleMutationResultTest.kt deleted file mode 100644 index 1a18011ff..000000000 --- a/manager/app/src/test/java/me/weishu/kernelsu/data/model/ModuleMutationResultTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package me.weishu.kernelsu.data.model - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class ModuleMutationResultTest { - @Test fun onlyBackendFailureMarkerIdentifiesConflict() { - assertTrue(ModuleMutationResult.fromOutput(false, listOf(" FOLKZYGISK_CONFLICT: rezygisk")).zygiskConflict) - assertTrue(ModuleMutationResult.fromOutput(false, listOf("Error: FOLKZYGISK_CONFLICT: rezygisk")).zygiskConflict) - assertFalse(ModuleMutationResult.fromOutput(true, listOf("FOLKZYGISK_CONFLICT: example")).zygiskConflict) - assertFalse(ModuleMutationResult.fromOutput(false, listOf("unrelated failure")).zygiskConflict) - assertFalse(ModuleMutationResult.fromOutput(false, listOf("mention FOLKZYGISK_CONFLICT: example")).zygiskConflict) - } - - @Test fun distinguishesExternalProviderConflict() { - val result = ModuleMutationResult.fromOutput(false, listOf("Error: FOLKZYGISK_EXTERNAL_CONFLICT: onyxzygisk")) - assertTrue(result.externalConflict) - assertFalse(result.zygiskConflict) - assertFalse(ModuleMutationResult.fromOutput(true, listOf("FOLKZYGISK_EXTERNAL_CONFLICT: example")).externalConflict) - } -} diff --git a/manager/app/src/test/java/me/weishu/kernelsu/ui/util/FlashOutputCollectorTest.kt b/manager/app/src/test/java/me/weishu/kernelsu/ui/util/FlashOutputCollectorTest.kt index bc0b65e16..4b2d66828 100644 --- a/manager/app/src/test/java/me/weishu/kernelsu/ui/util/FlashOutputCollectorTest.kt +++ b/manager/app/src/test/java/me/weishu/kernelsu/ui/util/FlashOutputCollectorTest.kt @@ -8,37 +8,37 @@ import java.util.concurrent.Executor class FlashOutputCollectorTest { @Test - fun preflightOutputIsAvailableBeforeUiCallbackDelivery() { + fun outputIsStoredBeforeUiCallbackDelivery() { val pending = ArrayDeque() val delivered = mutableListOf() val output = FlashOutputCollector(Executor { pending.add(it) }, delivered::add) - val json = "{\"schema_version\":2}" + val line = "Installing module" - val reader = Thread { output.add(json) } + val reader = Thread { output.add(line) } reader.start() reader.join() - assertEquals(listOf(json), output.toList()) + assertEquals(listOf(line), output.toList()) assertTrue(delivered.isEmpty()) pending.removeFirst().run() - assertEquals(listOf(json), delivered) + assertEquals(listOf(line), delivered) } @Test - fun installationMarkersAreAvailableWithoutDrainingUiQueue() { + fun everyLineIsStoredWithoutDrainingUiQueue() { val pending = ArrayDeque() val stdout = FlashOutputCollector(Executor { pending.add(it) }) {} val stderr = FlashOutputCollector(Executor { pending.add(it) }) {} val reader = Thread { - stdout.add("FOLKZYGISK_BATCH_CHANGED") - stderr.add("Error: FOLKZYGISK_STATE_CHANGED: selection changed") + stdout.add("Installing module") + stderr.add("Error: cannot read archive") } reader.start() reader.join() - assertTrue(stdout.any { it == "FOLKZYGISK_BATCH_CHANGED" }) + assertTrue(stdout.any { it == "Installing module" }) assertTrue(stderr.any { - it.trimStart().removePrefix("Error: ").startsWith("FOLKZYGISK_STATE_CHANGED:") + it.trimStart().removePrefix("Error: ").startsWith("cannot read archive") }) assertEquals(2, pending.size) } diff --git a/userspace/folkzygisk/UPSTREAM.md b/userspace/folkzygisk/UPSTREAM.md index f86204965..de55a9ede 100644 --- a/userspace/folkzygisk/UPSTREAM.md +++ b/userspace/folkzygisk/UPSTREAM.md @@ -60,7 +60,7 @@ skips startup; services only inspects health; boot completion clears the counter only with validated injection. The monitor holds a lifetime flock and does not inherit it across exec. Daemons no longer implicitly disable kernel_umount. Public activation is available following maintainer release approval and Android -16/arm64 device acceptance. Missing engine, external provider conflicts and the +16/arm64 device acceptance. Missing engine and the startup failure latch remain operational blockers. There is no development-only enable feature. Changes affect the next boot, never restart zygote automatically. Startup clears stale runtime artifacts while preserving and checking the monitor @@ -106,25 +106,35 @@ is refused while a validated monitor is alive or startup was attempted this boot, since running daemons resolve libraries through the engine path. It does not change provider selection or launch injection. -## Provider confirmation and installation - -The Manager stages an entire ZIP batch once and uses schema-2 preflight before -running installers. A built-in/external conflict offers switch-and-install or -cancel; multiple external providers are blocked. `zygisk install-batch` checks -the confirmed selection token under the shared operation lock and keeps that -lock across all installers. Ordinary modules run first, the single provider -archive last. Duplicate provider archives are rejected, including shared IDs. -Failed provider installs are quarantined in both module trees before restoring -built-in selection; failed quarantine leaves built-in selection disabled. Other -successful modules and arbitrary installer side effects are not rolled back. -These transactions recover ordinary errors, not process death or power loss. - -Enabling the built-in provider with `--disable-external` requires one -`--expected-external ` per provider shown in the confirmation. A changed -provider set is rejected before marker changes. Enable/undo-removal clears the -corresponding marker in installed and pending copies, restoring markers if rc -regeneration fails. All recognized provider installation/activation paths refuse -other active external IDs; plugin modules are not provider conflicts. +## External provider precedence + +Provider recognition drives the display and the built-in engine's boot +step-aside: the Manager names a known external implementation (`zygisksu`, +`zygisknext`, `rezygisk`, `neozygisk`, `shirokozygisk`, `onyxzygisk`) in the +home row and the FolkZygisk settings summary, and `post_fs_data` lets it win. +Recognition never blocks installation, activation or uninstallation of any +module. Module operations run the same commands as upstream KernelSU and take no +Zygisk lock, so a module archive installs whether or not it registers the Zygisk +API. + +An active external provider wins. `post_fs_data` reads the provider set before +touching the boot guard and returns early, so yielding consumes no startup +attempt and cannot latch startup protection. `services` returns early in the +same case and reports no built-in health; the settings summary then shows the +step-aside line rather than an injection state. The next-boot configuration is +left untouched, so removing or disabling the external provider lets the built-in +engine resume on the following boot without re-enabling anything. Only a module +ID from the recognized list is treated as a provider, so a provider built under +another ID would not be detected and both engines would inject. + +`ksud zygisk enable` writes `enabled: true` and nothing else. It never creates +`disable` markers on provider modules; when a provider is present it reports +that the provider takes precedence and the built-in engine will step aside. The +only module-tree writes on enable and undo-removal are the marker clears in +`module_markers.rs`: the `disable` (or `remove`) marker is cleared in the +installed and pending copies together, and both are restored if rc regeneration +fails. Clearing only the installed copy would let a bare `enable` or +`undo uninstall` silently revert on boot promotion. ## Kernel unmount coordination diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index a671e1500..800d67af7 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -617,7 +617,7 @@ pub fn run() -> Result<()> { let result = match cli.command { Commands::Anykernel3 { zip } => crate::anykernel3::flash(&zip), Commands::Susfs { command } => crate::susfs::run(command), - Commands::Zygisk { command } => crate::zygisk::run(command), + Commands::Zygisk { command } => crate::zygisk::run(&command), Commands::PostFsData => init_event::on_post_fs_data(), Commands::BootCompleted => { init_event::on_boot_completed(); diff --git a/userspace/ksud/src/main.rs b/userspace/ksud/src/main.rs index b8f3dbff6..cceb18f3e 100644 --- a/userspace/ksud/src/main.rs +++ b/userspace/ksud/src/main.rs @@ -48,6 +48,9 @@ mod metamodule; mod module; #[cfg(target_os = "android")] mod module_config; +#[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +mod module_markers; mod mount_plan; #[cfg(any(target_os = "android", test))] mod plugin; diff --git a/userspace/ksud/src/module.rs b/userspace/ksud/src/module.rs index e0c241130..efef36398 100644 --- a/userspace/ksud/src/module.rs +++ b/userspace/ksud/src/module.rs @@ -2,6 +2,7 @@ use crate::utils::*; use crate::{ assets, defs, ksucalls, metamodule, + module_markers::clear_activation_markers, restorecon::{restore_syscon, setsyscon}, sepolicy, }; @@ -685,8 +686,6 @@ fn install_module_to_system(zip: &str) -> Result<()> { validate_module_id(module_id) .with_context(|| format!("Invalid module ID in module.prop: '{module_id}'"))?; - crate::zygisk::check_activation(module_id)?; - // Check if this module is a metamodule let is_metamodule = metamodule::is_metamodule(&module_prop); @@ -798,14 +797,6 @@ fn install_module_to_system(zip: &str) -> Result<()> { pub fn install_module(zip: &str) -> Result<()> { ksucalls::ensure_uapi_version_matched()?; - let _zygisk_lock = crate::zygisk::lock()?; - - install_module_locked(zip) -} - -/// Caller holds the shared provider/configuration lock. -pub fn install_module_locked(zip: &str) -> Result<()> { - ksucalls::ensure_uapi_version_matched()?; let result = install_module_to_system(zip); if let Err(ref e) = result { @@ -818,23 +809,20 @@ pub fn install_module_locked(zip: &str) -> Result<()> { pub fn undo_uninstall_module(id: &str) -> Result<()> { validate_module_id(id)?; - let _zygisk_lock = crate::zygisk::lock()?; - crate::zygisk::check_activation(id)?; let module_path = Path::new(defs::MODULE_DIR).join(id); ensure!(module_path.exists(), "Module {id} not found"); - crate::zygisk::clear_activation_markers( + clear_activation_markers( &module_path, &Path::new(defs::MODULE_UPDATE_DIR).join(id), - true, + defs::REMOVE_FILE_NAME, regenerate_preinit_rc, ) } pub fn uninstall_module(id: &str) -> Result<()> { validate_module_id(id)?; - let _zygisk_lock = crate::zygisk::lock()?; let module_path = Path::new(defs::MODULE_DIR).join(id); ensure!(module_path.exists(), "Module {id} not found"); @@ -862,41 +850,20 @@ pub fn run_action(id: &str) -> Result<()> { pub fn enable_module(id: &str) -> Result<()> { validate_module_id(id)?; - let _zygisk_lock = crate::zygisk::lock()?; - crate::zygisk::check_activation(id)?; let module_path = Path::new(defs::MODULE_DIR).join(id); ensure!(module_path.exists(), "Module {id} not found"); - crate::zygisk::clear_activation_markers( + clear_activation_markers( &module_path, &Path::new(defs::MODULE_UPDATE_DIR).join(id), - false, + defs::DISABLE_FILE_NAME, regenerate_preinit_rc, ) } -/// Only called with the Zygisk operation lock held and selection already disabled. -pub fn activate_external_provider_locked(id: &str, undo_uninstall: bool) -> Result<()> { - validate_module_id(id)?; - let path = Path::new(defs::MODULE_DIR).join(id); - ensure!(path.is_dir(), "Module {id} not found"); - let pending = Path::new(defs::MODULE_UPDATE_DIR).join(id); - let other = if undo_uninstall { - defs::DISABLE_FILE_NAME - } else { - defs::REMOVE_FILE_NAME - }; - ensure!( - !path.join(other).exists() && !pending.join(other).exists(), - "Module {id} is still disabled or marked for removal" - ); - crate::zygisk::clear_activation_markers(&path, &pending, undo_uninstall, regenerate_preinit_rc) -} - pub fn disable_module(id: &str) -> Result<()> { validate_module_id(id)?; - let _zygisk_lock = crate::zygisk::lock()?; let module_path = Path::new(defs::MODULE_DIR).join(id); ensure!(module_path.exists(), "Module {id} not found"); diff --git a/userspace/ksud/src/module_markers.rs b/userspace/ksud/src/module_markers.rs new file mode 100644 index 000000000..c6ab143bd --- /dev/null +++ b/userspace/ksud/src/module_markers.rs @@ -0,0 +1,87 @@ +//! Marker handling shared by the module lifecycle commands. +//! +//! This lives outside `module.rs` on purpose: that module only compiles for Android, while the +//! marker dance is plain filesystem work that deserves host unit tests. + +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Clear one marker file on both boot-promotion copies, rolling back on rc failure. +/// +/// Boot promotion copies the pending module over the installed one, so a pending copy inherits +/// the installed module's markers. Clearing only the installed copy would let a bare +/// `enable`/`undo uninstall` silently revert on the next boot. +pub fn clear_activation_markers( + installed: &Path, + pending: &Path, + marker: &str, + mut refresh: impl FnMut() -> Result<()>, +) -> Result<()> { + let mut previous = Vec::new(); + for directory in [installed, pending] { + let path = directory.join(marker); + match std::fs::read(&path) { + Ok(bytes) => previous.push((path, bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + + let result = (|| { + for (path, _) in &previous { + std::fs::remove_file(path)?; + } + refresh() + })(); + if let Err(error) = result { + for (path, bytes) in previous { + std::fs::write(path, bytes).context("Restore activation marker")?; + } + refresh().context("Restore activation rc")?; + return Err(error); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::bail; + + #[test] + fn activation_markers_clear_together_and_restore_on_failure() -> Result<()> { + let dir = tempfile::tempdir()?; + let installed = dir.path().join("installed"); + let pending = dir.path().join("pending"); + std::fs::create_dir_all(&installed)?; + std::fs::create_dir_all(&pending)?; + + for marker in ["disable", "remove"] { + for path in [&installed, &pending] { + std::fs::write(path.join(marker), b"original")?; + } + + let mut calls = 0; + assert!( + clear_activation_markers(&installed, &pending, marker, || { + calls += 1; + if calls == 1 { + bail!("rc failure"); + } + Ok(()) + }) + .is_err() + ); + for path in [&installed, &pending] { + assert_eq!(std::fs::read(path.join(marker))?, b"original"); + } + + clear_activation_markers(&installed, &pending, marker, || Ok(()))?; + for path in [&installed, &pending] { + assert!(!path.join(marker).exists()); + } + } + Ok(()) + } +} diff --git a/userspace/ksud/src/zygisk.rs b/userspace/ksud/src/zygisk.rs index 263fc982c..8cf1e1082 100644 --- a/userspace/ksud/src/zygisk.rs +++ b/userspace/ksud/src/zygisk.rs @@ -1,7 +1,11 @@ -//! FolkZygisk provider configuration and authoritative module conflict checks. +//! FolkZygisk provider selection, external-provider step-aside and status reporting. //! Engine readiness and injection are separate from next-boot configuration. +//! +//! An active external provider wins: the built-in engine stays idle without touching module +//! installation or rewriting provider modules. Removing the external provider resumes the +//! built-in engine on the next boot because the selection is left untouched. -use anyhow::{Context, Result, bail, ensure}; +use anyhow::{Context, Result, ensure}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::fs::{self, File, OpenOptions}; @@ -172,10 +176,12 @@ pub fn post_fs_data(safe_mode: bool) -> Result<()> { if !read_config(Path::new(CONFIG))?.enabled { return Ok(()); } - ensure!( - providers(Path::new(MODULES), Path::new(UPDATES))?.is_empty(), - "FOLKZYGISK_CONFLICT: external provider active; skip built-in startup" - ); + // An active external provider wins. Step aside before touching the boot guard so that + // yielding never consumes a startup attempt. + if !providers(Path::new(MODULES), Path::new(UPDATES))?.is_empty() { + log::info!("External Zygisk provider active; built-in FolkZygisk steps aside"); + return Ok(()); + } let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id")?; let mut guard = load_guard(Path::new(BOOT_GUARD))?; let attempt = guard.attempt(boot.trim()); @@ -265,6 +271,10 @@ pub fn services() -> Result<()> { if crate::utils::is_safe_mode() || !read_config(Path::new(CONFIG))?.enabled { return Ok(()); } + // Stepped aside for an external provider: there is no built-in health to report. + if !providers(Path::new(MODULES), Path::new(UPDATES))?.is_empty() { + return Ok(()); + } let runtime = read_runtime(Path::new(RUNTIME), Path::new("/proc"))?; log::info!( "FolkZygisk service health: {}", @@ -302,123 +312,14 @@ pub enum Command { #[arg(long)] json: bool, }, - /// Enable FolkZygisk for the next boot; refuses active external providers - Enable { - /// Explicitly disable recognized external providers before selecting built-in - #[arg(long)] - disable_external: bool, - /// Exact external IDs shown in the confirmation dialog - #[arg(long)] - expected_external: Vec, - }, + /// Enable FolkZygisk for the next boot + Enable, /// Disable FolkZygisk for the next boot Disable, /// Clear the startup failure latch without enabling or starting injection ResetFailures, /// Install embedded engine assets without enabling or starting injection InstallEngine, - /// Inspect an entire ZIP batch before any installer is executed - Preflight { - #[arg(required = true)] - archives: Vec, - }, - /// Install a preflighted batch under one provider/configuration lock - InstallBatch { - #[arg(long)] - expected_state: String, - #[arg(long)] - switch_external: bool, - #[arg(required = true)] - archives: Vec, - }, - /// Explicitly select an installed external provider for the next boot - SwitchExternal { - id: String, - #[arg(long)] - undo_uninstall: bool, - }, -} - -#[derive(Serialize)] -struct PreflightStatus { - schema_version: u32, - builtin_enabled: bool, - provider_ids: Vec, - conflict: bool, - external_providers: Vec, - blocked: bool, - state_token: String, -} - -fn preflight(enabled: bool, ids: &[String], external: &[String]) -> PreflightStatus { - let provider_ids: Vec<_> = ids - .iter() - .filter(|id| is_provider(id)) - .cloned() - .collect::>() - .into_iter() - .collect(); - PreflightStatus { - schema_version: 2, - builtin_enabled: enabled, - conflict: enabled && !provider_ids.is_empty(), - blocked: provider_ids - .iter() - .chain(external) - .collect::>() - .len() - > 1 - || ids.iter().filter(|id| is_provider(id)).count() > 1, - state_token: selection_token(enabled, external), - external_providers: external.to_vec(), - provider_ids, - } -} - -fn selection_token(enabled: bool, external: &[String]) -> String { - let mut identity = vec![u8::from(enabled)]; - for id in external { - identity.extend_from_slice(id.as_bytes()); - identity.push(0); - } - sha256::digest(&identity) -} - -fn check_confirmed_providers(expected: &[String], actual: &[String]) -> Result<()> { - let expected: BTreeSet<_> = expected.iter().collect(); - let actual: BTreeSet<_> = actual.iter().collect(); - ensure!( - expected == actual, - "FOLKZYGISK_STATE_CHANGED: external providers changed; confirm again" - ); - Ok(()) -} - -fn archive_id(archive: &Path) -> Result { - use java_properties::PropertiesIter; - use std::io::Cursor; - use zip_extensions::inflate::zip_extract::zip_extract_file_to_memory; - let mut bytes = Vec::new(); - zip_extract_file_to_memory( - &archive.canonicalize()?, - &std::path::PathBuf::from("module.prop"), - &mut bytes, - )?; - let mut properties = std::collections::HashMap::new(); - PropertiesIter::new_with_encoding(Cursor::new(bytes), encoding_rs::UTF_8).read_into( - |key, value| { - properties.insert(key, value); - }, - )?; - let id = properties - .get("id") - .context("Module ID missing from module.prop")? - .trim() - .to_owned(); - #[cfg(target_os = "android")] - crate::module::validate_module_id(&id)?; - ensure!(!id.is_empty(), "Empty module ID"); - Ok(id) } #[derive(Default, Deserialize, Serialize)] @@ -443,14 +344,11 @@ struct Status { startup_blocked: bool, } -fn enable_blockers(engine: bool, external: bool, blocked: bool) -> Vec<&'static str> { +fn enable_blockers(engine: bool, blocked: bool) -> Vec<&'static str> { let mut blockers = Vec::new(); if !engine { blockers.push("engine_missing"); } - if external { - blockers.push("external_provider_conflict"); - } if blocked { blockers.push("startup_protection"); } @@ -566,9 +464,15 @@ fn injection_state(runtime: Option<&RuntimeStatus>) -> &'static str { } /// Recognize provider packages, not modules which merely use the Zygisk API. -pub fn is_provider(id: &str) -> bool { +/// +/// Mirrors FolkPatch and drives two things: naming an external implementation in the UI, and the +/// built-in engine stepping aside at boot. It never gates module installation. +fn is_provider(id: &str) -> bool { // Zygisk Next, NeoZygisk and HSSkyBoy/NyaZygisk share the module ID zygisksu. - matches!(id, "zygisksu" | "rezygisk" | "onyxzygisk") + matches!( + id, + "zygisksu" | "zygisknext" | "rezygisk" | "neozygisk" | "shirokozygisk" | "onyxzygisk" + ) } fn read_config(path: &Path) -> Result { @@ -614,9 +518,9 @@ fn providers(modules: &Path, updates: &Path) -> Result> { Ok(ids.into_iter().collect()) } -/// Hold across the full operation, including installer execution and rc refresh. +/// Serialize FolkZygisk configuration changes and boot-startup checks. /// flock is released by closing the descriptor, including on error/unwind. -pub fn lock() -> Result { +fn lock() -> Result { fs::create_dir_all("/data/adb/ksu")?; let file = OpenOptions::new() .read(true) @@ -628,67 +532,11 @@ pub fn lock() -> Result { // SAFETY: file owns a valid descriptor for the duration of this call. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(std::io::Error::last_os_error()) - .context("Zygisk configuration/module operation busy; retry after it finishes"); + .context("FolkZygisk configuration is busy; retry after it finishes"); } Ok(file) } -/// Called before extracting a provider ZIP, removing disable, or undoing removal. -pub fn check_activation(id: &str) -> Result<()> { - if !is_provider(id) { - return Ok(()); - } - if read_config(Path::new(CONFIG))?.enabled { - bail!( - "FOLKZYGISK_CONFLICT: {id} cannot be installed or activated while FolkZygisk is enabled; disable FolkZygisk first (changes apply on reboot)" - ); - } - ensure_single_external(id, &providers(Path::new(MODULES), Path::new(UPDATES))?)?; - Ok(()) -} - -fn ensure_single_external(id: &str, external: &[String]) -> Result<()> { - ensure!( - external.iter().all(|provider| provider == id), - "FOLKZYGISK_EXTERNAL_CONFLICT: other external providers are active: {}", - external.join(", ") - ); - Ok(()) -} - -/// Clear only the requested marker on both boot-promotion copies. Roll back on rc failure. -pub fn clear_activation_markers( - installed: &Path, - pending: &Path, - undo_uninstall: bool, - mut refresh: impl FnMut() -> Result<()>, -) -> Result<()> { - let marker = if undo_uninstall { "remove" } else { "disable" }; - let mut previous = Vec::new(); - for directory in [installed, pending] { - let path = directory.join(marker); - match fs::read(&path) { - Ok(bytes) => previous.push((path, bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - } - let result = (|| { - for (path, _) in &previous { - fs::remove_file(path)?; - } - refresh() - })(); - if let Err(error) = result { - for (path, bytes) in previous { - fs::write(path, bytes).context("Restore activation marker")?; - } - refresh().context("Restore activation rc")?; - return Err(error); - } - Ok(()) -} - fn engine_available(root: &Path) -> bool { let (monitor, daemon, library) = if cfg!(target_pointer_width = "64") { ("bin/zygisk-ptrace64", "bin/zygiskd64", "lib64/libzygisk.so") @@ -705,103 +553,6 @@ fn write_config(path: &Path, config: &Config) -> Result<()> { write_json(path, config) } -/// Caller holds the shared operation lock. Never execute external installers. -fn select_builtin( - config: &Path, - modules: &Path, - updates: &Path, - external: &[String], - mut refresh_rc: impl FnMut() -> Result<()>, -) -> Result<()> { - let previous = read_config(config)?; - let mut created = Vec::new(); - let result = (|| { - for id in external { - ensure!(is_provider(id), "Unrecognized Zygisk provider"); - for root in [modules, updates] { - let directory = root.join(id); - if directory.is_dir() && !directory.join("disable").exists() { - let path = directory.join("disable"); - File::options() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path)? - .sync_all()?; - created.push(path); - } - } - } - refresh_rc()?; - write_config(config, &Config { enabled: true }) - })(); - if let Err(error) = result { - for path in created { - fs::remove_file(path).context("Restore external provider marker")?; - } - write_config(config, &previous).context("Restore FolkZygisk selection")?; - refresh_rc().context("Restore external provider init rc")?; - return Err(error); - } - Ok(()) -} - -/// Caller holds the provider lock. Restore selection on ordinary operation errors. -/// This is not crash recovery and never changes the running zygote. -fn switch_external(config: &Path, id: &str, activate: impl FnOnce() -> Result<()>) -> Result<()> { - ensure!( - is_provider(id), - "Not a recognized external Zygisk provider: {id}" - ); - let previous = read_config(config)?; - write_config(config, &Config { enabled: false })?; - if let Err(error) = activate() { - write_config(config, &previous) - .context("Restore provider selection after activation failure")?; - return Err(error); - } - Ok(()) -} - -/// Restore built-in selection only after a failed installer's provider copies are quarantined. -fn install_external( - config: &Path, - modules: &Path, - updates: &Path, - id: &str, - install: impl FnOnce() -> Result<()>, - mut refresh: impl FnMut() -> Result<()>, -) -> Result<()> { - let previous = read_config(config)?; - for root in [modules, updates] { - ensure!( - !root.join(id).join("remove").exists(), - "Provider is marked for removal; undo removal before installing" - ); - } - write_config(config, &Config { enabled: false })?; - let result = install().and_then(|()| { - clear_activation_markers(&modules.join(id), &updates.join(id), false, &mut refresh)?; - ensure_single_external(id, &providers(modules, updates)?) - }); - if let Err(error) = result { - for root in [modules, updates] { - let path = root.join(id); - if path.is_dir() { - File::create(path.join("disable"))?.sync_all()?; - } - } - refresh()?; - ensure!( - providers(modules, updates)?.is_empty(), - "Provider quarantine failed; built-in remains disabled" - ); - write_config(config, &previous)?; - return Err(error); - } - Ok(()) -} - fn write_json(path: &Path, value: &impl Serialize) -> Result<()> { let parent = path.parent().context("Missing configuration directory")?; fs::create_dir_all(parent)?; @@ -817,102 +568,16 @@ fn write_json(path: &Path, value: &impl Serialize) -> Result<()> { Ok(()) } -pub fn run(command: Command) -> Result<()> { +pub fn run(command: &Command) -> Result<()> { let _lock = lock()?; match command { - Command::SwitchExternal { id, undo_uninstall } => { - #[cfg(target_os = "android")] - { - crate::module::validate_module_id(&id)?; - let external = providers(Path::new(MODULES), Path::new(UPDATES))?; - ensure_single_external(&id, &external)?; - switch_external(Path::new(CONFIG), &id, || { - crate::module::activate_external_provider_locked(&id, undo_uninstall) - })?; - println!("External provider {id} selected for the next boot; reboot required"); - } - #[cfg(not(target_os = "android"))] - { - let _ = (id, undo_uninstall); - bail!("External provider activation requires Android"); - } - } - Command::Preflight { archives } => { - let ids = archives - .iter() - .map(|archive| archive_id(Path::new(archive))) - .collect::>>()?; - let status = preflight( - read_config(Path::new(CONFIG))?.enabled, - &ids, - &providers(Path::new(MODULES), Path::new(UPDATES))?, - ); - println!("{}", serde_json::to_string(&status)?); - } - Command::InstallBatch { - expected_state, - switch_external: confirmed, - archives, - } => { - let ids = archives - .iter() - .map(|archive| archive_id(Path::new(archive))) - .collect::>>()?; - let config = read_config(Path::new(CONFIG))?; - let status = preflight( - config.enabled, - &ids, - &providers(Path::new(MODULES), Path::new(UPDATES))?, - ); - ensure!( - status.state_token == expected_state, - "FOLKZYGISK_STATE_CHANGED: preflight again" - ); - ensure!( - !status.blocked, - "FOLKZYGISK_EXTERNAL_CONFLICT: multiple external providers" - ); - ensure!( - ids.iter().filter(|id| is_provider(id)).count() <= 1, - "FOLKZYGISK_EXTERNAL_CONFLICT: choose only one provider archive per batch" - ); - ensure!( - !status.conflict || confirmed, - "FOLKZYGISK_CONFLICT: confirmation required" - ); - #[cfg(target_os = "android")] - { - // Ordinary modules first: a later unrelated failure cannot leave a - // successfully selected provider with restored built-in configuration. - let mut batch: Vec<_> = archives.iter().zip(&ids).collect(); - batch.sort_by_key(|(_, id)| is_provider(id)); - for (archive, id) in batch { - let install = || crate::module::install_module_locked(archive); - if is_provider(id) && config.enabled { - install_external( - Path::new(CONFIG), - Path::new(MODULES), - Path::new(UPDATES), - id, - install, - crate::module::regenerate_preinit_rc, - )?; - } else { - install()?; - } - println!("FOLKZYGISK_BATCH_CHANGED"); - } - } - #[cfg(not(target_os = "android"))] - bail!("Batch installation requires Android"); - } Command::Status { json } => { let runtime = read_runtime(Path::new(RUNTIME), Path::new("/proc"))?; let guard = load_guard(Path::new(BOOT_GUARD))?; let engine = engine_available(Path::new(ENGINE)); let external = providers(Path::new(MODULES), Path::new(UPDATES))?; let blocked = guard.latched || guard.failures >= FAILURE_LIMIT; - let blockers = enable_blockers(engine, !external.is_empty(), blocked); + let blockers = enable_blockers(engine, blocked); let status = Status { schema_version: 1, enabled: read_config(Path::new(CONFIG))?.enabled, @@ -925,46 +590,36 @@ pub fn run(command: Command) -> Result<()> { startup_failures: guard.failures, startup_blocked: blocked, }; - if json { + if *json { println!("{}", serde_json::to_string(&status)?); } else { println!("{}", serde_json::to_string_pretty(&status)?); } } - Command::Enable { - disable_external, - expected_external, - } => { - let external = providers(Path::new(MODULES), Path::new(UPDATES))?; - if disable_external { - check_confirmed_providers(&expected_external, &external)?; - } - ensure!( - disable_external || external.is_empty(), - "FOLKZYGISK_CONFLICT: {}", - external.join(", ") - ); + Command::Enable => { let guard = load_guard(Path::new(BOOT_GUARD))?; let engine = engine_available(Path::new(ENGINE)); let blocked = guard.latched || guard.failures >= FAILURE_LIMIT; - let blockers = enable_blockers(engine, false, blocked); + let blockers = enable_blockers(engine, blocked); ensure!( blockers.is_empty(), "FOLKZYGISK_UNAVAILABLE: {}", blockers.join(", ") ); - select_builtin( - Path::new(CONFIG), - Path::new(MODULES), - Path::new(UPDATES), - &external, - || { - #[cfg(target_os = "android")] - crate::module::regenerate_preinit_rc()?; - Ok(()) - }, - )?; - println!("FolkZygisk enabled for the next boot; reboot required"); + write_config(Path::new(CONFIG), &Config { enabled: true })?; + // The configuration is already persisted; the provider scan only picks the hint text, + // so a scan failure must not turn a successful enable into a CLI error. + match providers(Path::new(MODULES), Path::new(UPDATES)) { + Ok(external) if !external.is_empty() => println!( + "FolkZygisk enabled, but external provider(s) {} take precedence; the built-in engine will step aside", + external.join(", ") + ), + Ok(_) => println!("FolkZygisk enabled for the next boot; reboot required"), + Err(error) => { + log::warn!("Could not scan Zygisk providers: {error:#}"); + println!("FolkZygisk enabled for the next boot; reboot required"); + } + } } Command::Disable => { write_config(Path::new(CONFIG), &Config { enabled: false })?; @@ -1018,154 +673,6 @@ pub fn run(command: Command) -> Result<()> { #[cfg(test)] mod tests { - #[test] - fn confirmation_cannot_disable_an_unseen_provider() -> anyhow::Result<()> { - super::check_confirmed_providers(&["rezygisk".into()], &["rezygisk".into()])?; - assert!( - super::check_confirmed_providers( - &["rezygisk".into()], - &["onyxzygisk".into(), "rezygisk".into()] - ) - .is_err() - ); - assert!(super::check_confirmed_providers(&["rezygisk".into()], &[]).is_err()); - assert!(super::check_confirmed_providers(&[], &["rezygisk".into()]).is_err()); - Ok(()) - } - #[test] - fn failed_provider_install_is_quarantined_before_selection_restore() -> anyhow::Result<()> { - let dir = tempfile::tempdir()?; - let config = dir.path().join("config.json"); - let modules = dir.path().join("modules"); - let updates = dir.path().join("updates"); - super::write_config(&config, &super::Config { enabled: true })?; - let result = super::install_external( - &config, - &modules, - &updates, - "rezygisk", - || { - std::fs::create_dir_all(updates.join("rezygisk"))?; - anyhow::bail!("installer failed after extraction") - }, - || Ok(()), - ); - assert!(result.is_err()); - assert!(super::read_config(&config)?.enabled); - assert!(updates.join("rezygisk/disable").exists()); - assert!(super::providers(&modules, &updates)?.is_empty()); - super::install_external( - &config, - &modules, - &updates, - "rezygisk", - || { - std::fs::create_dir_all(modules.join("rezygisk"))?; - Ok(()) - }, - || Ok(()), - )?; - assert!(!super::read_config(&config)?.enabled); - assert!(!updates.join("rezygisk/disable").exists()); - assert_eq!(super::providers(&modules, &updates)?, vec!["rezygisk"]); - // Fail closed: never restore the built-in if quarantine/rc regeneration fails. - super::write_config(&config, &super::Config { enabled: true })?; - assert!( - super::install_external( - &config, - &modules, - &updates, - "rezygisk", - || anyhow::bail!("installer failed"), - || anyhow::bail!("rc failed") - ) - .is_err() - ); - assert!(!super::read_config(&config)?.enabled); - Ok(()) - } - #[test] - fn preflight_blocks_multiple_providers_and_tracks_selection() { - let ids = vec!["rezygisk".to_owned()]; - let one = super::preflight(true, &ids, &[]); - assert!(!one.blocked); - assert!(one.conflict); - assert!(super::preflight(false, &ids, &["onyxzygisk".to_owned()]).blocked); - assert!(super::preflight(false, &["rezygisk".into(), "rezygisk".into()], &[]).blocked); - assert!(!super::preflight(false, &ids, &ids).blocked); - assert_ne!( - one.state_token, - super::preflight(false, &ids, &[]).state_token - ); - assert_ne!( - one.state_token, - super::preflight(true, &ids, &ids).state_token - ); - assert!(super::ensure_single_external("rezygisk", &["onyxzygisk".into()]).is_err()); - assert!(super::ensure_single_external("rezygisk", &ids).is_ok()); - } - - #[test] - fn pending_activation_markers_clear_together_and_restore_on_failure() -> anyhow::Result<()> { - let dir = tempfile::tempdir()?; - let installed = dir.path().join("installed"); - let pending = dir.path().join("pending"); - std::fs::create_dir_all(&installed)?; - std::fs::create_dir_all(&pending)?; - for name in ["disable", "remove"] { - for path in [&installed, &pending] { - std::fs::write(path.join(name), b"original")?; - } - let mut calls = 0; - assert!( - super::clear_activation_markers(&installed, &pending, name == "remove", || { - calls += 1; - if calls == 1 { - anyhow::bail!("rc failure"); - } - Ok(()) - }) - .is_err() - ); - for path in [&installed, &pending] { - assert_eq!(std::fs::read(path.join(name))?, b"original"); - } - super::clear_activation_markers(&installed, &pending, name == "remove", || Ok(()))?; - for path in [&installed, &pending] { - assert!(!path.join(name).exists()); - } - } - Ok(()) - } - #[test] - fn builtin_selection_disables_pending_providers_and_rolls_back() -> Result<()> { - let dir = tempfile::tempdir()?; - let modules = dir.path().join("modules"); - let updates = dir.path().join("updates"); - for root in [&modules, &updates] { - fs::create_dir_all(root.join("rezygisk"))?; - } - let config = dir.path().join("config.json"); - let external = vec!["rezygisk".to_owned()]; - let mut calls = 0; - assert!( - select_builtin(&config, &modules, &updates, &external, || { - calls += 1; - if calls == 1 { - bail!("rc failed") - } - Ok(()) - }) - .is_err() - ); - assert!(!modules.join("rezygisk/disable").exists()); - assert!(!read_config(&config)?.enabled); - select_builtin(&config, &modules, &updates, &external, || Ok(()))?; - assert!(read_config(&config)?.enabled); - assert!(providers(&modules, &updates)?.is_empty()); - Ok(()) - } - #[test] fn runtime_cleanup_refuses_live_monitor_lock() -> Result<()> { let dir = tempfile::tempdir()?; @@ -1179,68 +686,14 @@ mod tests { assert!(dir.path().join("state.json").exists()); Ok(()) } - #[test] - fn external_switch_restores_selection_on_failure() { - let dir = tempfile::tempdir().unwrap(); - let config = dir.path().join("config.json"); - super::write_config(&config, &super::Config { enabled: true }).unwrap(); - assert!( - super::switch_external(&config, "rezygisk", || anyhow::bail!("activation failed")) - .is_err() - ); - assert!(super::read_config(&config).unwrap().enabled); - assert!(super::switch_external(&config, "plugin", || Ok(())).is_err()); - assert!(super::read_config(&config).unwrap().enabled); - super::switch_external(&config, "rezygisk", || { - assert!(!super::read_config(&config)?.enabled); - Ok(()) - }) - .unwrap(); - assert!(!super::read_config(&config).unwrap().enabled); - } - #[test] - fn archive_preflight_reads_root_properties_and_rejects_missing_id() { - use std::io::Write; - let file = tempfile::NamedTempFile::new().unwrap(); - let mut zip = zip::ZipWriter::new(std::fs::File::create(file.path()).unwrap()); - zip.start_file("module.prop", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip.write_all(b"id = rezygisk\nname=ReZygisk\n").unwrap(); - zip.finish().unwrap(); - assert_eq!(super::archive_id(file.path()).unwrap(), "rezygisk"); - let mut zip = zip::ZipWriter::new(std::fs::File::create(file.path()).unwrap()); - zip.start_file("module.prop", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip.write_all(b"name=No ID\n").unwrap(); - zip.finish().unwrap(); - assert!(super::archive_id(file.path()).is_err()); - } - #[test] - fn batch_preflight_recognizes_only_providers_without_mutating_selection() { - let ids = vec![ - "lsposed".to_owned(), - "rezygisk".to_owned(), - "onyxzygisk".to_owned(), - "rezygisk".to_owned(), - ]; - let status = super::preflight(true, &ids, &[]); - assert!(status.conflict); - assert_eq!(status.provider_ids, vec!["onyxzygisk", "rezygisk"]); - assert!(!super::preflight(false, &ids, &[]).conflict); - assert!(!super::preflight(true, &["lsposed".to_owned()], &[]).conflict); - } use super::*; #[test] fn activation_keeps_operational_protection() { - assert!(enable_blockers(true, false, false).is_empty()); + assert!(enable_blockers(true, false).is_empty()); assert_eq!( - enable_blockers(false, true, true), - vec![ - "engine_missing", - "external_provider_conflict", - "startup_protection" - ] + enable_blockers(false, true), + vec!["engine_missing", "startup_protection"] ); } @@ -1346,17 +799,18 @@ mod tests { } #[test] - fn recognizes_only_verified_providers() { - for id in ["zygisksu", "rezygisk", "onyxzygisk"] { - assert!(is_provider(id)); - } + fn recognizes_known_external_providers() { for id in [ - "lsposed", + "zygisksu", + "zygisknext", + "rezygisk", "neozygisk", - "nyazygisk", - "nyxzygisk", - "zygisk_plugin", + "shirokozygisk", + "onyxzygisk", ] { + assert!(is_provider(id)); + } + for id in ["lsposed", "nyazygisk", "nyxzygisk", "zygisk_plugin"] { assert!(!is_provider(id)); } } From 1cd0a2f73392a87afaf1df0097ad230065b55849 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 00:28:07 +0800 Subject: [PATCH 02/24] manager: Derive supporting text colour from the card content --- .../component/material/FolkWallpaperSurface.kt | 17 ++++++++++++++++- .../ui/component/material/SegmentedList.kt | 1 + .../kernelsu/ui/screen/home/HomeCountCards.kt | 10 +++++++--- .../ui/screen/home/HomeDashboardLayout.kt | 6 +++++- .../kernelsu/ui/screen/home/HomeFactsCards.kt | 16 ++++++++++++---- .../ui/screen/home/HomeMetricComponents.kt | 3 +++ .../kernelsu/ui/screen/home/HomeStatsLayout.kt | 7 +++++-- .../kernelsu/ui/screen/home/HomeStatusCard.kt | 4 +++- .../kernelsu/ui/screen/home/HomeSupportLinks.kt | 10 +++++++--- .../kernelsu/wallpaper/FolkWallpaperTokens.kt | 14 +++++++++++++- 10 files changed, 72 insertions(+), 16 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/FolkWallpaperSurface.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/FolkWallpaperSurface.kt index 0ed22ed0d..a174fd002 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/FolkWallpaperSurface.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/FolkWallpaperSurface.kt @@ -18,6 +18,18 @@ import me.weishu.kernelsu.wallpaper.WallpaperSurfaceRole */ val LocalWallpaperMaterialRole = staticCompositionLocalOf { null } +/** + * The supporting content colour for a secondary line inside a wallpaper material. + * + * It is the material's own content colour at a reduced alpha (see [WallpaperMaterial.supportingColor]), + * so the pair keeps its contrast over the translucent fill instead of falling back to the semantic + * greys that were tuned against an opaque panel. + * + * Null outside wallpaper mode, and outside any wallpaper surface while it is on, so callers keep + * their `onSurfaceVariant`/`outline` fallback and non-wallpaper rendering stays byte-identical. + */ +val LocalWallpaperSupportingColor = staticCompositionLocalOf { null } + /** * The unified transparency layering controller. * @@ -79,7 +91,10 @@ fun FolkWallpaperSurface( tonalElevation = 0.dp, shadowElevation = 0.dp, ) { - CompositionLocalProvider(LocalWallpaperMaterialRole provides role) { + CompositionLocalProvider( + LocalWallpaperMaterialRole provides role, + LocalWallpaperSupportingColor provides material.supportingColor, + ) { content() } } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/SegmentedList.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/SegmentedList.kt index 5fcb53948..d34b6499e 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/SegmentedList.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/material/SegmentedList.kt @@ -126,6 +126,7 @@ private fun WallpaperGroupBackground( CompositionLocalProvider( LocalWallpaperGroupActive provides true, LocalWallpaperMaterialRole provides WallpaperSurfaceRole.Group, + LocalWallpaperSupportingColor provides tokens.group.supportingColor, ) { content() } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeCountCards.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeCountCards.kt index 8a7592542..7d94669d7 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeCountCards.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeCountCards.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.FolkMetricValue +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.theme.FolkType /** The axis a host lays the two count cards along. */ @@ -123,20 +124,23 @@ internal fun CountCard( emphasis: CountCardEmphasis = CountCardEmphasis.Label, available: Boolean = true, ) { + // On wallpaper the card material carries its own supporting colour, tuned against the + // translucent fill; off it we keep the semantic greys. + val supportingColor = LocalWallpaperSupportingColor.current val labelStyle = when (emphasis) { CountCardEmphasis.Label -> FolkType.Summary CountCardEmphasis.Value -> FolkType.Caption } val labelColor = when (emphasis) { CountCardEmphasis.Label -> MaterialTheme.colorScheme.onSurface - CountCardEmphasis.Value -> MaterialTheme.colorScheme.onSurfaceVariant + CountCardEmphasis.Value -> supportingColor ?: MaterialTheme.colorScheme.onSurfaceVariant } val valueStyle = when (emphasis) { CountCardEmphasis.Label -> FolkType.Numeral CountCardEmphasis.Value -> FolkType.Title } val valueColor = when (emphasis) { - CountCardEmphasis.Label -> MaterialTheme.colorScheme.outline + CountCardEmphasis.Label -> supportingColor ?: MaterialTheme.colorScheme.outline CountCardEmphasis.Value -> MaterialTheme.colorScheme.onSurface } @@ -152,7 +156,7 @@ internal fun CountCard( imageVector = icon, contentDescription = null, modifier = Modifier.size(CountCardDefaults.IconSize), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = supportingColor ?: MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(CountCardDefaults.IconTextSpacing)) Column { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeDashboardLayout.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeDashboardLayout.kt index f5b1fcdd6..80ecf23c3 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeDashboardLayout.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeDashboardLayout.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import me.weishu.kernelsu.ui.util.useFullFeaturedLayout import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.SurfaceOptionsDialog +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.theme.FolkType import me.weishu.kernelsu.ui.theme.isInDarkTheme import me.weishu.kernelsu.wallpaper.WallpaperSurfaceRole @@ -114,7 +115,9 @@ private fun DashboardHeroCard( // Once the bitmap is decoded, drop to the photo palette; before that keep the opaque card. val overImage = backgroundUri != null && photo.ready val photoContentColor = if (overImage) photo.contentColor(surface, isInDarkTheme()) else null - val subColor = photoContentColor?.copy(alpha = 0.8f) ?: MaterialTheme.colorScheme.onSurfaceVariant + val subColor = photoContentColor?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant var showOptions by remember { mutableStateOf(false) } HomeCard( @@ -225,6 +228,7 @@ private fun HeroFact( text = label, style = FolkType.Caption, color = LocalHomeTileCardContentColor.current?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeFactsCards.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeFactsCards.kt index 79f36ef87..40cd39662 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeFactsCards.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeFactsCards.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.component.material.folkPressScale import me.weishu.kernelsu.ui.theme.FolkType import me.weishu.kernelsu.wallpaper.surface.SurfaceConfig @@ -105,7 +106,9 @@ internal fun HomeFactRow( Text( text = "$label: ", style = FolkType.Summary, - color = overColor?.copy(alpha = 0.8f) ?: MaterialTheme.colorScheme.onSurfaceVariant, + color = overColor?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = value, @@ -211,7 +214,9 @@ private fun SusfsHomeRow(status: me.weishu.kernelsu.data.susfs.SusfsStatus, onCl Text("SUSFS · ${status.version} · ${status.implementation}", style = FolkType.Summary, color = overColor ?: MaterialTheme.colorScheme.onSurface) Text(me.weishu.kernelsu.ui.screen.susfsManagementLabel(status.management), style = FolkType.Machine, - color = overColor?.copy(alpha = 0.8f) ?: MaterialTheme.colorScheme.onSurfaceVariant) + color = overColor?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant) } Text(stringResource(R.string.susfs_manage), style = FolkType.Summary, color = overColor ?: MaterialTheme.colorScheme.primary) @@ -230,11 +235,14 @@ internal fun InfoRow( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { + // On wallpaper the material carries its own supporting colour, tuned against the translucent + // fill; off it we keep the semantic greys. + val supportingColor = LocalWallpaperSupportingColor.current Icon( imageVector = icon, contentDescription = label, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = supportingColor ?: MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -242,7 +250,7 @@ internal fun InfoRow( Text( text = value, style = FolkType.Machine, - color = MaterialTheme.colorScheme.outline + color = supportingColor ?: MaterialTheme.colorScheme.outline ) } if (trailingContent != null) { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeMetricComponents.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeMetricComponents.kt index 3f70e935b..9cdce244f 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeMetricComponents.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeMetricComponents.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.theme.FolkType /** A single live metric drawn as a wavy ring with its value in the middle. */ @@ -68,6 +69,7 @@ internal fun StatusCircle( text = label, style = MaterialTheme.typography.bodyMediumEmphasized, color = LocalHomeTileCardContentColor.current?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -122,6 +124,7 @@ internal fun MetricBar( text = value, style = FolkType.Summary, color = LocalHomeTileCardContentColor.current?.copy(alpha = 0.8f) + ?: LocalWallpaperSupportingColor.current ?: MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatsLayout.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatsLayout.kt index 1e78e900a..bfdfa4c0a 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatsLayout.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatsLayout.kt @@ -35,6 +35,7 @@ import me.weishu.kernelsu.data.HomeMetrics import me.weishu.kernelsu.ui.component.chart.ModulePieChart import me.weishu.kernelsu.ui.component.chart.PieSlice import me.weishu.kernelsu.ui.component.chart.WaveChart +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.theme.FolkType /** @@ -142,7 +143,8 @@ private fun StatsMonitorTile( Text( text = stringResource(R.string.home_metric_cpu_frequency), style = FolkType.Caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant, ) clusters.forEach { cluster -> MetricBar( @@ -246,7 +248,8 @@ private fun ChartLegend( Text( text = label, style = FolkType.Summary, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatusCard.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatusCard.kt index 2211b3fb3..9b72b2678 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatusCard.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeStatusCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.material.FolkButton +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.component.statustag.StatusTag import me.weishu.kernelsu.ui.theme.FolkType @@ -189,7 +190,8 @@ internal fun StatusCard( text = statusSummary, modifier = Modifier.weight(1f, fill = false), style = FolkType.Summary, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = LocalWallpaperSupportingColor.current + ?: MaterialTheme.colorScheme.onSurfaceVariant ) if (state.showCustomLkmBadge) { Spacer(Modifier.width(8.dp)) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeSupportLinks.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeSupportLinks.kt index 420b7dddf..00c3d98f9 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeSupportLinks.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/home/HomeSupportLinks.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.weishu.kernelsu.R import me.weishu.kernelsu.data.model.SupportLinksStyle +import me.weishu.kernelsu.ui.component.material.LocalWallpaperSupportingColor import me.weishu.kernelsu.ui.component.material.folkPressScale import me.weishu.kernelsu.ui.theme.FolkType @@ -104,6 +105,9 @@ private fun SupportLinkRow( ) { val interactionSource = remember { MutableInteractionSource() } val haptic = LocalHapticFeedback.current + // On wallpaper the card material carries its own supporting colour, tuned against the + // translucent fill; off it we keep the semantic greys. + val supportingColor = LocalWallpaperSupportingColor.current Row( modifier = Modifier .fillMaxWidth() @@ -123,7 +127,7 @@ private fun SupportLinkRow( imageVector = icon, contentDescription = title, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = supportingColor ?: MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -132,7 +136,7 @@ private fun SupportLinkRow( Text( text = subtitle, style = FolkType.Caption, - color = MaterialTheme.colorScheme.outline + color = supportingColor ?: MaterialTheme.colorScheme.outline ) } Spacer(Modifier.width(12.dp)) @@ -140,7 +144,7 @@ private fun SupportLinkRow( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = supportingColor ?: MaterialTheme.colorScheme.onSurfaceVariant ) } } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/wallpaper/FolkWallpaperTokens.kt b/manager/app/src/main/java/me/weishu/kernelsu/wallpaper/FolkWallpaperTokens.kt index 971447227..29e633f86 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/wallpaper/FolkWallpaperTokens.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/wallpaper/FolkWallpaperTokens.kt @@ -61,6 +61,16 @@ val LocalFolkWallpaperTokens = compositionLocalOf { null } */ private const val CHROME_ALPHA_FLOOR = 0.10f +/** + * How much of the pair's content colour a supporting line keeps. + * + * The semantic `onSurfaceVariant`/`outline` greys are tuned against an opaque panel. Once the fill + * is translucent the wallpaper shows through, and those greys lose the contrast they were chosen + * for. Deriving the supporting colour from the content colour keeps the pair's own contrast, and the + * small alpha drop preserves the hierarchy between a card's main line and its supporting line. + */ +private const val SUPPORTING_CONTENT_ALPHA = 0.8f + /** * Maps the user-facing opacity preference (0..1) onto the role alphas. * @@ -76,11 +86,13 @@ fun resolveFolkWallpaperTokens(colorScheme: ColorScheme, opacity: Float): FolkWa tint = colorScheme.surface, alpha = alpha, contentColor = colorScheme.onSurface, - supportingColor = colorScheme.onSurfaceVariant, + supportingColor = colorScheme.onSurface.copy(alpha = SUPPORTING_CONTENT_ALPHA), ) return FolkWallpaperTokens( group = panel, raised = panel.copy(alpha = (alpha + 0.12f).coerceAtMost(1f)), + // The overlay stays near-opaque, so its supporting grey still sits on a panel of its own and + // keeps the semantic variant colour. overlay = WallpaperMaterial( tint = colorScheme.surfaceContainerHigh, alpha = 0.96f, From 6ffc17ee5dec974a0f73b319074f32e5757e77f3 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:00:24 +0800 Subject: [PATCH 03/24] nomount: Vendor the kernel module and build it with the DDK matrix NoMount is a VFS path redirection module; FolkSU intends to use it as a built-in module injection backend, so its kernel sources are vendored here instead of being downloaded prebuilt from upstream releases. - Vendor kernel/src (GPL-2.0-only) at 7046c7a (v2.1.1) plus UPSTREAM.md, NOTICE and both upstream license texts; imported files stay byte-identical. - build.sh nomount builds it out-of-tree with the same DDK image matrix as kernelsu.ko and stages _nomount.ko into ksud's embedded assets. The build runs from a temporary copy so the vendored sources are never written to. - ddk-lkm.yml builds and uploads the module with the kernelsu.ko artifact; ksud.yml/ksud-extra.yml stage it before building ksud. - assets.rs exposes the KMI set that ships a NoMount module. Verified locally against DDK 20260828: android12-5.10 (70000 B), android15-6.6 (59168 B) and android17-6.18 (70584 B) all build and report version=21, license=GPL. --- .github/workflows/ddk-lkm.yml | 11 +- .github/workflows/ksud-extra.yml | 1 + .github/workflows/ksud.yml | 1 + build.sh | 43 +- userspace/ksud/src/assets.rs | 16 + userspace/nomount/.gitignore | 12 + userspace/nomount/LICENSE | 674 +++++++++ userspace/nomount/NOTICE | 11 + userspace/nomount/UPSTREAM.md | 105 ++ userspace/nomount/build-lkm.sh | 66 + userspace/nomount/kernel/LICENSE | 339 +++++ userspace/nomount/kernel/src/Kconfig | 8 + userspace/nomount/kernel/src/Makefile | 21 + userspace/nomount/kernel/src/nomount.c | 1802 ++++++++++++++++++++++++ userspace/nomount/kernel/src/nomount.h | 435 ++++++ 15 files changed, 3542 insertions(+), 3 deletions(-) create mode 100644 userspace/nomount/.gitignore create mode 100644 userspace/nomount/LICENSE create mode 100644 userspace/nomount/NOTICE create mode 100644 userspace/nomount/UPSTREAM.md create mode 100644 userspace/nomount/build-lkm.sh create mode 100644 userspace/nomount/kernel/LICENSE create mode 100644 userspace/nomount/kernel/src/Kconfig create mode 100644 userspace/nomount/kernel/src/Makefile create mode 100644 userspace/nomount/kernel/src/nomount.c create mode 100644 userspace/nomount/kernel/src/nomount.h diff --git a/.github/workflows/ddk-lkm.yml b/.github/workflows/ddk-lkm.yml index ee3bb7875..c86bb7536 100644 --- a/.github/workflows/ddk-lkm.yml +++ b/.github/workflows/ddk-lkm.yml @@ -92,11 +92,20 @@ jobs: llvm-strip -d "$OUT_DIR/$OUTPUT_NAME" echo "Size after stripping: $(du -h "$OUT_DIR/$OUTPUT_NAME" | cut -f1)" + - name: Build nomount.ko + run: | + echo "=== Building NoMount for KMI: ${{ inputs.kmi }} ===" + NOMOUNT_OUT="/github/workspace/out/aarch64/${{ inputs.kmi }}_nomount.ko" \ + sh /github/workspace/userspace/nomount/build-lkm.sh + echo "Size: $(du -h "/github/workspace/out/aarch64/${{ inputs.kmi }}_nomount.ko" | cut -f1)" + - name: Upload kernelsu.ko artifact uses: actions/upload-artifact@v7 with: name: aarch64-${{ inputs.kmi }}-lkm - path: /github/workspace/out/aarch64/${{ inputs.kmi }}_kernelsu.ko + path: | + /github/workspace/out/aarch64/${{ inputs.kmi }}_kernelsu.ko + /github/workspace/out/aarch64/${{ inputs.kmi }}_nomount.ko - name: Build kernelsu.ko for x64 env: diff --git a/.github/workflows/ksud-extra.yml b/.github/workflows/ksud-extra.yml index 83bfcd133..46df2ab13 100644 --- a/.github/workflows/ksud-extra.yml +++ b/.github/workflows/ksud-extra.yml @@ -32,6 +32,7 @@ jobs: if: ${{ inputs.pack_lkm }} run: | cp aarch64-android*-lkm/*_kernelsu.ko ./userspace/ksud/bin/aarch64/ + cp aarch64-android*-lkm/*_nomount.ko ./userspace/ksud/bin/aarch64/ cp x86_64-android*-lkm/*_kernelsu.ko ./userspace/ksud/bin/x86_64/ - name: Prepare ksuinit diff --git a/.github/workflows/ksud.yml b/.github/workflows/ksud.yml index a6fe2a263..232d28dec 100644 --- a/.github/workflows/ksud.yml +++ b/.github/workflows/ksud.yml @@ -28,6 +28,7 @@ jobs: if: ${{ inputs.pack_lkm }} run: | cp aarch64-android*-lkm/*_kernelsu.ko ./userspace/ksud/bin/aarch64/ + cp aarch64-android*-lkm/*_nomount.ko ./userspace/ksud/bin/aarch64/ cp x86_64-android*-lkm/*_kernelsu.ko ./userspace/ksud/bin/x86_64/ - name: Prepare ksuinit diff --git a/build.sh b/build.sh index c0100646d..7a33b8a4d 100755 --- a/build.sh +++ b/build.sh @@ -139,6 +139,42 @@ build_lkm() { done } +# NoMount's kernel module is vendored at userspace/nomount (GPL-2.0-only, see +# userspace/nomount/UPSTREAM.md) and built out-of-tree with the same DDK matrix +# as kernelsu.ko. The stripped module is staged into ksud's embedded assets as +# _nomount.ko; it is never flashed and has no standalone module zip. +build_nomount() { + have docker || die "docker is required to build the NoMount LKM" + mkdir -p userspace/ksud/bin/aarch64 userspace/nomount/build + + local kmi image rel dest + for kmi in $KMIS; do + image="ghcr.io/ylarod/ddk-min:${kmi}-${DDK_RELEASE}" + log "building NoMount LKM for $kmi ($image)" + docker image inspect "$image" >/dev/null 2>&1 || docker pull "$image" + + # The container sees the repository mounted at /build, so the output + # path has to be given in container coordinates. + rel="userspace/nomount/build/${kmi}_nomount.ko" + dest="$REPO_ROOT/$rel" + rm -f "$dest" + docker run --rm --privileged \ + -v "$REPO_ROOT":/build \ + -e CONFIG_NOMOUNT=m -e CC=clang -e NOMOUNT_OUT="/build/$rel" \ + "$image" sh /build/userspace/nomount/build-lkm.sh + + [[ -f "$dest" ]] || die "NoMount LKM for $kmi was not produced" + cp "$dest" userspace/ksud/bin/aarch64/ + log "staged userspace/ksud/bin/aarch64/${kmi}_nomount.ko" + + # The container runs as root, so hand the artifacts back to us. + docker run --rm -v "$REPO_ROOT/userspace/nomount":/n \ + --entrypoint /bin/sh "$image" \ + -c "chown -R $(id -u):$(id -g) /n" >/dev/null 2>&1 || \ + warn "could not restore ownership of userspace/nomount/ (artifacts may be root-owned)" + done +} + abi_to_triple() { case "$1" in arm64-v8a) echo aarch64-linux-android ;; @@ -254,11 +290,13 @@ usage: ./build.sh [step...] steps: lkm build the kernel module(s) via the DDK docker image, stage them into userspace/ksud/bin/aarch64/ so ksud embeds them + nomount build the vendored NoMount module(s) the same way, stage them as + userspace/ksud/bin/aarch64/_nomount.ko for ksud to embed ksuinit cross-compile the ksuinit helper ksud embeds (needed by boot-patch) ksud cross-compile the ksud daemon with cargo-ndk apk gradle assembleRelease (signed with manager/keystore.properties) repack inject the freshly built ksud into the APK and re-sign it - all lkm -> ksuinit -> ksud -> apk -> repack + all lkm -> nomount -> ksuinit -> ksud -> apk -> repack environment overrides: KMIS=android15-6.6 kernel modules to build @@ -278,11 +316,12 @@ main() { for step in "$@"; do case "$step" in lkm) build_lkm ;; + nomount) build_nomount ;; ksuinit) build_ksuinit ;; ksud) build_ksud ;; apk) build_apk ;; repack) build_repack ;; - all) build_lkm; build_ksuinit; build_ksud; build_apk; build_repack ;; + all) build_lkm; build_nomount; build_ksuinit; build_ksud; build_apk; build_repack ;; -h|--help|help) usage ;; *) die "unknown step: $step (try --help)" ;; esac diff --git a/userspace/ksud/src/assets.rs b/userspace/ksud/src/assets.rs index 4632456e2..aa8c99173 100644 --- a/userspace/ksud/src/assets.rs +++ b/userspace/ksud/src/assets.rs @@ -81,3 +81,19 @@ pub fn list_supported_kmi() -> std::vec::Vec { } list } + +/// KMI names that carry a vendored NoMount module (`_nomount.ko`). +/// +/// This is a subset of [`list_supported_kmi`]: a KMI can ship `kernelsu.ko` +/// without a matching NoMount module, in which case the built-in NoMount +/// backend must report the driver as unavailable instead of guessing. +// Kept alongside the KMI asset discovery so the built-in NoMount backend lands +// on one source of truth; wired up by the `ksud nomount` commands. +#[allow(dead_code)] +pub fn list_supported_nomount_kmi() -> std::vec::Vec { + let mut list: Vec = Asset::iter() + .filter_map(|file| file.strip_suffix("_nomount.ko").map(ToString::to_string)) + .collect(); + list.sort(); + list +} diff --git a/userspace/nomount/.gitignore b/userspace/nomount/.gitignore new file mode 100644 index 000000000..06a2d5ac2 --- /dev/null +++ b/userspace/nomount/.gitignore @@ -0,0 +1,12 @@ +/build/ +*.ko +*.o +*.lds +*.mod +*.mod.c +*.cmd +*.symvers +*.order +Module.symvers +modules.order +.tmp_versions/ diff --git a/userspace/nomount/LICENSE b/userspace/nomount/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/userspace/nomount/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/userspace/nomount/NOTICE b/userspace/nomount/NOTICE new file mode 100644 index 000000000..5560ea701 --- /dev/null +++ b/userspace/nomount/NOTICE @@ -0,0 +1,11 @@ +NoMount +https://github.com/maxsteeel/nomount +Copyright (C) maxsteeel and NoMount contributors + +The vendored kernel module sources in kernel/ are licensed under the GNU +General Public License version 2.0. See kernel/LICENSE for the full text. + +The upstream project licenses everything outside its kernel/ directory under +the GNU General Public License version 3.0. That text is retained verbatim as +LICENSE for provenance. See UPSTREAM.md for the pinned revision, the imported +file list and the license mapping. diff --git a/userspace/nomount/UPSTREAM.md b/userspace/nomount/UPSTREAM.md new file mode 100644 index 000000000..bf79431ee --- /dev/null +++ b/userspace/nomount/UPSTREAM.md @@ -0,0 +1,105 @@ +# NoMount upstream provenance + +NoMount is vendored into FolkSU so that the kernel module is built by this +repository's own pipeline instead of being downloaded from upstream releases. +This mirrors how FolkZygisk vendors ReZygisk under `userspace/folkzygisk`. + +Only the kernel-side sources are imported here. FolkSU reimplements the +userspace control path (the `nm` CLI's keyring payload protocol) in Rust inside +`userspace/ksud`, and generates injection rules from the module directory in +Rust, so no upstream shell scripts or WebUI are imported. + +## Pinned sources + +- NoMount: https://github.com/maxsteeel/nomount + commit `7046c7ad3ee0bf4ae66e80f6b93b1e30db0cb4e3` (tag `v2.1.1`). + Imported from `kernel/src/` only: + + | upstream path | vendored path | sha256 | + | --- | --- | --- | + | `kernel/src/nomount.c` | `kernel/src/nomount.c` | `b082476d72ee755e3549b850fd3cf1e233234b6440f3d844b5ea190e392e0c69` | + | `kernel/src/nomount.h` | `kernel/src/nomount.h` | `8a805e7fe3bbc6bf8daf2f4fd26c1f00d880a5480ca66dd013485509028146ed` | + | `kernel/src/Kconfig` | `kernel/src/Kconfig` | `1df7ad3589a282128f797f7a46b28aee315d080550aae26b681a898f11c7abc7` | + | `kernel/src/Makefile` | `kernel/src/Makefile` | `ce9ca3ca442e6e30e3091168dbc1091a93d65dfbe0e93caf92d3a21be148779f` | + | `kernel/LICENSE` | `kernel/LICENSE` | `8177f97513213526df2cf6184d8ff986c675afb514d4e68a404010521b880643` | + | `LICENSE` | `LICENSE` | `3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986` | + +All imported files are byte-identical to upstream. No copyright notice has been +replaced or added inside them; FolkSU attribution is not applied to upstream +files. + +## Deliberately not imported + +- `module/*.sh` (`customize.sh`, `metainstall.sh`, `metamount.sh`, + `post-fs-data.sh`, `rule-paths.sh`, `service.sh`, `boot-completed.sh`): + FolkSU's built-in backend performs driver loading, rule generation and UID + filtering itself. +- `module/webroot/**`: the built-in backend ships its own manager UI instead of + a module WebUI. +- `userspace/src/nm.c`, `userspace/src/nm.h`: the keyring payload protocol is + reimplemented in Rust. This also avoids shipping a GPL-3 binary and the + `/data/adb/ksu/bin/nm` symlink the upstream installer relies on. +- `kernel/setup.sh`: it patches a full GKI source tree (`fs/Kconfig`, + `fs/Makefile`) for in-tree integration. FolkSU builds NoMount as an + out-of-tree module, so this script is not vendored. + +## License mapping + +Upstream splits its licenses by directory and carries no SPDX identifiers: + +- `kernel/**` (everything vendored here): GNU General Public License v2.0, + full text in `kernel/LICENSE`. The module itself declares + `MODULE_LICENSE("GPL")` and `MODULE_AUTHOR("maxsteeel")`. +- Everything outside `kernel/**` upstream: GNU General Public License v3.0. + That text is retained verbatim as `LICENSE` for provenance even though no + non-kernel upstream file is vendored. + +Upstream source files contain no copyright headers, so provenance rests on the +repository notice and the pinned commit above. Do not advertise the vendored +part as GPL-3-only. FolkSU releases must keep both license texts and provide +matching source for the exact revision they build. + +Files authored by FolkSU in this directory (`UPSTREAM.md`, `NOTICE`, +`.gitignore`, `build-lkm.sh`) are not upstream files. `build-lkm.sh` carries +`SPDX-License-Identifier: GPL-2.0-only` because it mirrors upstream's LKM build +steps and builds GPL-2.0-only code. + +Release source obligations are already met by the existing per-target source +tarball (`.github/workflows/ksud.yml` runs `git archive HEAD`, attached by +`release.yml`), which contains this directory verbatim. + +## FolkSU modifications + +None applied to the imported files as of the pinned revision. Any local change +must be recorded here with its rationale and kept minimal, so that re-syncing +from upstream stays a mechanical diff. + +## Build contract + +`kernel/src/` is built as an out-of-tree kernel module with the same Android +DDK image matrix used for `kernelsu.ko`: + +``` +CONFIG_NOMOUNT=m make -C "$KDIR" ARCH=arm64 LLVM=1 LLVM_IAS=1 \ + M=/userspace/nomount/kernel/src modules +``` + +The KMI name is derived from the DDK image (`ghcr.io/ylarod/ddk-min:-`), +matching `userspace/ksud/bin/aarch64/_kernelsu.ko`. Output is staged as +`userspace/ksud/bin/aarch64/_nomount.ko` for ksud's embedded assets. + +The upstream `kernel/src/Makefile` appends a commit count to +`NOMOUNT_VERSION` only when the source tree's enclosing git repository has an +origin URL containing `nomount`. Inside this repository that condition is +false, so the built module reports the plain base version (`21` for v2.1.1). +FolkSU's driver check therefore compares against the base version, not a commit +count. + +## Open items + +- `android17-6.18` is in FolkSU's KMI matrix but is not in upstream's test + matrix; compiling NoMount for it is unverified. +- Upstream's LKM workflow strips page-pinner symbols from the DDK headers before + building; the same workaround is needed here. +- Stripped module size per KMI is not yet measured, so the APK size delta is + unknown. diff --git a/userspace/nomount/build-lkm.sh b/userspace/nomount/build-lkm.sh new file mode 100644 index 000000000..37a12d656 --- /dev/null +++ b/userspace/nomount/build-lkm.sh @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Builds the vendored NoMount module inside an Android DDK container and drops +# the stripped module at $NOMOUNT_OUT. Invoked by build.sh and by +# .github/workflows/ddk-lkm.yml; it is a FolkSU-authored file, not upstream +# (see UPSTREAM.md). +# +# The vendored sources are never written to: the module is compiled from a copy +# in a temporary directory, so `userspace/nomount/kernel/src/` stays +# byte-identical to upstream and re-syncing remains a mechanical diff. +# +# Required environment (provided by the DDK image unless noted): +# KDIR kernel build directory inside the container +# NOMOUNT_OUT absolute path for the produced .ko (host-visible) +# +set -eu + +# Resolve the vendored sources relative to this script so the same entry point +# works from build.sh (repository at /build) and from CI (repository at the +# runner workspace). +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SELF_DIR/kernel/src" + +[ -d "$SRC" ] || { echo "NoMount source not found at $SRC" >&2; exit 1; } +[ -n "${KDIR:-}" ] || { echo "KDIR is not set; run inside the Android DDK image" >&2; exit 1; } +[ -n "${NOMOUNT_OUT:-}" ] || { echo "NOMOUNT_OUT is not set" >&2; exit 1; } + +# Some KMIs ship DDK headers that reference page-pinner helpers the kernel does +# not export, which fails the build for a module that never uses them. Disable +# the include and its single call site in the header copy, as upstream does. +# +# This writes outside the workspace (/opt/ddk), which only works when the +# container user owns those headers. Patch best-effort: if it is not permitted, +# continue, so a build that actually needed the patch fails on its own with a +# readable compiler error instead of an obscure permission failure here. +patched=0 +for mm in /opt/ddk/src/*/include/linux/mm.h /opt/ddk/kdir/*/include/linux/mm.h; do + [ -f "$mm" ] || continue + grep -q '^#include ' "$mm" || continue + if cp "$mm" "$mm.orig" 2>/dev/null && + sed -i 's|^#include |/* NoMount: page_pinner disabled */|' "$mm" 2>/dev/null && + sed -i 's|^\tpage_pinner_put_page(page);|\t/* NoMount: page_pinner disabled */|' "$mm" 2>/dev/null; then + echo "-- patched $mm" + patched=$((patched + 1)) + else + echo "!! cannot patch $mm (headers not writable); continuing" >&2 + fi +done +echo "-- page_pinner headers patched: $patched" + +build_dir="$(mktemp -d)" +trap 'rm -rf "$build_dir"' EXIT +cp -a "$SRC/." "$build_dir/" + +echo "-- building NoMount from $build_dir against $KDIR" +CONFIG_NOMOUNT=m make -C "$KDIR" ARCH=arm64 LLVM=1 LLVM_IAS=1 M="$build_dir" modules + +ko="$build_dir/nomount.ko" +[ -f "$ko" ] || { echo "nomount.ko was not produced in $build_dir" >&2; exit 1; } + +# Match the kernelsu.ko assets: strip debug/unneeded and drop BTF. +llvm-strip --strip-unneeded -R .BTF "$ko" + +mkdir -p "$(dirname "$NOMOUNT_OUT")" +cp "$ko" "$NOMOUNT_OUT" +ls -la "$NOMOUNT_OUT" diff --git a/userspace/nomount/kernel/LICENSE b/userspace/nomount/kernel/LICENSE new file mode 100644 index 000000000..d159169d1 --- /dev/null +++ b/userspace/nomount/kernel/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/userspace/nomount/kernel/src/Kconfig b/userspace/nomount/kernel/src/Kconfig new file mode 100644 index 000000000..638b9427e --- /dev/null +++ b/userspace/nomount/kernel/src/Kconfig @@ -0,0 +1,8 @@ +# Kconfig for NoMount + +config NOMOUNT + tristate "NoMount Path Redirection Subsystem" + default y + help + NoMount allows path redirection and virtual file injection + without mounting filesystems. Useful for systemless modifications. diff --git a/userspace/nomount/kernel/src/Makefile b/userspace/nomount/kernel/src/Makefile new file mode 100644 index 000000000..35710cda1 --- /dev/null +++ b/userspace/nomount/kernel/src/Makefile @@ -0,0 +1,21 @@ +# Makefile for NoMount + +ifeq ($(filter /%,$(src)),) +NM_REAL_PATH := $(realpath $(srctree)/$(src)) +else +NM_REAL_PATH := $(realpath $(src)) +endif + +NOMOUNT_COMMITS := $(shell \ + d='$(NM_REAL_PATH)'; \ + while [ -n "$$d" ] && [ "$$d" != "/" ] && [ ! -d "$$d/.git" ]; do d=$$(dirname "$$d"); done; \ + if [ -d "$$d/.git" ] && git -C "$$d" remote get-url origin 2>/dev/null | grep -q 'nomount'; then \ + git -C "$$d" rev-list --count HEAD 2>/dev/null; \ + fi) + +ifneq ($(NOMOUNT_COMMITS),) +ccflags-y += -DNOMOUNT_COMMIT_COUNT=\"$(NOMOUNT_COMMITS)\" +endif + +obj-$(CONFIG_NOMOUNT) += nomount.o +ccflags-y += -std=gnu11 -Wno-declaration-after-statement -O3 -fno-stack-protector diff --git a/userspace/nomount/kernel/src/nomount.c b/userspace/nomount/kernel/src/nomount.c new file mode 100644 index 000000000..e698b2a93 --- /dev/null +++ b/userspace/nomount/kernel/src/nomount.c @@ -0,0 +1,1802 @@ +#include +#include +#include +#include +#include +#include +#include +#include "nomount.h" + +/*** Helpers ***/ + +static bool nm_block_isolated_uids = false; +static __always_inline bool nomount_is_uid_blocked(uid_t target_uid) +{ + struct nm_uid_array *arr; + + if (unlikely(READ_ONCE(nm_block_isolated_uids))) { + uid_t app_id = target_uid % 100000U; + if (app_id >= 90000U && app_id <= 99999U) return true; + } + + if (likely(!rcu_access_pointer(nomount_uids))) + return false; + + rcu_guard(); + if ((arr = rcu_dereference(nomount_uids)) && nm_uid_bloom_test(arr, target_uid)) { + for (int i = 0; i < arr->count; i++) { + if (arr->uids[i] == target_uid) + return true; + } + } + return false; +} + +static __always_inline bool nm_child_matches(struct nomount_leaf *leaf, const char *name, size_t len) +{ + if (unlikely(!leaf || leaf->child_len != len)) return false; + const char *child_name = nm_get_child_name(leaf); + return child_name[0] == name[0] && child_name[len - 1] == name[len - 1] && !memcmp(child_name, name, len); +} + +static __always_inline struct nomount_leaf *nomount_bsearch_child(struct nomount_child_array *arr, const char *name, size_t len, u32 hash, int *index) +{ + struct nm_child *entries = nm_entries(arr); + int l = 0, n = arr->count; + + while (n > 0) { + int step = n >> 1, m = l + step, less = (entries[m].hash < hash), mask = -less; + l += (step + 1) & mask; + n = step + ((n - (step << 1) - 1) & mask); + } + if (index) *index = l; + while (l < arr->count && entries[l].hash == hash) { + struct nomount_leaf *leaf = entries[l].leaf; + if (nm_child_matches(leaf, name, len)) { + if (index) *index = l; + return leaf; + } + l++; + } + return NULL; +} + +static struct nm_rule *nm_select_rule(struct nomount_leaf *leaf, unsigned int uid) +{ + struct nm_rule_array *arr = rcu_dereference_check(leaf->rules, lockdep_is_held(&nomount_mutex)); + struct nm_rule *fallback = NULL; + if (!arr) return NULL; + for (int i = 0; i < arr->count; i++) { + struct nm_rule *r = &arr->rules[i]; + if (r->target_uid == uid) return r; + if (!r->target_uid) fallback = r; + if (r->target_uid > uid) break; + } + return fallback; +} + +static struct nm_rule *nm_lookup_rule(struct nomount_dir_node *dir_node, const char *name, size_t len, uid_t fsuid, struct nomount_leaf **leaf_out) +{ + struct nomount_child_array *children; + struct nomount_leaf *leaf = NULL; + struct nm_rule *rule = NULL; + + if (likely((children = rcu_dereference(dir_node->children)))) { + u32 bloom_h = nm_qhash(name, len); + if (!children->bloom_words) { + struct nm_child *entries = nm_entries(children); + for (int i = 0; i < children->count; i++) { + if (entries[i].bloom_hash == bloom_h && nm_child_matches(entries[i].leaf, name, len)) { + leaf = entries[i].leaf; + break; + } + } + } else if (unlikely(nm_bloom_test(children, bloom_h))) { + u32 hash = full_name_hash((const void *)(unsigned long)NOMOUNT_MAGIC_SIG, name, len); + leaf = nomount_bsearch_child(children, name, len, hash, NULL); + } + } + if (leaf) rule = nm_select_rule(leaf, fsuid); + if (rule && leaf_out) *leaf_out = leaf; + return rule; +} + +static bool nm_resolve_rule(struct nomount_dir_node *dir_node, const char *name, size_t len, uid_t fsuid, struct nm_rule_info *out) +{ + struct nomount_leaf *leaf = NULL; + struct nm_rule *rule; + + rcu_guard(); + rule = nm_lookup_rule(dir_node, name, len, fsuid, &leaf); + if (rule) { + out->flags = rule->flags; + out->v_ino = leaf->v_ino; + out->r_path = rule->r_path; + out->this_dir = (rule->flags & NM_FLAG_IS_DIR) ? leaf->this_dir : NULL; + if (out->r_path.dentry) path_get(&out->r_path); + if (out->this_dir) atomic_inc(&out->this_dir->refs); + return true; + } + return false; +} + +static void nm_dir_rcu_free(struct rcu_head *head) +{ + struct nomount_dir_node *dir = container_of(head, struct nomount_dir_node, rcu); + if (dir->pinned_dentry) dput(dir->pinned_dentry); + kfree(rcu_dereference_raw(dir->children)); + kfree(dir); +} + +static void nm_dir_put(struct nomount_dir_node *dir_node) +{ + if (dir_node && atomic_dec_and_test(&dir_node->refs)) + call_rcu(&dir_node->rcu, nm_dir_rcu_free); +} + +static inline void nm_destroy_virtual_inode(struct inode *inode) +{ + struct nm_inode_info *info = READ_ONCE(inode->i_private); + if (!info) return; + + WRITE_ONCE(inode->i_private, NULL); + smp_wmb(); + + nm_dir_put(info->dir_node); + if (info->r_path.dentry) path_put(&info->r_path); + kfree_rcu(info, rcu); +} + +static inline void nm_destroy_hijacked_inode(struct inode *inode) +{ + struct nm_dir_ops *ops = nm_get_nm_iop(smp_load_acquire(&inode->i_op)); + struct nomount_dir_node *node; + + if (!ops && !(ops = nm_get_nm_fop(smp_load_acquire(&inode->i_fop)))) return; + if (!(node = xchg(&ops->dir_node, NULL))) return; + + if (inode->i_op == &ops->fake_iop) smp_store_release(&inode->i_op, ops->orig_iop); + if (inode->i_fop == &ops->fake_fop) smp_store_release(&inode->i_fop, ops->orig_fop); + nm_dir_put(node); + kfree_rcu(ops, rcu); +} + +struct nomount_proxy_ctx { + struct dir_context ctx; + struct dir_context *orig_ctx; + struct nomount_dir_node *dir_node; + uid_t fsuid; + bool emitted; +}; + +static NM_ACTOR_RET nomount_actor_proxy(struct dir_context *ctx, const char *name, int namelen, + loff_t offset, u64 ino, unsigned int d_type) +{ + struct nomount_proxy_ctx *proxy = container_of(ctx, struct nomount_proxy_ctx, ctx); + struct dir_context *orig_ctx = proxy->orig_ctx; + NM_ACTOR_RET ret; + + if (likely(proxy->dir_node != NULL)) { + rcu_guard(); + if (unlikely(nm_lookup_rule(proxy->dir_node, name, namelen, proxy->fsuid, NULL) != NULL)) { + proxy->ctx.pos = offset; + return NM_ACTOR_CONTINUE; + } + } + + orig_ctx->pos = proxy->ctx.pos; + ret = orig_ctx->actor(orig_ctx, name, namelen, offset, ino, d_type); + proxy->ctx.pos = orig_ctx->pos; + proxy->emitted = true; + + return ret; +} + +static inline void __nomount_emit_virtual_children(struct dir_context *ctx, struct nomount_dir_node *dir_node, uid_t fsuid) +{ + if (!dir_node) return; + if (!nm_is_virtual_pos(ctx->pos)) ctx->pos = nm_pack_pos(0); + + for (int id = nm_unpack_pos(ctx->pos); ; id++) { + char name_buf[NAME_MAX + 1]; + int name_len = 0; + unsigned long v_ino = 0; + unsigned char d_type = 0; + bool do_emit = false, has_more = false; + + rcu_read_lock(); + struct nomount_child_array *array = rcu_dereference(dir_node->children); + if (array && id < array->count) { + struct nomount_leaf *leaf = nm_entries(array)[id].leaf; + struct nm_rule *rule = nm_select_rule(leaf, fsuid); + has_more = true; + if (rule && !(rule->flags & NM_FLAG_WHITEOUT)) { + name_len = leaf->child_len; + if (likely(name_len <= NAME_MAX)) { + memcpy(name_buf, nm_get_child_name(leaf), name_len); + v_ino = leaf->v_ino; + d_type = (rule->flags & NM_FLAG_IS_DIR) ? DT_DIR : DT_REG; + do_emit = true; + } + } + } + rcu_read_unlock(); + if (!has_more) break; + + ctx->pos = nm_pack_pos(id); + if (do_emit && !dir_emit(ctx, name_buf, name_len, v_ino, d_type)) break; + ctx->pos = nm_pack_pos(id + 1); + } +} + +static void nomount_init_prealloc_inode(struct inode *inode, struct nm_inode_info *info, unsigned long v_ino) +{ + struct inode *r_inode = info->r_path.dentry ? d_backing_inode(info->r_path.dentry) : NULL; + + inode->i_ino = v_ino; + inode->i_private = info; + inode->i_mode = r_inode ? r_inode->i_mode : (S_IFDIR | 0755); + inode->i_size = r_inode ? i_size_read(r_inode) : 4096; + inode->i_blocks = r_inode ? r_inode->i_blocks : 8; + inode->i_uid = r_inode ? r_inode->i_uid : GLOBAL_ROOT_UID; + inode->i_gid = r_inode ? r_inode->i_gid : GLOBAL_ROOT_GID; + inode->i_op = (r_inode && !S_ISDIR(r_inode->i_mode)) ? &nm_file_iops : &nm_dir_iops; + + if (r_inode && !S_ISDIR(r_inode->i_mode)) +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 16, 0) + inode->i_fop = (r_inode->i_fop && r_inode->i_fop->mmap_prepare) ? &nm_file_fops_mmap_prepare : &nm_file_fops; +#else + inode->i_fop = &nm_file_fops; +#endif + else + inode->i_fop = &nm_dir_fops; + + if (r_inode) nm_sync_inode_times(inode, r_inode), inode->i_mapping = r_inode->i_mapping; + inode->i_flags |= S_PRIVATE | S_NOATIME | S_NOCMTIME | S_NOSEC; + if (!S_ISLNK(inode->i_mode)) inode->i_opflags |= IOP_NOFOLLOW; +} + +static bool nm_inode_matches_binding(struct inode *inode, const struct path *path, + struct nomount_dir_node *dir_node) +{ + struct nm_inode_info *info = READ_ONCE(inode->i_private); + struct inode *r_inode; + + if (!info || info->dir_node != dir_node || !path_equal(&info->r_path, path)) + return false; + r_inode = path->dentry ? d_backing_inode(path->dentry) : NULL; + return !r_inode || (inode->i_state & I_NEW) || + (inode->i_mode == r_inode->i_mode && uid_eq(inode->i_uid, r_inode->i_uid) && + gid_eq(inode->i_gid, r_inode->i_gid)); +} + +static int nm_cached_inode_test(struct inode *inode, void *data) +{ + struct nm_inode_info *info = data; + return inode->i_op == &nm_dir_iops && + nm_inode_matches_binding(inode, &info->r_path, info->dir_node); +} + +static int nm_cached_inode_set(struct inode *inode, void *data) +{ + inode->i_private = data; + inode->i_op = &nm_dir_iops; + return 0; +} + +static struct dentry *nm_materialize_rule(struct inode *dir, struct dentry *dentry, struct nm_rule_info *rule_info) +{ + struct nm_inode_info *info = NULL; + struct inode *inode; + struct dentry *res = ERR_PTR(-ENODATA); + + if (rule_info->flags & NM_FLAG_WHITEOUT) { + nomount_hijack_dentry_ops(dir, dentry, true); + d_add(dentry, NULL); + res = NULL; + goto out; + } + + if (!(info = kmalloc(sizeof(*info), GFP_KERNEL))) goto out; + info->r_path = rule_info->r_path; + info->dir_node = rule_info->this_dir; + if (rule_info->this_dir) + inode = iget5_locked(dir->i_sb, (unsigned long)rule_info->this_dir, nm_cached_inode_test, nm_cached_inode_set, info); + else + inode = new_inode(dir->i_sb); + + if (!inode) goto out; + if (!rule_info->this_dir || (inode->i_state & I_NEW)) { + nomount_init_prealloc_inode(inode, info, rule_info->v_ino); + info = NULL; + if (rule_info->this_dir) unlock_new_inode(inode); + } + if (!IS_ERR((res = d_splice_alias(inode, dentry)))) + nomount_hijack_dentry_ops(dir, res ? res : dentry, true); + if (!info) return res; +out: + kfree(info); + nm_dir_put(rule_info->this_dir); + if (rule_info->r_path.dentry) path_put(&rule_info->r_path); + return res; +} + +/*** i_op / s_op / f_op Hijacking Hooks ***/ +static struct dentry *nomount_hijacked_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags) +{ + const struct inode_operations *orig_iop = NULL; + struct nm_rule_info rule_info = {0}; + struct nm_dir_ops *nm_iop = NULL; + struct dentry *res, *target; + bool found = false; + + rcu_read_lock(); + if (likely((nm_iop = nm_get_nm_iop(rcu_dereference(dir->i_op))))) { + orig_iop = nm_iop->orig_iop; + uid_t fsuid = current_fsuid().val; + if (likely(!nomount_is_uid_blocked(fsuid))) { + struct nomount_dir_node *dir_node = rcu_dereference(nm_iop->dir_node); + if (dir_node) found = nm_resolve_rule(dir_node, dentry->d_name.name, dentry->d_name.len, fsuid, &rule_info); + } + } + rcu_read_unlock(); + + if (found) + return nm_materialize_rule(dir, dentry, &rule_info); + + if (likely(orig_iop && orig_iop->lookup)) { + target = (res = orig_iop->lookup(dir, dentry, flags)) ? res : dentry; + if (likely(!IS_ERR(target))) + nomount_hijack_dentry_ops(dir, target, false); + return res; + } + return ERR_PTR(-EOPNOTSUPP); +} + +static int nomount_hijacked_iterate_dir(struct file *file, struct dir_context *ctx) +{ + const struct file_operations *orig_fop = NULL; + struct nomount_dir_node *d, *dir_node = NULL; + struct nm_dir_ops *nm_fop = NULL; + struct nomount_proxy_ctx proxy_ctx = { .ctx.actor = nomount_actor_proxy }; + uid_t fsuid = current_fsuid().val; + bool uid_blocked = false; + int res = 0; + + rcu_read_lock(); + if ((nm_fop = nm_get_nm_fop(rcu_dereference(file->f_op)))) { + orig_fop = nm_fop->orig_fop; + if ((d = rcu_dereference(nm_fop->dir_node)) && rcu_access_pointer(d->children) && atomic_inc_not_zero(&d->refs)) + dir_node = d; + } + rcu_read_unlock(); + + if (unlikely(!orig_fop || !dir_node)) + goto do_real_iterate; + + uid_blocked = nomount_is_uid_blocked(fsuid); + if (unlikely(nm_is_virtual_pos(ctx->pos))) { + if (!uid_blocked) __nomount_emit_virtual_children(ctx, dir_node, fsuid); + nm_dir_put(dir_node); + return 0; + } + + if (likely(!rcu_access_pointer(dir_node->children)) || unlikely(uid_blocked)) + goto put_dir_node; + + proxy_ctx.ctx.pos = ctx->pos; + proxy_ctx.orig_ctx = ctx; + proxy_ctx.fsuid = fsuid; + proxy_ctx.dir_node = dir_node; + res = nm_call_iterate(file, &proxy_ctx.ctx, orig_fop); + ctx->pos = proxy_ctx.ctx.pos; + + if (res < 0 || proxy_ctx.emitted) { + nm_dir_put(dir_node); + return res; + } + + ctx->pos = nm_pack_pos(0); + __nomount_emit_virtual_children(ctx, dir_node, fsuid); + nm_dir_put(dir_node); + return res; + +put_dir_node: + nm_dir_put(dir_node); +do_real_iterate: + if (likely(orig_fop)) + return nm_call_iterate(file, ctx, orig_fop); + return -ENOTDIR; +} + +static int nomount_hijacked_drop_inode(struct inode *inode) +{ + struct nm_sop *nm_sop; + if (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops) return 1; + + nm_sop = nm_get_nm_sop(smp_load_acquire(&inode->i_sb->s_op)); + if (nm_sop && nm_sop->orig_sop && nm_sop->orig_sop->drop_inode) + return nm_sop->orig_sop->drop_inode(inode); + + return !inode->i_nlink || inode_unhashed(inode); +} + +static void nomount_hijacked_evict_inode(struct inode *inode) +{ + struct nm_sop *nm_sop = nm_get_nm_sop(smp_load_acquire(&inode->i_sb->s_op)); + bool virtual = inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops; + + virtual ? nm_destroy_virtual_inode(inode) : nm_destroy_hijacked_inode(inode); + + if (!virtual && nm_sop && nm_sop->orig_sop && nm_sop->orig_sop->evict_inode) { + nm_sop->orig_sop->evict_inode(inode); + } else { + truncate_inode_pages_final(&inode->i_data); + clear_inode(inode); + } +} + +/*** file / inode / superblock operations ***/ + +static int nm_open(struct inode *inode, struct file *file) +{ + struct nm_inode_info *info = inode->i_private; + struct file *real_file; + + if (unlikely(!info)) return -ENODEV; + if (unlikely(!info->r_path.dentry)) { + file->private_data = NULL; + return 0; + } + + real_file = dentry_open(&info->r_path, (file->f_flags & ~(O_CREAT | O_EXCL | O_NOCTTY)), file->f_cred); + if (IS_ERR(real_file)) return PTR_ERR(real_file); + + file->private_data = real_file; + return 0; +} + +static int nm_release(struct inode *inode, struct file *file) +{ + struct file *real_file = file->private_data; + if (real_file) fput(real_file), file->private_data = NULL; + return 0; +} + +static loff_t nm_llseek(struct file *file, loff_t offset, int whence) +{ + struct file *real_file = file->private_data; + loff_t res; + if (!real_file) return -EINVAL; + + real_file->f_pos = file->f_pos; + res = vfs_llseek(real_file, offset, whence); + file->f_pos = real_file->f_pos; + + return res; +} + +static ssize_t nm_read_iter(struct kiocb *iocb, struct iov_iter *to) +{ + struct file *file = iocb->ki_filp; + struct file *real_file = file->private_data; + ssize_t ret; + if (!real_file || !real_file->f_op->read_iter) return -EINVAL; + + iocb->ki_filp = real_file; + ret = real_file->f_op->read_iter(iocb, to); + iocb->ki_filp = file; + + return ret; +} + +static ssize_t nm_write_iter(struct kiocb *iocb, struct iov_iter *from) +{ + struct file *file = iocb->ki_filp; + struct file *real_file = file->private_data; + ssize_t ret; + if (!real_file || !real_file->f_op->write_iter) return -EINVAL; + + iocb->ki_filp = real_file; + ret = real_file->f_op->write_iter(iocb, from); + iocb->ki_filp = file; + + return ret; +} + +static int nm_mmap(struct file *file, struct vm_area_struct *vma) +{ + int ret = generic_file_mmap(file, vma); + return ret ? ret : (file_inode(file)->i_flags &= ~S_PRIVATE, 0); +} + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 16, 0) +static int nm_mmap_prepare(struct vm_area_desc *desc) +{ + int ret = generic_file_mmap_prepare(desc); + return ret ? ret : (file_inode(desc->file)->i_flags &= ~S_PRIVATE, 0); +} +#endif + +static long nm_unlocked_ioctl(struct file *file, unsigned int cmd, unsigned long arg) +{ + struct file *real_file = file->private_data; + if (!real_file || !real_file->f_op->unlocked_ioctl) return -ENOTTY; + return real_file->f_op->unlocked_ioctl(real_file, cmd, arg); +} + +#ifdef CONFIG_COMPAT +static long nm_compat_ioctl(struct file *file, unsigned int cmd, unsigned long arg) +{ + struct file *real_file = file->private_data; + if (!real_file || !real_file->f_op->compat_ioctl) return -ENOTTY; + return real_file->f_op->compat_ioctl(real_file, cmd, arg); +} +#endif + +static ssize_t nm_splice_read(struct file *in, loff_t *ppos, struct pipe_inode_info *pipe, + size_t len, unsigned int flags) +{ + struct file *real_file = in->private_data; + if (!real_file || !real_file->f_op->splice_read) return -EINVAL; + return real_file->f_op->splice_read(real_file, ppos, pipe, len, flags); +} + +static ssize_t nm_splice_write(struct pipe_inode_info *pipe, struct file *out, + loff_t *ppos, size_t len, unsigned int flags) +{ + struct file *real_file = out->private_data; + if (!real_file || !real_file->f_op->splice_write) return -EINVAL; + return real_file->f_op->splice_write(pipe, real_file, ppos, len, flags); +} + +static int nm_fsync(struct file *file, loff_t start, loff_t end, int datasync) +{ + struct file *real_file = file->private_data; + if (!real_file || !real_file->f_op->fsync) return -EINVAL; + return real_file->f_op->fsync(real_file, start, end, datasync); +} + +static ssize_t nm_listxattr(struct dentry *dentry, char *buffer, size_t size) +{ + struct nm_inode_info *info = d_backing_inode(dentry)->i_private; + struct inode *r_inode; + + if (unlikely(!info)) return -EIO; + if (!info->r_path.dentry) return 0; + + r_inode = d_backing_inode(info->r_path.dentry); + if (!r_inode || !r_inode->i_op || !r_inode->i_op->listxattr) return 0; + return r_inode->i_op->listxattr(info->r_path.dentry, buffer, size); +} + +#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 11, 0) +static int nm_getattr(struct vfsmount *mnt, struct dentry *dentry, struct kstat *stat) +#else +static int nm_getattr(IDMAP_ARG const struct path *path, struct kstat *stat, u32 request_mask, unsigned int query_flags) +#endif +{ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 11, 0) + struct dentry *dentry = path->dentry; +#endif + struct inode *v_inode = d_backing_inode(dentry); + struct nm_inode_info *info = v_inode->i_private; + int res = 0; + + if (unlikely(!info)) return -EIO; + if (unlikely(!info->r_path.dentry)) +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 3, 0) + generic_fillattr(IDMAP_CALL request_mask, v_inode, stat); +#else + generic_fillattr(IDMAP_CALL v_inode, stat); +#endif + else +#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 11, 0) + res = vfs_getattr_nosec(&info->r_path, stat); +#else + res = vfs_getattr_nosec(&info->r_path, stat, request_mask, query_flags); +#endif + + if (likely(res == 0)) { + stat->ino = v_inode->i_ino; + stat->dev = v_inode->i_sb->s_dev; + } + + return res; +} + +static int nm_setattr(IDMAP_ARG struct dentry *dentry, struct iattr *attr) +{ + struct inode *v_inode = d_inode(dentry); + struct nm_inode_info *info = v_inode->i_private; + struct inode *r_inode; + int err; + + if (unlikely(!info)) return -EIO; + if (!info->r_path.dentry) return 0; + + r_inode = d_backing_inode(info->r_path.dentry); + inode_lock(r_inode); + err = notify_change(IDMAP_CALL info->r_path.dentry, attr, NULL); + inode_unlock(r_inode); + + if (likely(!err)) { + if (attr->ia_valid & ATTR_SIZE) i_size_write(v_inode, i_size_read(r_inode)); + if (attr->ia_valid & ATTR_MODE) v_inode->i_mode = r_inode->i_mode; + if (attr->ia_valid & ATTR_UID) v_inode->i_uid = r_inode->i_uid; + if (attr->ia_valid & ATTR_GID) v_inode->i_gid = r_inode->i_gid; + nm_sync_inode_times(v_inode, r_inode); + } + return err; +} + +static const char *nm_get_link(struct dentry *dentry, struct inode *inode, struct delayed_call *done) +{ + struct nm_inode_info *info = inode->i_private; + struct inode *real_inode; + struct dentry *target_dentry; + if (unlikely(!info || !info->r_path.dentry)) return ERR_PTR(-ECHILD); + + real_inode = d_backing_inode(info->r_path.dentry); + target_dentry = dentry ? info->r_path.dentry : NULL; + if (real_inode && real_inode->i_op && real_inode->i_op->get_link) { + return real_inode->i_op->get_link(target_dentry, real_inode, done); + } + + return ERR_PTR(-EINVAL); +} + +static int nm_dir_iterate_dir(struct file *file, struct dir_context *ctx) +{ + struct nm_inode_info *info = file_inode(file)->i_private; + struct nomount_dir_node *dir_node = info ? info->dir_node : NULL; + struct file *real_file = file->private_data; + uid_t fsuid = current_fsuid().val; + bool uid_blocked = nomount_is_uid_blocked(fsuid); + int res = 0; + + if (unlikely(nm_is_virtual_pos(ctx->pos))) + goto emit_virtual; + + if (real_file) { + if (unlikely(uid_blocked || !dir_node)) + return nm_call_iterate(real_file, ctx, real_file->f_op); + + struct nomount_proxy_ctx proxy_ctx = { + .ctx.actor = nomount_actor_proxy, .ctx.pos = ctx->pos, .orig_ctx = ctx, + .dir_node = dir_node, .fsuid = fsuid, .emitted = false, + }; + res = nm_call_iterate(real_file, &proxy_ctx.ctx, real_file->f_op); + ctx->pos = proxy_ctx.ctx.pos; + if (res < 0 || proxy_ctx.emitted) return res; + ctx->pos = nm_pack_pos(0); + } else if (info && !info->r_path.dentry) { + if (ctx->pos < 2 && !dir_emit_dots(file, ctx)) return 0; + ctx->pos = nm_pack_pos(0); + } else { + return -ENOTDIR; + } + +emit_virtual: + if (likely(!uid_blocked)) + __nomount_emit_virtual_children(ctx, dir_node, fsuid); + return res; +} + +static struct dentry *nm_dir_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags) +{ + struct nm_inode_info *info = dir->i_private; + struct nm_rule_info rule_info = {0}; + struct nomount_dir_node *dir_node; + uid_t fsuid = current_fsuid().val; + struct dentry *res; + + if (unlikely(nomount_is_uid_blocked(fsuid))) + goto negative_dentry; + + if (info->dir_node) + if ((dir_node = READ_ONCE(info->dir_node)) && nm_resolve_rule(dir_node, dentry->d_name.name, dentry->d_name.len, fsuid, &rule_info)) + return nm_materialize_rule(dir, dentry, &rule_info); + + if (!info->r_path.dentry) + goto negative_dentry; + + struct inode *r_dir = d_backing_inode(info->r_path.dentry); + if (r_dir->i_op->lookup) { + res = r_dir->i_op->lookup(r_dir, dentry, flags); + if (!IS_ERR(res ? res : dentry)) nomount_hijack_dentry_ops(dir, res ? res : dentry, false); + return res; + } + return ERR_PTR(-EOPNOTSUPP); + +negative_dentry: + nomount_hijack_dentry_ops(dir, dentry, true); + d_add(dentry, NULL); + return NULL; +} + +struct nm_xattr_proxy { + struct xattr_handler fake; + const struct xattr_handler *orig; +}; + +static int nm_xattr_get(const struct xattr_handler *handler, struct dentry *dentry, struct inode *inode, const char *name, void *buffer, size_t size FLAGS_ARG) +{ + struct nm_xattr_proxy *proxy = container_of(handler, struct nm_xattr_proxy, fake); + if (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops) { + struct nm_inode_info *info = inode->i_private; + if (unlikely(!info || !info->r_path.dentry)) return -ENODATA; + return __vfs_getxattr(info->r_path.dentry, d_inode(info->r_path.dentry), xattr_full_name(handler, name), buffer, size FLAGS_VAL); + } + + return proxy->orig->get(proxy->orig, dentry, inode, name, buffer, size FLAGS_VAL); +} + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 14, 0) && LINUX_VERSION_CODE < KERNEL_VERSION(5, 4, 0) +static int nm_xattr__get(const struct xattr_handler *handler, struct dentry *dentry, + struct inode *inode, const char *name, void *buffer, size_t size) +{ + struct nm_xattr_proxy *proxy = container_of(handler, struct nm_xattr_proxy, fake); + if (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops) { + struct nm_inode_info *info = inode->i_private; + if (unlikely(!info || !info->r_path.dentry)) return -ENODATA; + return __vfs_getxattr(info->r_path.dentry, d_inode(info->r_path.dentry), xattr_full_name(handler, name), buffer, size); + } + + if (proxy->orig->__get) + return proxy->orig->__get(proxy->orig, dentry, inode, name, buffer, size); + return proxy->orig->get(proxy->orig, dentry, inode, name, buffer, size); +} +#endif + +static int nm_xattr_set(const struct xattr_handler *handler, IDMAP_ARG struct dentry *dentry, struct inode *inode, const char *name, const void *buffer, size_t size, int flags) +{ + struct nm_xattr_proxy *proxy = container_of(handler, struct nm_xattr_proxy, fake); + if (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops) { + struct nm_inode_info *info = inode->i_private; + if (unlikely(!info || !info->r_path.dentry)) return -ENODATA; + return __vfs_setxattr(IDMAP_PATH(info->r_path) info->r_path.dentry, d_inode(info->r_path.dentry), xattr_full_name(handler, name), buffer, size, flags); + } + return proxy->orig->set(proxy->orig, IDMAP_CALL dentry, inode, name, buffer, size, flags); +} + +static int nm_d_revalidate_common(struct inode *parent_inode, const struct qstr *name, + struct dentry *dentry, unsigned int flags) +{ + struct nomount_dir_node *parent_dir = NULL; + const struct dentry_operations *orig_dops = NULL; + struct inode *inode = READ_ONCE(dentry->d_inode); + struct nomount_leaf *leaf = NULL; + struct nm_dir_ops *iop = NULL; + uid_t fsuid = current_fsuid().val; + bool owned, has_rule = false, whiteout = false, match_rule = false; + + if (unlikely(!parent_inode)) return 1; + owned = (READ_ONCE(dentry->d_op) == &nm_owned_dops) || + (inode && (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops)); + + rcu_read_lock(); + if (parent_inode->i_op == &nm_dir_iops) { + struct nm_inode_info *pinfo = rcu_dereference(parent_inode->i_private); + parent_dir = pinfo ? pinfo->dir_node : NULL; + } else if ((iop = nm_get_nm_iop(rcu_dereference(parent_inode->i_op)))) { + parent_dir = rcu_dereference(iop->dir_node); + } + + if (!nomount_is_uid_blocked(fsuid) && parent_dir) { + struct nm_rule *rule = nm_lookup_rule(parent_dir, name->name, name->len, fsuid, &leaf); + if (rule) { + has_rule = true; + if (rule->flags & NM_FLAG_WHITEOUT) + whiteout = true; + else if (inode && (inode->i_op == &nm_file_iops || inode->i_op == &nm_dir_iops)) + match_rule = nm_inode_matches_binding(inode, &rule->r_path, (rule->flags & NM_FLAG_IS_DIR) ? leaf->this_dir : NULL); + } + } + + orig_dops = nm_get_orig_dops(iop); + rcu_read_unlock(); + + if (whiteout) return !inode; + if (match_rule) return 1; + if (has_rule || owned) goto drop_it; + + return nm_call_orig_revalidate(orig_dops, parent_inode, name, dentry, flags); + +drop_it: + if (flags & LOOKUP_RCU) return -ECHILD; + d_drop(dentry); + return 0; +} + +static int nm_d_weak_revalidate(struct dentry *dentry, unsigned int flags) +{ + return nm_d_revalidate_common(d_inode(READ_ONCE(dentry->d_parent)), &dentry->d_name, dentry, flags); +} + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 14, 0) +static int nm_d_revalidate(struct inode *parent_inode, const struct qstr *name, struct dentry *dentry, unsigned int flags) { + return nm_d_revalidate_common(parent_inode, name, dentry, flags); +} +#else +static int nm_d_revalidate(struct dentry *dentry, unsigned int flags) { + return nm_d_revalidate_common(d_inode(READ_ONCE(dentry->d_parent)), &dentry->d_name, dentry, flags); +} +#endif + +static const struct dentry_operations nm_owned_dops = { + .d_revalidate = nm_d_revalidate, + .d_weak_revalidate = nm_d_weak_revalidate, +}; + +static const struct dentry_operations nm_dops = { + .d_revalidate = nm_d_revalidate, + .d_weak_revalidate = nm_d_weak_revalidate, +}; + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 16, 0) +static const struct file_operations nm_file_fops_mmap_prepare = { + .owner = THIS_MODULE, + .llseek = nm_llseek, + .open = nm_open, + .release = nm_release, + .read_iter = nm_read_iter, + .write_iter = nm_write_iter, + .mmap_prepare = nm_mmap_prepare, + .unlocked_ioctl = nm_unlocked_ioctl, +#ifdef CONFIG_COMPAT + .compat_ioctl = nm_compat_ioctl, +#endif + .splice_read = nm_splice_read, + .splice_write = nm_splice_write, + .fsync = nm_fsync, +}; +#endif + +static const struct file_operations nm_file_fops = { + .owner = THIS_MODULE, + .llseek = nm_llseek, + .open = nm_open, + .release = nm_release, + .read_iter = nm_read_iter, + .write_iter = nm_write_iter, + .mmap = nm_mmap, + .unlocked_ioctl = nm_unlocked_ioctl, +#ifdef CONFIG_COMPAT + .compat_ioctl = nm_compat_ioctl, +#endif + .splice_read = nm_splice_read, + .splice_write = nm_splice_write, + .fsync = nm_fsync, +}; + +static const struct inode_operations nm_file_iops = { + .getattr = nm_getattr, + .setattr = nm_setattr, + .listxattr = nm_listxattr, + .get_link = nm_get_link, +}; + +static const struct file_operations nm_dir_fops = { + .owner = THIS_MODULE, + .open = nm_open, + .release = nm_release, + .llseek = default_llseek, + .read = generic_read_dir, + .iterate_shared = nm_dir_iterate_dir, +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + .iterate = nm_dir_iterate_dir, +#endif +}; + +static const struct inode_operations nm_dir_iops = { + .lookup = nm_dir_lookup, + .getattr = nm_getattr, + .setattr = nm_setattr, + .listxattr = nm_listxattr, +}; + +/* --- Hijacking Management --- */ + +static inline int nomount_hijack_superblock(struct super_block *sb) +{ + struct nm_sop *nm_sop; + int count = 0; + + if (nm_get_nm_sop(smp_load_acquire(&sb->s_op))) return 0; + if (!(nm_sop = kmalloc(sizeof(*nm_sop), GFP_KERNEL))) return -ENOMEM; + + nm_sop->fake_sop = *(sb->s_op); + nm_sop->orig_sop = sb->s_op; + nm_sop->orig_xattr = nm_sop->fake_xattr = NULL; + nm_sop->sb = sb; + nm_sop->fake_sop.drop_inode = nomount_hijacked_drop_inode; + nm_sop->fake_sop.evict_inode = nomount_hijacked_evict_inode; + + if (sb->s_xattr) { + const struct xattr_handler **new_array; + struct nm_xattr_proxy *proxies; + + while (sb->s_xattr[count]) count++; + if (!(new_array = kmalloc((count + 1) * sizeof(void *) + (count * sizeof(*proxies)), GFP_KERNEL))) { + kfree(nm_sop); + return -ENOMEM; + } + proxies = (void *)(new_array + count + 1); + new_array[count] = NULL; + for (int i = 0; i < count; i++) { + proxies[i].orig = sb->s_xattr[i]; + proxies[i].fake = *sb->s_xattr[i]; + if (proxies[i].fake.get) proxies[i].fake.get = nm_xattr_get; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 14, 0) && LINUX_VERSION_CODE < KERNEL_VERSION(5, 4, 0) + if (proxies[i].fake.__get) proxies[i].fake.__get = nm_xattr__get; +#endif + if (proxies[i].fake.set) proxies[i].fake.set = nm_xattr_set; + new_array[i] = &proxies[i].fake; + } + nm_sop->orig_xattr = (const struct xattr_handler **)sb->s_xattr; + nm_sop->fake_xattr = new_array; + smp_store_release((const struct xattr_handler ***)&sb->s_xattr, new_array); + nm_debug("xattr handlers successfully hijacked for dev: 0x%x\n", sb->s_dev); + } + + list_add_tail_rcu(&nm_sop->list, &nomount_sb_list); + smp_store_release(&sb->s_op, &nm_sop->fake_sop); + nm_debug("Superblock successfully hijacked for dev: 0x%x\n", sb->s_dev); + return 0; +} + +static inline int nomount_hijack_dir_ops(struct nomount_dir_node *dir_node, struct inode *inode) +{ + const struct inode_operations *iop = smp_load_acquire(&inode->i_op); + const struct file_operations *fop = smp_load_acquire(&inode->i_fop); + struct nm_dir_ops *ops; + int err; + bool iterate = fop && (fop->iterate_shared +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + || fop->iterate +#endif + ); + + if ((ops = nm_get_nm_iop(iop)) || (ops = nm_get_nm_fop(fop))) { + if (ops->dir_node != dir_node) return -EBUSY; + return nomount_hijack_superblock(inode->i_sb); + } + if (!iop && !iterate) return -EOPNOTSUPP; + if (!(ops = kmalloc(sizeof(*ops), GFP_KERNEL))) return -ENOMEM; + if ((err = nomount_hijack_superblock(inode->i_sb))) { + kfree(ops); + return err; + } + + ops->orig_iop = iop; + ops->orig_fop = fop; + ops->orig_dops = NULL; + ops->dir_node = dir_node; + if (iop) { + ops->fake_iop = *iop; + ops->fake_iop.lookup = nomount_hijacked_lookup; + } + if (fop) { + ops->fake_fop = *fop; + ops->fake_fop.owner = THIS_MODULE; + if (fop->iterate_shared) ops->fake_fop.iterate_shared = nomount_hijacked_iterate_dir; +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + if (fop->iterate) ops->fake_fop.iterate = nomount_hijacked_iterate_dir; +#endif + } + atomic_inc(&dir_node->refs); + if (iop) smp_store_release(&inode->i_op, &ops->fake_iop); + if (fop) smp_store_release(&inode->i_fop, &ops->fake_fop); + + nm_debug("Successfully hijacked VFS ops for parent dir (ino: %lu)\n", (unsigned long)inode->i_ino); + return 1; +} + +static void nomount_hijack_dentry_ops(struct inode *dir, struct dentry *dentry, bool injected) +{ +#define DCACHE_OPS (DCACHE_OP_HASH | DCACHE_OP_COMPARE | DCACHE_OP_DELETE | DCACHE_OP_PRUNE | DCACHE_OP_REAL) + const struct dentry_operations *orig, *current_orig; + struct nm_dir_ops *iop; + + if (unlikely(!dentry || !dir)) return; + iop = nm_get_nm_iop(smp_load_acquire(&dir->i_op)); + orig = READ_ONCE(dentry->d_op); + if (unlikely(!injected && !iop && orig)) return; + if (orig == &nm_owned_dops || orig == &nm_dops || (iop && orig == &iop->fake_dops)) return; + + spin_lock(&dentry->d_lock); + orig = dentry->d_op; + if (orig == &nm_owned_dops || orig == &nm_dops || (iop && orig == &iop->fake_dops)) { + spin_unlock(&dentry->d_lock); + return; + } + + if (injected) { + dentry->d_op = &nm_owned_dops; + dentry->d_flags &= ~DCACHE_OPS; + dentry->d_flags |= (DCACHE_OP_REVALIDATE | DCACHE_OP_WEAK_REVALIDATE | DCACHE_DONTCACHE); + } else if (orig && iop) { + if (unlikely((current_orig = smp_load_acquire(&iop->orig_dops)) != orig)) { + if (current_orig == NULL) { + if (cmpxchg(&iop->orig_dops, NULL, NM_DOP_INITIALIZING) == NULL) { + iop->fake_dops = *orig; + iop->fake_dops.d_revalidate = nm_d_revalidate; + smp_store_release(&iop->orig_dops, orig); + } else { + while (smp_load_acquire(&iop->orig_dops) == NM_DOP_INITIALIZING) cpu_relax(); + } + } else if (current_orig == NM_DOP_INITIALIZING) { + while (smp_load_acquire(&iop->orig_dops) == NM_DOP_INITIALIZING) cpu_relax(); + } + } + dentry->d_op = &iop->fake_dops; + dentry->d_flags |= DCACHE_OP_REVALIDATE; + } else if (!orig) { + dentry->d_op = &nm_dops; + dentry->d_flags &= ~DCACHE_OPS; + dentry->d_flags |= (DCACHE_OP_REVALIDATE | DCACHE_OP_WEAK_REVALIDATE); + } + + spin_unlock(&dentry->d_lock); +} + +static void nomount_restore_superblocks(void) +{ + struct nm_sop *nm_sop, *tmp; + struct inode *inode; + list_for_each_entry_safe(nm_sop, tmp, &nomount_sb_list, list) { + if (nm_sop->sb) { + shrink_dcache_sb(nm_sop->sb); + spin_lock(&nm_sop->sb->s_inode_list_lock); + list_for_each_entry(inode, &nm_sop->sb->s_inodes, i_sb_list) { + if (!inode->i_op && !inode->i_fop) continue; + nm_destroy_hijacked_inode(inode); + } + spin_unlock(&nm_sop->sb->s_inode_list_lock); + smp_store_release(&nm_sop->sb->s_op, nm_sop->orig_sop); + if (nm_sop->fake_xattr) { + smp_store_release((const struct xattr_handler ***)&nm_sop->sb->s_xattr, nm_sop->orig_xattr); + kfree(nm_sop->fake_xattr); + } + nm_debug("Successfully cured superblock for dev: 0x%x\n", nm_sop->sb->s_dev); + } + list_del_rcu(&nm_sop->list); + kfree_rcu(nm_sop, rcu); + } +} + +/*** Module Management ***/ + +static struct nomount_dir_node *__nomount_alloc_dir_node(void) +{ + struct nomount_dir_node *dir_node = kzalloc(sizeof(*dir_node), GFP_KERNEL); + if (dir_node) atomic_set(&dir_node->refs, 1); + return dir_node; +} + +static struct nomount_child_array *nm_alloc_child_array(int count, u16 bloom_words) +{ + size_t sz = sizeof(struct nomount_child_array) + bloom_words * sizeof(u64) + count * sizeof(struct nm_child); + struct nomount_child_array *array = kmalloc(sz, GFP_KERNEL); + if (array) { + array->count = count; + array->bloom_words = bloom_words; + } + return array; +} + +static void nm_publish_child_view(struct nomount_dir_node *dir_node, struct nomount_child_array *children) +{ + struct nomount_child_array *old = rcu_dereference_protected(dir_node->children, lockdep_is_held(&nomount_mutex)); + rcu_assign_pointer(dir_node->children, children); + if (!children && dir_node->pinned_dentry) { + struct dentry *pinned = dir_node->pinned_dentry; + dir_node->pinned_dentry = NULL; + dput(pinned); + } + if (old) kfree_rcu(old, rcu); +} + +static int __nomount_inject_child_locked(struct nomount_dir_node *dir_node, struct nomount_leaf *leaf, const char *name, size_t name_len) +{ + struct nomount_child_array *array, *old = rcu_dereference_protected(dir_node->children, lockdep_is_held(&nomount_mutex)); + int count = old ? old->count : 0, pos = 0; + u32 full_h = full_name_hash((const void *)(unsigned long)NOMOUNT_MAGIC_SIG, name, name_len); + u32 bloom_h = nm_qhash(name, name_len); + + if (old && nomount_bsearch_child(old, name, name_len, full_h, &pos)) return -EEXIST; + if (!(array = nm_alloc_child_array(count + 1, nm_bloom_words_for(count + 1)))) return -ENOMEM; + + if (old) { + memcpy(nm_entries(array), nm_entries(old), pos * sizeof(struct nm_child)); + memcpy(nm_entries(array) + pos + 1, nm_entries(old) + pos, (count - pos) * sizeof(struct nm_child)); + } + if (array->bloom_words) { + if (old && array->bloom_words == old->bloom_words) { + memcpy(array->bloom, old->bloom, old->bloom_words * sizeof(u64)); + } else { + memset(array->bloom, 0, array->bloom_words * sizeof(u64)); + for (int i = 0; i < count; i++) + nm_bloom_set(array, nm_entries(old)[i].bloom_hash); + } + nm_bloom_set(array, bloom_h); + } + nm_entries(array)[pos] = (struct nm_child){ .hash = full_h, .bloom_hash = bloom_h, .leaf = leaf }; + leaf->child_len = name_len; + leaf->parent_dir = dir_node; + atomic_inc(&dir_node->refs); + nm_publish_child_view(dir_node, array); + return 0; +} + +static struct nomount_dir_node *__nomount_delete_child_locked(struct nomount_leaf *leaf, bool clear) +{ + struct nomount_dir_node *dir_node = leaf->parent_dir; + struct nomount_child_array *old, *array = NULL; + int pos; + + if (!dir_node) return NULL; + if (!(old = rcu_dereference_protected(dir_node->children, lockdep_is_held(&nomount_mutex)))) return dir_node; + if (!clear) { + for (pos = 0; pos < old->count && nm_entries(old)[pos].leaf != leaf; pos++) {} + if (pos == old->count) return dir_node; + if (old->count > 1) { + int new_count = old->count - 1; + if (!(array = nm_alloc_child_array(new_count, nm_bloom_words_for(new_count)))) return ERR_PTR(-ENOMEM); + memcpy(nm_entries(array), nm_entries(old), pos * sizeof(struct nm_child)); + memcpy(nm_entries(array) + pos, nm_entries(old) + pos + 1, (old->count - pos - 1) * sizeof(struct nm_child)); + if (array->bloom_words) { + memset(array->bloom, 0, array->bloom_words * sizeof(u64)); + for (int i = 0; i < array->count; i++) + nm_bloom_set(array, nm_entries(array)[i].bloom_hash); + } + } + } + nm_publish_child_view(dir_node, array); + return dir_node; +} + +static struct nomount_leaf *nm_find_leaf(const char *path, u16 len) +{ + struct nomount_leaf *leaf; + u32 hash = full_name_hash((const void *)(unsigned long)NOMOUNT_MAGIC_SIG, path, len); + hash_for_each_possible(nomount_leaves_ht, leaf, ht_node, hash) { + if (leaf->v_len == len && !memcmp(leaf->paths, path, len)) return leaf; + } + return NULL; +} + +static struct nm_rule_array *nm_alloc_rule_array(int count, size_t heap_size) +{ + struct nm_rule_array *arr = kmalloc(sizeof(struct nm_rule_array) + count * sizeof(struct nm_rule) + heap_size, GFP_KERNEL); + if (arr) arr->count = count; + return arr; +} + +static int nm_prepare_rule(const char *r_path, u16 r_len, u32 flags, unsigned int uid, struct nm_rule *out) +{ + bool backed = !(flags & (NM_FLAG_WHITEOUT | NM_FLAG_VIRTUAL_DIR)); + if (backed && !r_path) return -EINVAL; + if (backed) while (r_len > 1 && r_path[r_len - 1] == '/') r_len--; + else r_len = 0; + + memset(out, 0, sizeof(*out)); + out->r_len = r_len; + out->flags = flags; + out->target_uid = uid; + if (backed) { + int err = kern_path(r_path, LOOKUP_FOLLOW, &out->r_path); + if (err) { memset(out, 0, sizeof(*out)); return err; } + struct inode *inode = d_backing_inode(out->r_path.dentry); + if (inode && S_ISDIR(inode->i_mode)) out->flags |= NM_FLAG_IS_DIR; + } + return 0; +} + +static struct nomount_leaf *nm_alloc_leaf(const char *path, u16 len, u32 flags) +{ + struct nomount_leaf *leaf = kzalloc(sizeof(*leaf) + len + 1, GFP_KERNEL); + struct path anchor; + + if (!leaf) return ERR_PTR(-ENOMEM); + memcpy(leaf->paths, path, len); + leaf->v_len = len; + leaf->v_hash = full_name_hash((const void *)(unsigned long)NOMOUNT_MAGIC_SIG, path, len); + leaf->v_ino = (unsigned long)leaf->v_hash; + if (!kern_path(leaf->paths, LOOKUP_FOLLOW, &anchor)) { + struct inode *inode = d_backing_inode(anchor.dentry); + leaf->v_ino = inode->i_ino; + d_drop(anchor.dentry); + if (S_ISDIR(inode->i_mode) && inode->i_op != &nm_dir_iops) { + struct nm_dir_ops *iop = nm_get_nm_iop(smp_load_acquire(&inode->i_op)); + struct nm_dir_ops *fop = nm_get_nm_fop(smp_load_acquire(&inode->i_fop)); + struct nomount_dir_node *dir = iop ? iop->dir_node : (fop ? fop->dir_node : NULL); + if (dir && !dir->owner) { + leaf->this_dir = dir; + atomic_inc(&dir->refs); + } + leaf->anchor = anchor; + flags |= NM_FLAG_IS_DIR; + } else { + path_put(&anchor); + } + } + if ((flags & NM_FLAG_IS_DIR) && !leaf->this_dir) { + leaf->this_dir = __nomount_alloc_dir_node(); + if (!leaf->this_dir) { + if (leaf->anchor.dentry) path_put(&leaf->anchor); + kfree(leaf); + return ERR_PTR(-ENOMEM); + } + } + if (leaf->this_dir) leaf->this_dir->owner = leaf; + return leaf; +} + +static void nm_free_leaf(struct nomount_leaf *leaf) +{ + if (leaf->anchor.dentry) path_put(&leaf->anchor); + if (leaf->this_dir) { + if (leaf->this_dir->owner == leaf) leaf->this_dir->owner = NULL; + nm_dir_put(leaf->this_dir); + } + nm_dir_put(leaf->parent_dir); + kfree(leaf); +} + +static void nm_rule_array_free(struct rcu_head *head) +{ + struct nm_rule_array *arr = container_of(head, struct nm_rule_array, rcu); + for (int i = 0; i < arr->count; i++) + if (arr->rules[i].r_path.dentry) path_put(&arr->rules[i].r_path); + kfree(arr); +} + +static void nm_leaf_rcu_free(struct rcu_head *head) +{ + nm_free_leaf(container_of(head, struct nomount_leaf, rcu)); +} + +static int nomount_generate_virtual_topology(struct nomount_leaf *target, unsigned int uid) +{ + struct nomount_leaf *child = target, *parent; + struct hlist_node *h_tmp; + int len = target->v_len, err = 0; + HLIST_HEAD(pending); + + while (len > 1) { + int slash = len - 1; + while (slash > 0 && target->paths[slash] != '/') slash--; + int parent_len = slash ? slash : 1; + const char *name = target->paths + slash + 1; + int name_len = len - slash - 1; + + if ((parent = nm_find_leaf(target->paths, parent_len))) { + struct nm_rule *rule = nm_select_rule(parent, uid); + int hijacked = 0; + if ((rule && !(rule->flags & NM_FLAG_IS_DIR)) || !parent->this_dir) { err = -ENOTDIR; break; } + if (parent->anchor.dentry && (hijacked = nomount_hijack_dir_ops(parent->this_dir, d_backing_inode(parent->anchor.dentry))) < 0) { + err = hijacked; + break; + } + err = __nomount_inject_child_locked(parent->this_dir, child, name, name_len); + if (!err && parent->anchor.dentry) { + if (hijacked > 0) shrink_dcache_parent(parent->anchor.dentry); + struct dentry *dentry = nm_hash_and_lookup(parent->anchor.dentry, &(struct qstr)QSTR_INIT(name, name_len)); + if (dentry) { d_drop(dentry); dput(dentry); } + } + break; + } + + char saved = target->paths[parent_len]; + target->paths[parent_len] = '\0'; + struct path path; + int found = kern_path(target->paths, LOOKUP_FOLLOW, &path); + target->paths[parent_len] = saved; + if (!found) { + struct inode *inode = d_backing_inode(path.dentry); + struct nomount_dir_node *dir = NULL; + int hijacked = 0; + bool virtual = inode->i_op == &nm_dir_iops; + if (!S_ISDIR(inode->i_mode)) { + err = -ENOTDIR; + } else if (virtual) { + dir = ((struct nm_inode_info *)inode->i_private)->dir_node; + atomic_inc(&dir->refs); + } else { + struct nm_dir_ops *iop = nm_get_nm_iop(smp_load_acquire(&inode->i_op)); + struct nm_dir_ops *fop = nm_get_nm_fop(smp_load_acquire(&inode->i_fop)); + dir = iop ? iop->dir_node : (fop ? fop->dir_node : NULL); + if (dir) atomic_inc(&dir->refs); + else dir = __nomount_alloc_dir_node(); + if (!dir) err = -ENOMEM; + } + if (!err && !virtual && (hijacked = nomount_hijack_dir_ops(dir, inode)) < 0) + err = hijacked; + if (!err && !(err = __nomount_inject_child_locked(dir, child, name, name_len))) { + if (!virtual && !dir->owner && !dir->pinned_dentry) dir->pinned_dentry = dget(path.dentry); + if (hijacked > 0) shrink_dcache_parent(path.dentry); + struct dentry *child = nm_hash_and_lookup(path.dentry, &(struct qstr)QSTR_INIT(name, name_len)); + if (child) { d_drop(child); dput(child); } + } + nm_dir_put(dir); + path_put(&path); + break; + } + + parent = nm_alloc_leaf(target->paths, parent_len, NM_FLAG_IS_DIR); + if (IS_ERR(parent)) { err = PTR_ERR(parent); break; } + struct nm_rule_array *rules = nm_alloc_rule_array(1, 1); + if (!rules) { + err = -ENOMEM; + nm_free_leaf(parent); + break; + } + memset(&rules->rules[0], 0, sizeof(struct nm_rule)); + rules->rules[0].flags = NM_FLAG_IS_DIR | NM_FLAG_VIRTUAL_DIR; + nm_rule_heap(rules)[0] = '\0'; + RCU_INIT_POINTER(parent->rules, rules); + + hlist_add_head(&parent->ht_node, &pending); + err = __nomount_inject_child_locked(parent->this_dir, child, name, name_len); + if (err) break; + child = parent; + len = parent_len; + } + + hlist_for_each_entry_safe(parent, h_tmp, &pending, ht_node) { + hlist_del(&parent->ht_node); + if (!err) hash_add_rcu(nomount_leaves_ht, &parent->ht_node, parent->v_hash); + else { + kfree(rcu_access_pointer(parent->rules)); + nm_free_leaf(parent); + } + } + return err; +} + +static void nm_retire_rules(struct nomount_leaf *leaf) +{ + struct nm_rule_array *array = rcu_dereference_protected(leaf->rules, lockdep_is_held(&nomount_mutex)); + RCU_INIT_POINTER(leaf->rules, NULL); + if (array) call_rcu(&array->rcu, nm_rule_array_free); +} + +static struct nomount_dir_node *nm_detach_leaf_locked(struct nomount_leaf *leaf) +{ + struct nomount_dir_node *parent = __nomount_delete_child_locked(leaf, false); + if (IS_ERR(parent)) return parent; + if (leaf->this_dir) leaf->this_dir->owner = NULL; + hash_del_rcu(&leaf->ht_node); + call_rcu(&leaf->rcu, nm_leaf_rcu_free); + return parent; +} + +static int nomount_prune_empty_virtual_dirs(struct nomount_dir_node *dir) +{ + struct nomount_leaf *leaf; + while (dir && !rcu_access_pointer(dir->children) && (leaf = dir->owner)) { + struct nm_rule_array *rules = rcu_dereference_protected(leaf->rules, lockdep_is_held(&nomount_mutex)); + if (rules && (rules->count != 1 || !(rules->rules[0].flags & NM_FLAG_VIRTUAL_DIR))) break; + dir = nm_detach_leaf_locked(leaf); + if (IS_ERR(dir)) return PTR_ERR(dir); + nm_retire_rules(leaf); + } + return 0; +} + +/*** Rule Operations ***/ + +static int __nomount_add_rule(const char *v_path, const char *r_path, u16 v_len, u16 r_len, u32 flags, unsigned int uid) +{ + struct nm_rule new_rule; + struct nomount_leaf *leaf; + struct nm_rule_array *old, *array; + struct nomount_dir_node *new_dir = NULL; + int pos = 0, count, new_count, err = 0, j = 0; + size_t heap_size = 0; + bool fresh, replace; + + char *r_buf = NULL; + if (r_path && r_len) { + if (!(r_buf = kmalloc(r_len + 1, GFP_KERNEL))) return -ENOMEM; + memcpy(r_buf, r_path, r_len); + r_buf[r_len] = '\0'; + } + + err = nm_prepare_rule(r_buf, r_len, flags, uid, &new_rule); + if (err) { kfree(r_buf); return err; } + + while (v_len > 1 && v_path[v_len - 1] == '/') v_len--; + + fresh = !(leaf = nm_find_leaf(v_path, v_len)); + if (fresh && IS_ERR((leaf = nm_alloc_leaf(v_path, v_len, new_rule.flags)))) { + err = PTR_ERR(leaf); + goto out_rule; + } + + count = (old = rcu_dereference_protected(leaf->rules, lockdep_is_held(&nomount_mutex))) ? old->count : 0; + while (pos < count && old->rules[pos].target_uid < uid) pos++; + replace = pos < count && old->rules[pos].target_uid == uid; + new_count = count + !replace; + + if ((new_rule.flags & NM_FLAG_IS_DIR) && !leaf->this_dir && !(new_dir = __nomount_alloc_dir_node())) { + err = -ENOMEM; + goto out_leaf; + } + + if (old) { + for (int i = 0; i < count; i++) { + if (replace && i == pos) continue; + heap_size += old->rules[i].r_len + 1; + } + } + heap_size += new_rule.r_len + 1; + + if (!(array = nm_alloc_rule_array(new_count, heap_size))) { + err = -ENOMEM; + goto out_dir; + } + + char *new_h = nm_rule_heap(array); + if (old) { + const char *old_h = nm_rule_heap(old); + for (int i = 0; i < pos; i++, j++) { + array->rules[j] = old->rules[i]; + if (array->rules[j].r_path.dentry) path_get(&array->rules[j].r_path); + int len = old->rules[i].r_len; + memcpy(new_h, old_h, len); new_h[len] = '\0'; + new_h += len + 1; old_h += len + 1; + } + if (replace) old_h += old->rules[pos].r_len + 1; + + array->rules[j] = new_rule; + memcpy(new_h, r_buf, new_rule.r_len); + new_h[new_rule.r_len] = '\0'; + new_h += new_rule.r_len + 1; j++; + + for (int i = pos + replace; i < count; i++, j++) { + array->rules[j] = old->rules[i]; + if (array->rules[j].r_path.dentry) path_get(&array->rules[j].r_path); + int len = old->rules[i].r_len; + memcpy(new_h, old_h, len); new_h[len] = '\0'; + new_h += len + 1; old_h += len + 1; + } + } else { + array->rules[0] = new_rule; + memcpy(new_h, r_buf, new_rule.r_len); + new_h[new_rule.r_len] = '\0'; + } + + kfree(r_buf); + r_buf = NULL; + + if (fresh) { + RCU_INIT_POINTER(leaf->rules, array); + if ((err = nomount_generate_virtual_topology(leaf, uid))) { + mutex_unlock(&nomount_mutex); + for (int i = 0; i < array->count; i++) + if (array->rules[i].r_path.dentry) path_put(&array->rules[i].r_path); + kfree(array); + nm_free_leaf(leaf); + return err; + } + hash_add_rcu(nomount_leaves_ht, &leaf->ht_node, leaf->v_hash); + } else { + if (new_dir) { leaf->this_dir = new_dir; new_dir->owner = leaf; } + rcu_assign_pointer(leaf->rules, array); + if (old) call_rcu(&old->rcu, nm_rule_array_free); + } + + return 0; + +out_dir: nm_dir_put(new_dir); +out_leaf: if (fresh) nm_free_leaf(leaf); +out_rule: mutex_unlock(&nomount_mutex); + if (new_rule.r_path.dentry) path_put(&new_rule.r_path); + kfree(r_buf); + return err; +} + +static int __nomount_del_rule(const char *path, u16 len, unsigned int uid) +{ + struct nomount_leaf *leaf; + struct nm_rule_array *old, *array = NULL; + struct nomount_dir_node *parent = NULL; + int pos = 0, new_count; + size_t heap_size = 0; + bool stub_virtual = false; + + while (len > 1 && path[len - 1] == '/') len--; + if (!(leaf = nm_find_leaf(path, len)) || !(old = rcu_dereference_protected(leaf->rules, lockdep_is_held(&nomount_mutex)))) return 0; + while (pos < old->count && old->rules[pos].target_uid != uid) pos++; + if (pos == old->count) return 0; + + if (leaf->this_dir && rcu_access_pointer(leaf->this_dir->children) && + ((old->rules[pos].flags & NM_FLAG_IS_DIR) || !uid || old->rules[0].target_uid)) { + if (old->rules[pos].flags & NM_FLAG_VIRTUAL_DIR) return 0; + stub_virtual = true; + } + + if (!stub_virtual && old->count == 1 && (!leaf->this_dir || !rcu_access_pointer(leaf->this_dir->children))) { + parent = nm_detach_leaf_locked(leaf); + if (IS_ERR(parent)) return PTR_ERR(parent); + call_rcu(&old->rcu, nm_rule_array_free); + rcu_assign_pointer(leaf->rules, NULL); + return parent ? nomount_prune_empty_virtual_dirs(parent) : 0; + } + + new_count = stub_virtual ? old->count : old->count - 1; + for (int i = 0; i < old->count; i++) { + if (i == pos) continue; + heap_size += old->rules[i].r_len + 1; + } + if (stub_virtual) heap_size += 1; + + if (!(array = nm_alloc_rule_array(new_count, heap_size))) return -ENOMEM; + + char *new_h = nm_rule_heap(array); + const char *old_h = nm_rule_heap(old); + for (int i = 0, j = 0; i < old->count; i++) { + if (!stub_virtual && i == pos) { old_h += old->rules[i].r_len + 1; continue; } + if (stub_virtual && i == pos) { + array->rules[j].r_path = (struct path){ .mnt = NULL, .dentry = NULL }; + array->rules[j].target_uid = uid; + array->rules[j].r_len = 0; + array->rules[j].flags = NM_FLAG_IS_DIR | NM_FLAG_VIRTUAL_DIR; + *new_h++ = '\0'; + j++; old_h += old->rules[i].r_len + 1; + continue; + } + array->rules[j] = old->rules[i]; + if (array->rules[j].r_path.dentry) path_get(&array->rules[j].r_path); + int l = old->rules[i].r_len; + memcpy(new_h, old_h, l); new_h[l] = '\0'; + new_h += l + 1; old_h += l + 1; + j++; + } + + rcu_assign_pointer(leaf->rules, array); + call_rcu(&old->rcu, nm_rule_array_free); + return parent ? nomount_prune_empty_virtual_dirs(parent) : 0; +} + +static void __nomount_clear_all(int clear_flags) +{ + if (clear_flags & NM_CLEAR_UIDS) { + struct nm_uid_array *old = rcu_dereference_protected(nomount_uids, lockdep_is_held(&nomount_mutex)); + RCU_INIT_POINTER(nomount_uids, NULL); + if (old) kfree_rcu(old, rcu); + } + if (clear_flags & NM_CLEAR_RULES) { + struct nomount_leaf *leaf; + struct hlist_node *tmp; + int bkt; + hash_for_each_safe(nomount_leaves_ht, bkt, tmp, leaf, ht_node) { + __nomount_delete_child_locked(leaf, true); + if (leaf->this_dir) leaf->this_dir->owner = NULL; + hash_del_rcu(&leaf->ht_node); + nm_retire_rules(leaf); + call_rcu(&leaf->rcu, nm_leaf_rcu_free); + } + } + if (clear_flags & NM_CLEAR_EXIT) nomount_restore_superblocks(); +} + +/*** Payload Communication API ***/ + +static int nm_process_payload(unsigned long user_addr) +{ + struct nm_payload *payload; + struct page *page; + unsigned long pg_off = offset_in_page(user_addr); + char *buf_ptr, *buf_end; + + if (pg_off + sizeof(*payload) > PAGE_SIZE || get_user_pages_fast(user_addr, 1, FOLL_WRITE, &page) != 1) + return -EFAULT; + + if ((payload = (void *)((char *)kmap(page) + pg_off))->magic != NOMOUNT_MAGIC_SIG) { + kunmap(page); + put_page(page); + return -EFAULT; + } + + payload->status = 0; + + switch (payload->cmd) { + case NM_CMD_GET_VERSION: + memcpy(payload->buffer, NOMOUNT_VERSION, (payload->data_size = strlen(NOMOUNT_VERSION))); + break; + + case NM_CMD_ADD_RULE: { + unsigned int data_size = payload->data_size, offset = payload->arg1; + if (data_size > sizeof(payload->buffer) || offset > data_size) { payload->status = -EINVAL; break; } + buf_ptr = payload->buffer + offset; + buf_end = payload->buffer + data_size; + mutex_lock(&nomount_mutex); + while (buf_ptr < buf_end) { + struct nm_rule_hdr h; + int err; + if ((size_t)(buf_end - buf_ptr) < sizeof(h)) { + if (!payload->status) payload->status = -EINVAL; + break; + } + h = *(struct nm_rule_hdr *)buf_ptr; + if (unlikely(!h.v_len || h.v_len >= PATH_MAX || h.r_len >= PATH_MAX) || + h.v_len + h.r_len > (size_t)(buf_end - buf_ptr) - sizeof(h)) { + if (!payload->status) payload->status = -EINVAL; + break; + } + buf_ptr += sizeof(h); + err = __nomount_add_rule(buf_ptr, buf_ptr + h.v_len, h.v_len, h.r_len, h.flags, h.uid); + if (!payload->status) payload->status = err; + buf_ptr += (size_t)(h.v_len + h.r_len); + } + mutex_unlock(&nomount_mutex); + payload->arg1 = buf_ptr - payload->buffer; + break; + } + + case NM_CMD_DEL_RULE: { + unsigned int data_size = payload->data_size, offset = payload->arg1; + if (data_size > sizeof(payload->buffer) || offset > data_size) { payload->status = -EINVAL; break; } + buf_ptr = payload->buffer + offset; + buf_end = payload->buffer + data_size; + mutex_lock(&nomount_mutex); + while (buf_ptr < buf_end) { + struct nm_del_hdr h; + int err; + if ((size_t)(buf_end - buf_ptr) < sizeof(h)) { payload->status = -EINVAL; break; } + h = *(struct nm_del_hdr *)buf_ptr; + if (!h.v_len || h.v_len > (size_t)(buf_end - buf_ptr) - sizeof(h)) { payload->status = -EINVAL; break; } + buf_ptr += sizeof(h); + err = __nomount_del_rule(buf_ptr, h.v_len, h.uid); + if (!payload->status) payload->status = err; + buf_ptr += h.v_len; + } + mutex_unlock(&nomount_mutex); + payload->arg1 = buf_ptr - payload->buffer; + break; + } + + case NM_CMD_ADD_UID: + mutex_lock(&nomount_mutex); + payload->status = nm_uid_add(payload->target_uid); + mutex_unlock(&nomount_mutex); + break; + + case NM_CMD_DEL_UID: + mutex_lock(&nomount_mutex); + payload->status = nm_uid_del(payload->target_uid); + mutex_unlock(&nomount_mutex); + break; + + case NM_CMD_BLOCK_ISOLATED_UIDS: + WRITE_ONCE(nm_block_isolated_uids, payload->arg1 != 0); + break; + + case NM_CMD_GET_ISOLATED_STATE: + payload->buffer[0] = READ_ONCE(nm_block_isolated_uids) ? '1' : '0'; + payload->buffer[1] = '\n'; + payload->data_size = 2; + break; + + case NM_CMD_CLEAR_ALL: + case NM_CMD_CLEAR_UIDS: + case NM_CMD_CLEAR_RULES: + mutex_lock(&nomount_mutex); + __nomount_clear_all((payload->cmd == NM_CMD_CLEAR_ALL) ? (NM_CLEAR_UIDS | NM_CLEAR_RULES) : + (payload->cmd == NM_CMD_CLEAR_UIDS) ? NM_CLEAR_UIDS : NM_CLEAR_RULES); + mutex_unlock(&nomount_mutex); + break; + + case NM_CMD_GET_LIST: { + int current_idx = 0; + int bkt; + buf_ptr = payload->buffer; + buf_end = payload->buffer + sizeof(payload->buffer); + + rcu_guard(); + struct nomount_leaf *leaf; + hash_for_each_rcu(nomount_leaves_ht, bkt, leaf, ht_node) { + struct nm_rule_array *rules = rcu_dereference(leaf->rules); + if (!rules) continue; + const char *heap = nm_rule_heap(rules); + for (int i = 0; i < rules->count; i++) { + struct nm_rule *r = &rules->rules[i]; + if (current_idx++ < payload->arg1) { heap += r->r_len + 1; continue; } + if ((sizeof(struct nm_rule_hdr) + leaf->v_len + r->r_len) > (size_t)(buf_end - buf_ptr)) { current_idx--; goto list_done; } + *(struct nm_rule_hdr *)buf_ptr = (struct nm_rule_hdr){ .flags = r->flags, .uid = r->target_uid, .v_len = leaf->v_len, .r_len = r->r_len }; + buf_ptr += sizeof(struct nm_rule_hdr); + memcpy(buf_ptr, leaf->paths, leaf->v_len); buf_ptr += leaf->v_len; + if (r->r_len > 0) { memcpy(buf_ptr, heap, r->r_len); buf_ptr += r->r_len; } + heap += r->r_len + 1; + } + } + list_done: + payload->data_size = buf_ptr - payload->buffer; + payload->arg1 = current_idx; + break; + } + + case NM_CMD_GET_UIDS: { + u32 *out = (u32 *)payload->buffer; + u32 start_idx = payload->arg1; + struct nm_uid_array *arr; + int count = 0; + + rcu_guard(); + if ((arr = rcu_dereference(nomount_uids))) { + while (start_idx < arr->count && count < (sizeof(payload->buffer) / sizeof(*out))) { + out[count++] = arr->uids[start_idx++]; + } + } + payload->data_size = count * sizeof(*out); + payload->arg1 = start_idx; + break; + } + } + + kunmap(page); + set_page_dirty_lock(page); + put_page(page); + return 0; +} + +static int nm_key_preparse(struct key_preparsed_payload *prep) +{ + unsigned long user_addr = 0; + if (!capable(CAP_SYS_ADMIN)) return -EPERM; + if (prep->datalen == 8) user_addr = *(u64 *)prep->data; + else if (prep->datalen == 4) user_addr = *(u32 *)prep->data; + else return -EINVAL; + if (user_addr) nm_process_payload(user_addr); + return -ECANCELED; +} + +static int dummy_key_instantiate(struct key *key, struct key_preparsed_payload *prep) { return -EINVAL; } +static void dummy_key_free_preparse(struct key_preparsed_payload *prep) { } + +static struct key_type nm_key_type = { + .name = "nomount", + .preparse = nm_key_preparse, + .free_preparse = dummy_key_free_preparse, + .instantiate = dummy_key_instantiate, +}; + +static int __init nomount_init(void) +{ + int ret = register_key_type(&nm_key_type); + if (ret) + nm_err("Failed to register key type (err: %d)\n", ret); + else + nm_info("Loaded successfully\n"); + return ret; +} + +static void __exit nomount_exit(void) +{ + unregister_key_type(&nm_key_type); + mutex_lock(&nomount_mutex); + __nomount_clear_all(NM_CLEAR_UIDS | NM_CLEAR_RULES | NM_CLEAR_EXIT); + mutex_unlock(&nomount_mutex); + rcu_barrier(); + nm_info("Unloaded successfully\n"); +} + +MODULE_LICENSE("GPL"); +MODULE_VERSION(NOMOUNT_VERSION); +MODULE_AUTHOR("maxsteeel"); +MODULE_DESCRIPTION("NoMount Path Redirection VFS Subsystem"); + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 13, 0) +MODULE_IMPORT_NS("VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver"); +MODULE_IMPORT_NS("ANDROID_GKI_VFS_EXPORT_ONLY"); +#elif LINUX_VERSION_CODE >= KERNEL_VERSION(5, 0, 0) +MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver); +MODULE_IMPORT_NS(ANDROID_GKI_VFS_EXPORT_ONLY); +#endif + +fs_initcall(nomount_init); +module_exit(nomount_exit); diff --git a/userspace/nomount/kernel/src/nomount.h b/userspace/nomount/kernel/src/nomount.h new file mode 100644 index 000000000..95deecac1 --- /dev/null +++ b/userspace/nomount/kernel/src/nomount.h @@ -0,0 +1,435 @@ +#ifndef _LINUX_NOMOUNT_H +#define _LINUX_NOMOUNT_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NOMOUNT_BASE_VERSION "21" +#ifdef NOMOUNT_COMMIT_COUNT + #define NOMOUNT_VERSION ""NOMOUNT_BASE_VERSION" ("NOMOUNT_COMMIT_COUNT")" +#else + #define NOMOUNT_VERSION NOMOUNT_BASE_VERSION +#endif + +#define NOMOUNT_MAGIC_SIG 0x4E4F4D4F554E54ULL /* "NOMOUNT" in hex */ +#define NM_FLAG_IS_DIR (1 << 0) +#define NM_FLAG_VIRTUAL_DIR (1 << 1) +#define NM_FLAG_WHITEOUT (1 << 2) + +/* flags for cleanup */ +#define NM_CLEAR_UIDS (1 << 0) +#define NM_CLEAR_RULES (1 << 1) +#define NM_CLEAR_EXIT (1 << 2) + +/* logs */ +#define nm_debug(fmt, ...) printk(KERN_DEBUG "NoMount: [DEBUG] " fmt, ##__VA_ARGS__) +#define nm_info(fmt, ...) printk(KERN_INFO "NoMount: " fmt, ##__VA_ARGS__) +#define nm_warn(fmt, ...) printk(KERN_WARNING "NoMount: [WARN] " fmt, ##__VA_ARGS__) +#define nm_err(fmt, ...) printk(KERN_ERR "NoMount: [ERROR] " fmt, ##__VA_ARGS__) + +static struct nm_uid_array __rcu *nomount_uids = NULL; +static DEFINE_HASHTABLE(nomount_leaves_ht, 6); +static DEFINE_MUTEX(nomount_mutex); +static LIST_HEAD(nomount_sb_list); + +/* * Helpers to dynamically calculate the memory address of the strings / structs */ +#define nm_get_child_name(leaf) ((leaf)->paths + (leaf)->v_len - (leaf)->child_len) +#define nm_rule_heap(arr) ((char *)(arr)->rules + (arr)->count * sizeof(struct nm_rule)) + +struct nm_dir_ops { + struct inode_operations fake_iop; + struct file_operations fake_fop; + const struct inode_operations *orig_iop; + const struct file_operations *orig_fop; + struct nomount_dir_node *dir_node; + struct rcu_head rcu; + + /* Dentry Operations Hijacking */ + struct dentry_operations fake_dops; + const struct dentry_operations *orig_dops; +}; + +struct nm_sop { + struct super_operations fake_sop; /* MUST be exactly at offset 0 */ + const struct super_operations *orig_sop; + const struct xattr_handler **orig_xattr; + const struct xattr_handler **fake_xattr; + struct super_block *sb; + struct rcu_head rcu; + struct list_head list; +}; + +struct nm_inode_info { + struct path r_path; + struct nomount_dir_node *dir_node; + struct rcu_head rcu; +}; + +struct nm_child { + u32 hash; + u32 bloom_hash; + struct nomount_leaf *leaf; +}; + +struct nomount_child_array { + struct rcu_head rcu; + int count; + u32 bloom_words; + u64 bloom[] __aligned(8); +}; + +struct nomount_dir_node { + struct rcu_head rcu; + struct nomount_child_array __rcu *children; + atomic_t refs; + struct dentry *pinned_dentry; + struct nomount_leaf *owner; +}; + +struct nm_rule { + struct path r_path; + unsigned int target_uid; + u16 r_len; + u16 flags; +}; + +struct nm_rule_array { + struct rcu_head rcu; + int count; + struct nm_rule rules[]; +}; + +struct nomount_leaf { + struct path anchor; + struct nm_rule_array __rcu *rules; + struct nomount_dir_node *this_dir; + struct nomount_dir_node *parent_dir; + unsigned long v_ino; + u32 v_hash; + u16 v_len; + u16 child_len; + struct hlist_node ht_node; + struct rcu_head rcu; + char paths[]; +}; + +struct nm_rule_info { + u16 flags; + unsigned long v_ino; + struct path r_path; + struct nomount_dir_node *this_dir; +}; + +struct nm_uid_array { + struct rcu_head rcu; + int count; + u64 bloom_mask[4]; + uid_t uids[]; +}; + +static inline struct nm_child *nm_entries(struct nomount_child_array *arr) +{ + return (struct nm_child *)(arr->bloom + arr->bloom_words); +} + +static inline int nm_bloom_words_for(int count) +{ + if (count <= 2) return 0; + if (count <= 16) return 4; + if (count <= 64) return 16; + if (count <= 256) return 64; + if (count <= 1024) return 128; + return 256; +} + +#define nm_bloom_set(arr, hash) \ + ((arr)->bloom[((hash) >> 6) & ((arr)->bloom_words - 1)] |= 1ULL << ((hash) & 63)) + +#define nm_bloom_test(arr, hash) \ + (!!((arr)->bloom[((hash) >> 6) & ((arr)->bloom_words - 1)] & (1ULL << ((hash) & 63)))) + +static __always_inline u32 nm_qhash(const char *name, size_t len) +{ + u32 h = (u8)name[0] ^ (u32)len; + h = (h << 5) + (u8)name[len - 1]; + h = (h << 5) + (u8)name[len >> 1]; + return h; +} + +/*** Operaction Vectors ***/ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 16, 0) +static const struct file_operations nm_file_fops_mmap_prepare; +#endif +static const struct file_operations nm_file_fops; +static const struct inode_operations nm_file_iops; +static const struct file_operations nm_dir_fops; +static const struct inode_operations nm_dir_iops; +static const struct dentry_operations nm_dops; +static const struct dentry_operations nm_owned_dops; + +/*** forward declarations ***/ +static struct dentry *nomount_hijacked_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags); +static int nomount_hijacked_iterate_dir(struct file *file, struct dir_context *ctx); +static void nomount_hijacked_evict_inode(struct inode *inode); +static void nomount_hijack_dentry_ops(struct inode *dir, struct dentry *dentry, bool injected); +static void nm_dir_put(struct nomount_dir_node *dir_node); + +/* ===================================================================== + * NoMount VFS Offset Protocol + * ===================================================================== + * 64-bit layout: [ 16-bit 'nm' ][ 16-bit 0 ][ 32-bit ID ] + * 32-bit layout: [ 16-bit 'nm' ][ 16-bit ID ] + */ +#define NM_SIG_16 0x6E6DULL /* "nm" in hex */ +static inline bool nm_is_virtual_pos(loff_t pos) { +#ifdef CONFIG_COMPAT + if (in_compat_syscall()) return (pos & 0xFFFF0000ULL) == (NM_SIG_16 << 16); +#endif + return (pos & 0xFFFFFFFF00000000ULL) == (NM_SIG_16 << 48); +} + +static inline loff_t nm_pack_pos(int id) { +#ifdef CONFIG_COMPAT + if (in_compat_syscall()) return (NM_SIG_16 << 16) | (id & 0xFFFF); +#endif + return (NM_SIG_16 << 48) | (id & 0xFFFFFFFF); +} + +static inline int nm_unpack_pos(loff_t pos) { +#ifdef CONFIG_COMPAT + if (in_compat_syscall()) return (int)(pos & 0xFFFF); +#endif + return (int)(pos & 0xFFFFFFFF); +} + +/* --- UIDs Array RCU Management --- */ +static __always_inline void nm_uid_bloom_set(struct nm_uid_array *arr, uid_t uid) +{ + arr->bloom_mask[(uid >> 6) & 3] |= 1ULL << (uid & 63); +} +static __always_inline bool nm_uid_bloom_test(struct nm_uid_array *arr, uid_t uid) +{ + return !!(arr->bloom_mask[(uid >> 6) & 3] & (1ULL << (uid & 63))); +} + +static inline int nm_uid_add(uid_t target) +{ + struct nm_uid_array *old, *new_arr; + int count = 0; + + if ((old = rcu_dereference_protected(nomount_uids, lockdep_is_held(&nomount_mutex)))) { + for (int i = 0; i < (count = old->count); i++) if (old->uids[i] == target) return -EEXIST; + } + + if (!(new_arr = kmalloc(sizeof(*new_arr) + (count + 1) * sizeof(uid_t), GFP_KERNEL))) + return -ENOMEM; + + new_arr->count = count + 1; + if (old) { + memcpy(new_arr->uids, old->uids, count * sizeof(uid_t)); + memcpy(new_arr->bloom_mask, old->bloom_mask, sizeof(new_arr->bloom_mask)); + } else { + memset(new_arr->bloom_mask, 0, sizeof(new_arr->bloom_mask)); + } + new_arr->uids[count] = target; + nm_uid_bloom_set(new_arr, target); + rcu_assign_pointer(nomount_uids, new_arr); + if (old) kfree_rcu(old, rcu); + return 0; +} + +static inline int nm_uid_del(uid_t target) +{ + struct nm_uid_array *old, *new_arr = NULL; + int count, target_idx = -1; + + if (!(old = rcu_dereference_protected(nomount_uids, lockdep_is_held(&nomount_mutex)))) return -ENOENT; + for (int i = 0; i < (count = old->count); i++) if (old->uids[i] == target) { target_idx = i; break; } + if (target_idx < 0) return -ENOENT; + + if (count > 1) { + if (!(new_arr = kmalloc(sizeof(*new_arr) + (count - 1) * sizeof(uid_t), GFP_KERNEL))) return -ENOMEM; + new_arr->count = count - 1; + if (target_idx > 0) memcpy(new_arr->uids, old->uids, target_idx * sizeof(uid_t)); + if (target_idx < count - 1) memcpy(new_arr->uids + target_idx, old->uids + target_idx + 1, (count - target_idx - 1) * sizeof(uid_t)); + memset(new_arr->bloom_mask, 0, sizeof(new_arr->bloom_mask)); + for (int i = 0; i < new_arr->count; i++) + nm_uid_bloom_set(new_arr, new_arr->uids[i]); + } + + rcu_assign_pointer(nomount_uids, new_arr); + kfree_rcu(old, rcu); + return 0; +} + +/* ============================ */ +/* NOMOUNT PAYLOAD PROTOCOL */ +/* ============================ */ + +enum { + NM_CMD_UNSPEC = 0, + NM_CMD_GET_VERSION, + NM_CMD_ADD_RULE, + NM_CMD_DEL_RULE, + NM_CMD_ADD_UID, + NM_CMD_DEL_UID, + NM_CMD_CLEAR_ALL, + NM_CMD_CLEAR_RULES, + NM_CMD_CLEAR_UIDS, + NM_CMD_GET_LIST, + NM_CMD_GET_UIDS, + NM_CMD_BLOCK_ISOLATED_UIDS, + NM_CMD_GET_ISOLATED_STATE, +}; + +struct nm_payload { + u64 magic; + u32 cmd; + u32 target_uid; + int status; + u32 arg1; + u32 data_size; + char buffer[4068]; +} __attribute__((packed)); + +struct nm_rule_hdr { + u32 flags; + u32 uid; + u16 v_len; + u16 r_len; +} __attribute__((packed)); + +struct nm_del_hdr { + u32 uid; + u16 v_len; +} __attribute__((packed)); + +/* * Compat macros * */ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 3, 0) + #define IDMAP_PATH(path) mnt_idmap((path).mnt), + #define IDMAP_ARG struct mnt_idmap *idmap, + #define IDMAP_CALL idmap, +#elif LINUX_VERSION_CODE >= KERNEL_VERSION(5, 12, 0) + #define IDMAP_PATH(path) mnt_user_ns((path).mnt), + #define IDMAP_ARG struct user_namespace *mnt_userns, + #define IDMAP_CALL mnt_userns, +#else + #define IDMAP_PATH(path)/* Nothing */ + #define IDMAP_ARG /* Nothing */ + #define IDMAP_CALL /* Nothing */ +#endif + +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 1, 0) + #define NM_ACTOR_RET bool + #define NM_ACTOR_CONTINUE true +#else + #define NM_ACTOR_RET int + #define NM_ACTOR_CONTINUE 0 +#endif + +#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 12, 0) && LINUX_VERSION_CODE >= KERNEL_VERSION(5, 2, 0) + #define FLAGS_ARG , int flags + #define FLAGS_VAL , flags +#else + #define FLAGS_ARG /* Nothing */ + #define FLAGS_VAL /* Nothing */ +#endif + +#ifndef DCACHE_DONTCACHE +# define DCACHE_DONTCACHE 0 +#endif + +static inline void nm_sync_inode_times(struct inode *v_inode, struct inode *r_inode) +{ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 12, 0) + v_inode->i_atime_sec = r_inode->i_atime_sec; + v_inode->i_atime_nsec = r_inode->i_atime_nsec; + v_inode->i_mtime_sec = r_inode->i_mtime_sec; + v_inode->i_mtime_nsec = r_inode->i_mtime_nsec; + v_inode->i_ctime_sec = r_inode->i_ctime_sec; + v_inode->i_ctime_nsec = r_inode->i_ctime_nsec; +#elif LINUX_VERSION_CODE >= KERNEL_VERSION(6, 6, 0) + v_inode->i_atime = r_inode->i_atime; + v_inode->i_mtime = r_inode->i_mtime; + inode_set_ctime_to_ts(v_inode, inode_get_ctime(r_inode)); +#else + v_inode->i_atime = r_inode->i_atime; + v_inode->i_mtime = r_inode->i_mtime; + v_inode->i_ctime = r_inode->i_ctime; +#endif +} + +static inline int nm_call_iterate(struct file *file, struct dir_context *ctx, const struct file_operations *fop) +{ + if (fop->iterate_shared) + return fop->iterate_shared(file, ctx); +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + else if (fop->iterate) + return fop->iterate(file, ctx); +#endif + return -ENOTDIR; +} + +static inline struct dentry *nm_hash_and_lookup(struct dentry *dir, struct qstr *n) { + n->hash = full_name_hash(dir, n->name, n->len); + return (unlikely(dir->d_flags & DCACHE_OP_HASH) && dir->d_op->d_hash(dir, n) < 0) ? NULL : d_lookup(dir, n); +} + +static inline struct nm_dir_ops *nm_get_nm_iop(const struct inode_operations *iop) { + if (likely(iop) && iop->lookup == nomount_hijacked_lookup) + return container_of(iop, struct nm_dir_ops, fake_iop); + return NULL; +} + +static inline struct nm_dir_ops *nm_get_nm_fop(const struct file_operations *fop) { + if (unlikely(!fop)) return NULL; + if (fop->iterate_shared == nomount_hijacked_iterate_dir) + return container_of(fop, struct nm_dir_ops, fake_fop); +#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0) + if (fop->iterate == nomount_hijacked_iterate_dir) + return container_of(fop, struct nm_dir_ops, fake_fop); +#endif + return NULL; +} + +static inline struct nm_sop *nm_get_nm_sop(const struct super_operations *sop) { + if (likely(sop) && sop->evict_inode == nomount_hijacked_evict_inode) + return container_of(sop, struct nm_sop, fake_sop); + return NULL; +} + +#define NM_DOP_INITIALIZING ((const struct dentry_operations *)1L) +static inline const struct dentry_operations *nm_get_orig_dops(struct nm_dir_ops *iop) +{ + const struct dentry_operations *dops; + if (!iop) return NULL; + dops = smp_load_acquire(&iop->orig_dops); + return (dops == NM_DOP_INITIALIZING) ? NULL : dops; +} + +static inline int nm_call_orig_revalidate(const struct dentry_operations *dops, struct inode *parent_inode, + const struct qstr *name, struct dentry *dentry, unsigned int flags) +{ + if (!dops || !dops->d_revalidate) return 1; +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 14, 0) + return dops->d_revalidate(parent_inode, name, dentry, flags); +#else + return dops->d_revalidate(dentry, flags); +#endif +} + +/* RCU read lock scope guard */ +static __always_inline void __nm_rcu_unlock(int *g) { (void)g; rcu_read_unlock(); } +#define rcu_guard() rcu_read_lock(); int __attribute__((cleanup(__nm_rcu_unlock), unused)) __UNIQUE_ID(rcug) + +#endif /* _LINUX_NOMOUNT_H */ From 93faafb2412cb34a00b156b76cb35521648f9a16 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:10:14 +0800 Subject: [PATCH 04/24] nomount: Add the built-in backend, rule generation and visibility sync ksud can now drive NoMount on its own, without the upstream metamodule or the `nm` binary: the kernel protocol is reimplemented in Rust and the rules come from the module directory instead of metamount.sh. - nomount.rs encodes the page-aligned add_key payload, probes the driver, refuses an API below 21, and verifies every applied rule by reading the kernel list back, so a transport that drops rules cannot look successful. - nomount_plan.rs walks the modules (partition remapping, whiteout markers, opaque directories) and replaces a replaced directory by redirecting it: upstream whiteouts it without the directory flag and the kernel then rejects every child rule with -ENOTDIR, so children would be lost. - Visibility: the stored UID list and the isolated-process flag are replayed into the kernel, which is what hides injected files from apps now that there are no mounts to unmount. - magic_mount gains the nomount provider: it loads the embedded module for the running KMI, falls back to the built-in engine when NoMount cannot be made usable, and records why. `mount status` becomes schema 2 with boot_warning. - New commands: ksud nomount status/inject/list/clear and uid set/add/del/isolated. Host tests: 26 new cases covering the wire layout, batching, read-back verification, rule generation, the visibility diff and the kernel's last-rule-wins conflict rule. --- userspace/ksud/src/cli.rs | 112 ++ userspace/ksud/src/defs.rs | 7 + userspace/ksud/src/magic_mount.rs | 87 +- userspace/ksud/src/main.rs | 6 + userspace/ksud/src/nomount.rs | 1248 ++++++++++++++++++++++ userspace/ksud/src/nomount_plan.rs | 309 ++++++ userspace/ksud/src/nomount_plan_tests.rs | 284 +++++ userspace/ksud/src/nomount_tests.rs | 528 +++++++++ 8 files changed, 2574 insertions(+), 7 deletions(-) create mode 100644 userspace/ksud/src/nomount.rs create mode 100644 userspace/ksud/src/nomount_plan.rs create mode 100644 userspace/ksud/src/nomount_plan_tests.rs create mode 100644 userspace/ksud/src/nomount_tests.rs diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 800d67af7..3c5b39c1d 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -181,6 +181,12 @@ enum Commands { command: Mount, }, + /// Manage the built-in NoMount backend + Nomount { + #[command(subcommand)] + command: Nomount, + }, + /// Manage dynamic managers Dynamic { #[command(subcommand)] @@ -538,6 +544,76 @@ enum Mount { }, } +#[derive(clap::Subcommand, Debug)] +enum Nomount { + /// Print driver state, rule counts, and UID exclusions + Status { + /// Emit a single JSON object instead of key=value lines + #[arg(long)] + json: bool, + }, + + /// Build the rule list from the modules without touching the kernel + Inject { + /// Report what would be applied; never talks to the kernel + #[arg(long)] + dry_run: bool, + /// Emit a single JSON object instead of key=value lines + #[arg(long)] + json: bool, + }, + + /// Show the rules the kernel currently holds + List { + /// Emit a single JSON object instead of text + #[arg(long)] + json: bool, + }, + + /// Drop every redirection rule; UID exclusions are kept + Clear, + + /// Manage the UID exclusions (apps that see the stock filesystem) + Uid { + #[command(subcommand)] + command: NomountUid, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum NomountUid { + /// Show the active and stored UID exclusions + List { + /// Emit a single JSON object instead of text + #[arg(long)] + json: bool, + }, + + /// Replace the stored list with these UIDs, then apply it + Set { + #[arg(required = true)] + uids: Vec, + }, + + /// Add these UIDs to the stored list, then apply it + Add { + #[arg(required = true)] + uids: Vec, + }, + + /// Remove these UIDs from the stored list, then apply it + Del { + #[arg(required = true)] + uids: Vec, + }, + + /// Whether isolated processes also see the stock filesystem + Isolated { + /// `on` or `off` + state: String, + }, +} + #[derive(clap::Subcommand, Debug)] enum Dynamic { /// Print an APK's v2 signature without applying it @@ -925,6 +1001,42 @@ pub fn run() -> Result<()> { Mount::Status { json } => crate::magic_mount::print_status(json), Mount::SetMode { mode } => crate::magic_mount::set_mode(&mode), }, + Commands::Nomount { command } => match command { + Nomount::Status { json } => { + let status = crate::nomount::Status::collect()?; + crate::nomount::log_probe(&status.probe); + status.print(json) + } + Nomount::Inject { dry_run, json } => crate::nomount::inject(dry_run, json), + Nomount::List { json } => crate::nomount::print_rules(json), + Nomount::Clear => crate::nomount::clear_rules(), + Nomount::Uid { command } => match command { + NomountUid::List { json } => crate::nomount::uid_edit(None, &[], json), + NomountUid::Set { uids } => { + crate::nomount::uid_edit(Some(crate::nomount::UidEdit::Set), &uids, false) + } + NomountUid::Add { uids } => { + crate::nomount::uid_edit(Some(crate::nomount::UidEdit::Add), &uids, false) + } + NomountUid::Del { uids } => { + crate::nomount::uid_edit(Some(crate::nomount::UidEdit::Del), &uids, false) + } + NomountUid::Isolated { state } => { + let on = match state.as_str() { + "on" | "1" | "true" => true, + "off" | "0" | "false" => false, + other => { + anyhow::bail!("invalid isolated state {other:?}; expected on or off") + } + }; + crate::nomount::uid_edit( + Some(crate::nomount::UidEdit::Isolated(on)), + &[], + false, + ) + } + }, + }, Commands::Dynamic { command } => match command { Dynamic::GetSign { apk, uid, json } => { crate::dynamic_manager::get_sign(apk.as_deref(), uid, json) diff --git a/userspace/ksud/src/defs.rs b/userspace/ksud/src/defs.rs index 8d2f990b0..f6533aaa0 100644 --- a/userspace/ksud/src/defs.rs +++ b/userspace/ksud/src/defs.rs @@ -63,6 +63,13 @@ mod android { pub const FOLK_MOUNT_RUNTIME: &str = concatcp!(WORKING_DIR, "mount_runtime"); pub const FOLK_MOUNT_LOCK: &str = concatcp!(WORKING_DIR, "mount.lock"); + // Built-in NoMount backend. + /// Per-boot record of the driver state and the injection result. + pub const NOMOUNT_RUNTIME: &str = concatcp!(WORKING_DIR, "nomount_runtime"); + /// UIDs that must see the stock filesystem, one per line. Written by the + /// manager from the app profiles and replayed into the kernel on boot. + pub const NOMOUNT_UIDS: &str = concatcp!(WORKING_DIR, "nomount_uids"); + pub const KSU_BACKUP_DIR: &str = WORKING_DIR; pub const KSU_BACKUP_FILE_PREFIX: &str = "ksu_backup_"; pub const BACKUP_FILENAME: &str = "stock_image.sha1"; diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index 1ecc5c60e..7c064aca9 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -28,6 +28,7 @@ pub enum MountMode { Auto, Builtin, Metamodule, + Nomount, } impl MountMode { @@ -37,6 +38,7 @@ impl MountMode { Self::Auto => "auto", Self::Builtin => "builtin", Self::Metamodule => "metamodule", + Self::Nomount => "nomount", } } @@ -47,6 +49,7 @@ impl MountMode { "auto" => Some(Self::Auto), "builtin" => Some(Self::Builtin), "metamodule" => Some(Self::Metamodule), + "nomount" => Some(Self::Nomount), _ => None, } } @@ -57,6 +60,7 @@ impl MountMode { pub enum Provider { Builtin, Metamodule, + Nomount, } impl Provider { @@ -65,6 +69,7 @@ impl Provider { match self { Self::Builtin => "builtin", Self::Metamodule => "metamodule", + Self::Nomount => "nomount", } } } @@ -84,6 +89,8 @@ pub struct MountOutcome { pub residue: Vec, /// True when the provider ran but published nothing (or had no script). pub noop: bool, + /// Provider-specific degradations that did not stop the injection. + pub warnings: Vec, } impl MountOutcome { @@ -95,6 +102,7 @@ impl MountOutcome { partial: false, residue: Vec::new(), noop: true, + warnings: Vec::new(), } } @@ -133,6 +141,8 @@ struct RuntimeRecord { result: String, target_count: Option, error: Option, + /// Provider-specific degradation that did not stop the injection. + warning: Option, } /// Read the configured mount mode from [`defs::FOLK_MOUNT_CONFIG`]. @@ -219,6 +229,7 @@ fn metamodule_state() -> (Option, bool) { const fn provider_for(mode: MountMode, metamodule_enabled: bool) -> Provider { match mode { MountMode::Metamodule => Provider::Metamodule, + MountMode::Nomount => Provider::Nomount, MountMode::Auto if metamodule_enabled => Provider::Metamodule, MountMode::Builtin | MountMode::Auto => Provider::Builtin, } @@ -256,7 +267,7 @@ fn sanitize(value: &str) -> String { fn write_runtime(record: &RuntimeRecord) -> Result<()> { let contents = format!( - "boot_id={}\nprovider={}\nresult={}\ntarget_count={}\nerror={}\n", + "boot_id={}\nprovider={}\nresult={}\ntarget_count={}\nerror={}\nwarning={}\n", record.boot_id, record.provider.as_deref().unwrap_or(""), record.result, @@ -264,6 +275,7 @@ fn write_runtime(record: &RuntimeRecord) -> Result<()> { .target_count .map_or_else(String::new, |count| count.to_string()), record.error.as_deref().map_or_else(String::new, sanitize), + record.warning.as_deref().map_or_else(String::new, sanitize), ); write_atomic(Path::new(defs::FOLK_MOUNT_RUNTIME), contents.as_bytes()) } @@ -281,6 +293,7 @@ fn read_runtime() -> Option { "result" => record.result = value.to_string(), "target_count" => record.target_count = value.parse().ok(), "error" => record.error = (!value.is_empty()).then(|| value.to_string()), + "warning" => record.warning = (!value.is_empty()).then(|| value.to_string()), _ => {} } } @@ -296,6 +309,7 @@ fn record_skipped() { result: "skipped".to_string(), target_count: None, error: None, + warning: None, }; if let Err(e) = write_runtime(&record) { warn!("folk mount: failed to record skipped state: {e:#}"); @@ -352,7 +366,7 @@ impl MountLock { /// Executor primitives backed by std/std::fs, rustix, extattr and ksucalls. struct AndroidOps; -fn read_opaque(path: &Path) -> Result { +pub fn read_opaque(path: &Path) -> Result { const REPLACE_DIR_XATTR: &str = "trusted.overlay.opaque"; match extattr::lgetxattr(path, REPLACE_DIR_XATTR) { Ok(value) => Ok(String::from_utf8_lossy(&value) == "y"), @@ -599,6 +613,7 @@ fn run_builtin() -> (MountOutcome, Option) { partial: report.outcome.partial, residue: report.outcome.residue, noop: report.error.is_none() && report.outcome.target_count == 0, + warnings: Vec::new(), }; (outcome, report.error) } @@ -614,6 +629,28 @@ fn run_metamodule() -> Result { Ok(outcome) } +/// Run the built-in NoMount backend. +/// +/// The driver is probed first and loaded from the embedded assets when the +/// kernel has no support. A load is never treated as success on its own: the +/// API has to answer afterwards, because the loader rewrites vermagic and +/// relocates symbols and can therefore install a module that does not work. +fn run_nomount() -> Result { + let result = crate::nomount::apply_current_plan()?; + for warning in &result.warnings { + warn!("nomount: {warning}"); + } + Ok(MountOutcome { + provider: Provider::Nomount, + target_count: result.applied, + skipped_count: result.skipped, + partial: false, + residue: Vec::new(), + noop: result.applied == 0, + warnings: result.warnings, + }) +} + /// Android built-in executor entry point. /// /// # Errors @@ -654,6 +691,13 @@ fn run_provider(provider: Provider) -> ProviderRun { ProviderRun::Failed { outcome, error } } }, + Provider::Nomount => match run_nomount() { + Ok(outcome) => ProviderRun::Ok(outcome), + Err(error) => ProviderRun::Failed { + outcome: MountOutcome::failed(Provider::Nomount), + error, + }, + }, } } @@ -696,9 +740,25 @@ pub fn mount_selected_provider() -> Result { result: "mounting".to_string(), target_count: None, error: None, + warning: None, })?; - match run_provider(provider) { + // NoMount is opt-in, so a driver that cannot be made usable must not leave + // the modules unmounted: fall back to the built-in engine and say so. + let (provider, run, fallback) = match run_provider(provider) { + ProviderRun::Failed { error, .. } if provider == Provider::Nomount => { + warn!("nomount: {error:#}; falling back to the built-in engine"); + let note = format!("NoMount unavailable ({error:#}); mounted with the built-in engine"); + ( + Provider::Builtin, + run_provider(Provider::Builtin), + Some(note), + ) + } + run => (provider, run, None), + }; + + match run { ProviderRun::Ok(outcome) => { let result = if outcome.noop { "noop" } else { "mounted" }; log::debug!( @@ -707,6 +767,7 @@ pub fn mount_selected_provider() -> Result { outcome.target_count, outcome.skipped_count ); + let warning = fallback.or_else(|| outcome.warnings.first().cloned()); finish_runtime( &boot_id, provider, @@ -714,6 +775,7 @@ pub fn mount_selected_provider() -> Result { provider == Provider::Builtin, outcome.target_count, None, + warning, ); Ok(outcome) } @@ -734,6 +796,7 @@ pub fn mount_selected_provider() -> Result { provider == Provider::Builtin, outcome.target_count, Some(format!("{error:#}")), + fallback, ); Err(error) } @@ -747,6 +810,7 @@ fn finish_runtime( known_count: bool, target_count: usize, error: Option, + warning: Option, ) { let record = RuntimeRecord { boot_id: boot_id.to_string(), @@ -754,6 +818,7 @@ fn finish_runtime( result: result.to_string(), target_count: known_count.then_some(target_count), error, + warning, }; if let Err(e) = write_runtime(&record) { warn!("folk mount: failed to record runtime state: {e:#}"); @@ -782,7 +847,7 @@ pub fn print_status(json: bool) -> Result<()> { let boot_id = current_boot_id(); let runtime = read_runtime(); - let (boot_id, boot_provider, boot_result, target_count, runtime_error) = + let (boot_id, boot_provider, boot_result, target_count, runtime_error, boot_warning) = match (&runtime, &boot_id) { (Some(record), Some(current)) if &record.boot_id == current => ( Some(record.boot_id.clone()), @@ -794,8 +859,9 @@ pub fn print_status(json: bool) -> Result<()> { }, record.target_count, record.error.clone(), + record.warning.clone(), ), - _ => (None, None, "unknown".to_string(), None, None), + _ => (None, None, "unknown".to_string(), None, None, None), }; let error = config_error.or(runtime_error); @@ -806,7 +872,7 @@ pub fn print_status(json: bool) -> Result<()> { out, "{}", json!({ - "schema_version": 1, + "schema_version": 2, "configured_mode": configured_mode.map(MountMode::as_str), "next_provider": next_provider.map(Provider::as_str), "metamodule_id": metamodule_id, @@ -815,12 +881,13 @@ pub fn print_status(json: bool) -> Result<()> { "boot_provider": boot_provider, "boot_result": boot_result, "target_count": target_count, + "boot_warning": boot_warning, "error": error, }) ) .context("failed to write mount status")?; } else { - writeln!(out, "schema_version=1").context("failed to write mount status")?; + writeln!(out, "schema_version=2").context("failed to write mount status")?; writeln!( out, "configured_mode={}", @@ -856,6 +923,12 @@ pub fn print_status(json: bool) -> Result<()> { target_count.map_or_else(String::new, |count| count.to_string()) ) .context("failed to write mount status")?; + writeln!( + out, + "boot_warning={}", + boot_warning.as_deref().unwrap_or("") + ) + .context("failed to write mount status")?; writeln!(out, "error={}", error.as_deref().unwrap_or("")) .context("failed to write mount status")?; } diff --git a/userspace/ksud/src/main.rs b/userspace/ksud/src/main.rs index cceb18f3e..6ebefdc8f 100644 --- a/userspace/ksud/src/main.rs +++ b/userspace/ksud/src/main.rs @@ -53,6 +53,12 @@ mod module_config; mod module_markers; mod mount_plan; #[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +mod nomount; +#[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +mod nomount_plan; +#[cfg(any(target_os = "android", test))] mod plugin; #[cfg(target_os = "android")] mod profile; diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs new file mode 100644 index 000000000..70a60828c --- /dev/null +++ b/userspace/ksud/src/nomount.rs @@ -0,0 +1,1248 @@ +//! NoMount backend: driver transport, probing and rule application. +//! +//! NoMount is a VFS path redirection module: instead of mounting anything it +//! hijacks lookups and directory iteration, so ksud talks to it through a +//! single custom key type. Every command is one `add_key()` call whose payload +//! is a page-aligned 4096-byte structure the kernel reads and writes back in +//! place; `add_key` itself always fails, so the answer is the `status` field. +//! +//! The protocol is pinned to the vendored module revision: see +//! `userspace/nomount/UPSTREAM.md` for provenance and the payload layout this +//! module encodes. Nothing here is derived from upstream userspace code, which +//! is deliberately not vendored. + +use std::fmt::Write as _; +use std::io::Write; + +use anyhow::{Context, Result, bail, ensure}; + +/// Kernel/userspace payload size; the kernel maps exactly one page. +pub const PAGE_SIZE: usize = 4096; + +/// Magic stamped into every payload (`"NOMOUNT"` as a big-endian word). +pub const MAGIC: u64 = u64::from_be_bytes(*b"\0NOMOUNT"); + +/// Bytes of the payload available to command data. +pub const BUFFER_SIZE: usize = PAGE_SIZE - PAYLOAD_HEADER_SIZE; + +const PAYLOAD_HEADER_SIZE: usize = 28; + +/// Oldest kernel API this backend accepts. The vendored module (v2.1.1) +/// reports `21`; anything lower predates the payload this code encodes. +pub const MIN_API_VERSION: u32 = 21; + +/// Base name of the module inside the embedded assets. +pub const ASSET_SUFFIX: &str = "_nomount.ko"; + +/// Whiteout marker: a character device with `rdev == 0`. +pub const WHITEOUT_FLAG: u32 = 1 << 2; + +/// Driver state as reported by [`probe`], never inferred from a load attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverState { + /// The key type is not registered: no built-in support and no module loaded. + Absent, + /// The API answered but its version is below [`MIN_API_VERSION`]. + Outdated, + /// The API answered and is usable. + Ready, +} + +impl DriverState { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Absent => "absent", + Self::Outdated => "outdated", + Self::Ready => "ready", + } + } +} + +/// A probe result: the driver state plus the version string when one was read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriverProbe { + pub state: DriverState, + pub version: Option, +} + +impl DriverProbe { + #[must_use] + pub const fn ready(&self) -> bool { + matches!(self.state, DriverState::Ready) + } +} + +/// Commands understood by the kernel side. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum Command { + GetVersion = 1, + AddRule = 2, + DelRule = 3, + AddUid = 4, + DelUid = 5, + ClearRules = 7, + GetList = 9, + GetUids = 10, + BlockIsolatedUids = 11, + GetIsolatedState = 12, +} + +/// One redirection: `real_path` is served at `virtual_path`, or the path is +/// hidden when `whiteout` is set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub virtual_path: String, + pub real_path: Option, + pub whiteout: bool, + pub uid: u32, +} + +impl Rule { + #[must_use] + pub fn whiteout(virtual_path: impl Into) -> Self { + Self { + virtual_path: virtual_path.into(), + real_path: None, + whiteout: true, + uid: 0, + } + } + + #[must_use] + pub fn redirect(virtual_path: impl Into, real_path: impl Into) -> Self { + Self { + virtual_path: virtual_path.into(), + real_path: Some(real_path.into()), + whiteout: false, + uid: 0, + } + } +} + +/// Wire layout shared with the kernel module. Packed and page-sized on purpose: +/// the kernel rejects a payload that straddles two pages. +#[repr(C, packed)] +pub struct Payload { + pub magic: u64, + pub cmd: u32, + pub target_uid: u32, + pub status: i32, + pub arg1: u32, + pub data_size: u32, + pub buffer: [u8; BUFFER_SIZE], +} + +const _: () = assert!(size_of::() == PAGE_SIZE); + +/// A zeroed, page-aligned payload. +/// +/// The kernel maps exactly the page the payload starts on and writes results +/// back through it, so the payload has to be aligned and writable. Keeping it +/// on the stack also makes construction infallible. +#[repr(C, align(4096))] +pub struct PageBuffer { + bytes: [u8; PAGE_SIZE], +} + +impl PageBuffer { + #[must_use] + pub const fn zeroed() -> Self { + Self { + bytes: [0; PAGE_SIZE], + } + } + + #[must_use] + pub const fn as_mut(&mut self) -> &mut Payload { + // SAFETY: `bytes` is page aligned and `PAGE_SIZE` bytes long, which is + // exactly the size of `Payload`, so the cast is layout-correct. + unsafe { &mut *self.bytes.as_mut_ptr().cast::() } + } + + #[must_use] + pub fn data_size(&self) -> u32 { + // Volatile: the kernel produced this value through its own mapping. + // SAFETY: the payload pointer is valid and `data_size` is a plain field. + unsafe { std::ptr::read_volatile(std::ptr::addr_of!((*self.as_ptr()).data_size)) } + } + + const fn as_ptr(&self) -> *const Payload { + self.bytes.as_ptr().cast() + } + + /// Copy `len` bytes of command data the kernel wrote into `buffer`. + /// + /// # Errors + /// Returns an error when `len` exceeds the payload buffer. + pub fn data(&self, len: usize) -> Result> { + ensure!( + len <= BUFFER_SIZE, + "nomount: kernel reported {len} bytes, buffer holds {BUFFER_SIZE}" + ); + // SAFETY: `buffer` lives inside the payload and `len` was bounded above. + let src = unsafe { std::ptr::addr_of!((*self.as_ptr()).buffer).cast::() }; + Ok(unsafe { std::slice::from_raw_parts(src, len) }.to_vec()) + } +} + +/// One command round trip. Split out so the encoding and the command semantics +/// can be tested without a kernel. +pub trait Transport { + /// Send `payload` and wait for the kernel's answer. + /// + /// # Errors + /// Returns an error when the payload cannot be delivered or the kernel + /// reports a non-zero status. + fn send(&self, payload: &mut Payload) -> Result<()>; +} + +/// The real transport: one `add_key()` against the `nomount` key type. +pub struct KernelTransport; + +impl Transport for KernelTransport { + fn send(&self, payload: &mut Payload) -> Result<()> { + payload.status = -1; + let address = std::ptr::from_mut::(payload) as u64; + // `add_key` is the whole protocol: the key type's `preparse` reads the + // address, runs the command and returns `-ECANCELED`, so the syscall's + // own return value carries no information. + unsafe { + libc::syscall( + libc::SYS_add_key, + c"nomount".as_ptr(), + c"trigger".as_ptr(), + std::ptr::addr_of!(address), + 8_usize, + -1_isize, + ); + } + // Copy the fields out: `Payload` is packed, so only value reads are + // sound, never references to its fields. + let status = unsafe { std::ptr::read_volatile(std::ptr::addr_of!(payload.status)) }; + let cmd = payload.cmd; + ensure!(status == 0, "nomount: command {cmd} failed with {status}"); + Ok(()) + } +} + +/// A driver handle bound to a transport. +pub struct Driver { + transport: T, +} + +impl Driver { + #[must_use] + pub const fn kernel() -> Self { + Self { + transport: KernelTransport, + } + } +} + +impl Driver { + #[cfg(test)] + #[must_use] + pub const fn new(transport: T) -> Self { + Self { transport } + } + + /// Ask the kernel for its API version. + /// + /// # Errors + /// Returns an error when the key type is not registered or the reply is + /// not valid UTF-8. + pub fn version(&self) -> Result { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = Command::GetVersion as u32; + self.transport.send(page.as_mut())?; + let len = usize::try_from(page.data_size()).context("nomount: bad version length")?; + let bytes = page.data(len)?; + String::from_utf8(bytes).context("nomount: version reply is not UTF-8") + } + + /// Probe the driver without changing any state. + /// + /// A version reply alone is not enough: the listing command also has to + /// answer, otherwise a half-registered key type would look healthy. + #[must_use] + pub fn probe(&self) -> DriverProbe { + let version = match self.version() { + Ok(version) => version, + Err(error) => { + log::debug!("nomount: driver absent ({error:#})"); + return DriverProbe { + state: DriverState::Absent, + version: None, + }; + } + }; + let state = match parse_api_version(&version) { + Some(api) if api >= MIN_API_VERSION => DriverState::Ready, + _ => DriverState::Outdated, + }; + if state == DriverState::Ready + && let Err(error) = self.list_rules() + { + log::debug!("nomount: listing refused ({error:#}); driver not ready"); + return DriverProbe { + state: DriverState::Outdated, + version: Some(version), + }; + } + DriverProbe { + state, + version: Some(version), + } + } + + /// Apply rules, then read the kernel's rule list back to prove they landed. + /// + /// The verification is the point: a transport that silently drops rules + /// must not be reported as a successful injection. + /// + /// # Errors + /// Returns an error when a rule cannot be encoded, the kernel refuses a + /// batch, or the read-back does not contain every virtual path. + pub fn apply_rules(&self, rules: &[Rule]) -> Result { + if rules.is_empty() { + return Ok(0); + } + for batch in encode_batches(rules)? { + self.command(Command::AddRule, &batch)?; + } + let applied: std::collections::HashSet = self + .list_rules()? + .into_iter() + .map(|rule| rule.virtual_path) + .collect(); + let missing: Vec<&str> = rules + .iter() + .map(|rule| rule.virtual_path.as_str()) + .filter(|path| !applied.contains(*path)) + .collect(); + ensure!( + missing.is_empty(), + "nomount: kernel did not accept {} rule(s), first missing: {}", + missing.len(), + missing[0] + ); + Ok(rules.len()) + } + + /// The rules currently active in the kernel. + /// + /// # Errors + /// Returns an error when the reply is malformed or the kernel refuses the + /// command. + pub fn list_rules(&self) -> Result> { + let mut rules = Vec::new(); + loop { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = Command::GetList as u32; + page.as_mut().arg1 = u32::try_from(rules.len()).context("nomount: rule overflow")?; + self.transport.send(page.as_mut())?; + let len = usize::try_from(page.data_size()).context("nomount: bad list length")?; + if len == 0 { + return Ok(rules); + } + let batch = parse_list_records(&page.data(len)?)?; + let count = batch.len(); + rules.extend(batch); + if count == 0 { + return Ok(rules); + } + } + } + + /// Remove specific virtual paths. + /// + /// # Errors + /// Returns an error when a path is too long or the kernel refuses a batch. + pub fn delete_rules(&self, virtual_paths: &[String]) -> Result<()> { + if virtual_paths.is_empty() { + return Ok(()); + } + let mut page = PageBuffer::zeroed(); + let mut cursor = 0_usize; + for path in virtual_paths { + let len = u16::try_from(path.len()).context("nomount: path too long")?; + let record = 6 + path.len(); + ensure!( + record <= BUFFER_SIZE, + "nomount: {path} does not fit in one payload" + ); + if cursor + record > BUFFER_SIZE { + self.flush(Command::DelRule, &mut page, cursor)?; + cursor = 0; + } + let buffer = &mut page.as_mut().buffer; + buffer[cursor..cursor + 4].copy_from_slice(&0_u32.to_le_bytes()); + buffer[cursor + 4..cursor + 6].copy_from_slice(&len.to_le_bytes()); + buffer[cursor + 6..cursor + 6 + path.len()].copy_from_slice(path.as_bytes()); + cursor += record; + } + self.flush(Command::DelRule, &mut page, cursor) + } + + /// Block or unblock isolated-process UIDs. + /// + /// # Errors + /// Returns an error when the kernel refuses the command. + pub fn block_isolated(&self, on: bool) -> Result<()> { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = Command::BlockIsolatedUids as u32; + page.as_mut().arg1 = u32::from(on); + self.transport.send(page.as_mut()) + } + + /// Isolated-process blocking state. + /// + /// # Errors + /// Returns an error when the kernel refuses the command. + pub fn isolated_state(&self) -> Result { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = Command::GetIsolatedState as u32; + self.transport.send(page.as_mut())?; + let len = usize::try_from(page.data_size()).context("nomount: bad state length")?; + Ok(page.data(len)?.first() == Some(&b'1')) + } + + /// Add UIDs to the exclusion list (they see the stock filesystem). + /// + /// # Errors + /// Returns an error when the kernel refuses a UID. + pub fn add_uids(&self, uids: &[u32]) -> Result<()> { + self.each_uid(Command::AddUid, uids) + } + + /// Remove UIDs from the exclusion list. + /// + /// # Errors + /// Returns an error when the kernel refuses a UID. + pub fn del_uids(&self, uids: &[u32]) -> Result<()> { + self.each_uid(Command::DelUid, uids) + } + + /// The UIDs currently excluded from redirection. + /// + /// # Errors + /// Returns an error when the reply is malformed. + pub fn list_uids(&self) -> Result> { + let mut uids = Vec::new(); + loop { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = Command::GetUids as u32; + page.as_mut().arg1 = u32::try_from(uids.len()).context("nomount: uid overflow")?; + self.transport.send(page.as_mut())?; + let len = usize::try_from(page.data_size()).context("nomount: bad uid length")?; + if len < 4 { + return Ok(uids); + } + let bytes = page.data(len)?; + let count = bytes.len() / 4; + let mut index = 0; + while index + 4 <= bytes.len() { + uids.push(u32::from_le_bytes([ + bytes[index], + bytes[index + 1], + bytes[index + 2], + bytes[index + 3], + ])); + index += 4; + } + if count == 0 { + return Ok(uids); + } + } + } + + /// Drop every redirection rule, keeping UID exclusions. + /// + /// # Errors + /// Returns an error when the kernel refuses the command. + pub fn clear_rules(&self) -> Result<()> { + self.command(Command::ClearRules, &[]) + } + + /// Make the kernel's rules match `rules` exactly. + /// + /// Stale rules from an earlier module state are removed, so re-running the + /// injection for the same boot converges instead of accumulating paths. + /// + /// # Errors + /// Returns an error when the kernel refuses a change or the read-back does + /// not contain every wanted path. + pub fn reconcile_rules(&self, rules: &[Rule]) -> Result<(usize, usize)> { + let wanted: std::collections::HashSet<&str> = rules + .iter() + .map(|rule| rule.virtual_path.as_str()) + .collect(); + let stale: Vec = self + .list_rules()? + .into_iter() + .map(|rule| rule.virtual_path) + .filter(|path| !wanted.contains(path.as_str())) + .collect(); + let removed = stale.len(); + if !stale.is_empty() { + self.delete_rules(&stale)?; + } + let applied = self.apply_rules(rules)?; + Ok((applied, removed)) + } + + fn each_uid(&self, command: Command, uids: &[u32]) -> Result<()> { + for uid in uids { + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = command as u32; + page.as_mut().target_uid = *uid; + self.transport.send(page.as_mut())?; + } + Ok(()) + } + + fn command(&self, command: Command, data: &[u8]) -> Result<()> { + ensure!(data.len() <= BUFFER_SIZE, "nomount: command data too large"); + let mut page = PageBuffer::zeroed(); + page.as_mut().magic = MAGIC; + page.as_mut().cmd = command as u32; + page.as_mut().buffer[..data.len()].copy_from_slice(data); + page.as_mut().data_size = u32::try_from(data.len()).context("nomount: data too large")?; + self.transport.send(page.as_mut()) + } + + fn flush(&self, command: Command, page: &mut PageBuffer, len: usize) -> Result<()> { + if len == 0 { + return Ok(()); + } + // Stamp the magic here so no batching path can forget it: the kernel + // rejects a payload without it. + page.as_mut().magic = MAGIC; + page.as_mut().cmd = command as u32; + page.as_mut().data_size = u32::try_from(len).context("nomount: batch too large")?; + page.as_mut().arg1 = 0; + self.transport.send(page.as_mut()) + } +} + +/// Parse the leading integer of a version string (`"21"`, `"21 (1234)"`). +#[must_use] +pub fn parse_api_version(version: &str) -> Option { + let digits: String = version + .trim() + .chars() + .take_while(char::is_ascii_digit) + .collect(); + digits.parse().ok() +} + +/// Encode rules into wire batches, preserving input order. +/// +/// # Errors +/// Returns an error when a rule has no target, or a path does not fit the +/// payload buffer. +pub fn encode_batches(rules: &[Rule]) -> Result>> { + let mut batches = Vec::new(); + let mut current = Vec::new(); + for rule in rules { + let real = rule.real_path.as_deref().unwrap_or_default(); + if rule.whiteout { + ensure!( + rule.real_path.is_none(), + "nomount: a whiteout rule must not carry a target" + ); + } else { + ensure!( + !real.is_empty(), + "nomount: rule for {} has no target", + rule.virtual_path + ); + } + let v_len = u16::try_from(rule.virtual_path.len()).context("nomount: path too long")?; + let r_len = u16::try_from(real.len()).context("nomount: target too long")?; + let record = 12 + rule.virtual_path.len() + real.len(); + ensure!( + record <= BUFFER_SIZE, + "nomount: rule for {} does not fit in one payload", + rule.virtual_path + ); + if current.len() + record > BUFFER_SIZE { + batches.push(std::mem::take(&mut current)); + } + let flags = if rule.whiteout { WHITEOUT_FLAG } else { 0 }; + current.extend_from_slice(&flags.to_le_bytes()); + current.extend_from_slice(&rule.uid.to_le_bytes()); + current.extend_from_slice(&v_len.to_le_bytes()); + current.extend_from_slice(&r_len.to_le_bytes()); + current.extend_from_slice(rule.virtual_path.as_bytes()); + current.extend_from_slice(real.as_bytes()); + } + if !current.is_empty() { + batches.push(current); + } + Ok(batches) +} + +/// Decode the kernel's rule list reply. +/// +/// # Errors +/// Returns an error when a record is truncated or not valid UTF-8. +pub fn parse_list_records(data: &[u8]) -> Result> { + let mut rules = Vec::new(); + let mut cursor = 0_usize; + while cursor + 12 <= data.len() { + let header = &data[cursor..cursor + 12]; + let flags = u32::from_le_bytes([header[0], header[1], header[2], header[3]]); + let uid = u32::from_le_bytes([header[4], header[5], header[6], header[7]]); + let v_len = usize::from(u16::from_le_bytes([header[8], header[9]])); + let r_len = usize::from(u16::from_le_bytes([header[10], header[11]])); + let end = cursor + 12 + v_len + r_len; + ensure!(end <= data.len(), "nomount: truncated rule record"); + let virtual_path = String::from_utf8(data[cursor + 12..cursor + 12 + v_len].to_vec()) + .context("nomount: rule path is not UTF-8")?; + let real_path = if r_len == 0 { + None + } else { + Some( + String::from_utf8(data[cursor + 12 + v_len..end].to_vec()) + .context("nomount: rule target is not UTF-8")?, + ) + }; + rules.push(Rule { + virtual_path, + real_path, + whiteout: flags & WHITEOUT_FLAG != 0, + uid, + }); + cursor = end; + } + ensure!( + cursor == data.len(), + "nomount: trailing bytes after the last rule record" + ); + Ok(rules) +} + +/// Parse a `/proc/modules` listing for the vendored module. +#[must_use] +pub fn module_loaded(proc_modules: &str) -> bool { + proc_modules + .lines() + .any(|line| line.split_whitespace().next() == Some("nomount")) +} + +/// Fail closed when a load attempt produced no usable API. +/// +/// A module can load and still be unusable: ksud's loader rewrites vermagic and +/// relocates symbols, so a mismatched build may install itself without +/// implementing what this code talks to. +/// +/// # Errors +/// Returns an error describing the unusable state. +pub fn ensure_usable(probe: &DriverProbe) -> Result<()> { + match probe.state { + DriverState::Ready => Ok(()), + DriverState::Absent => bail!("nomount: driver did not answer after loading"), + DriverState::Outdated => bail!( + "nomount: driver is {} but this build needs API {MIN_API_VERSION}; refusing to inject", + probe.version.as_deref().unwrap_or("unknown") + ), + } +} + +/// Log helper used by the CLI so operators can see what was parsed. +pub fn log_probe(probe: &DriverProbe) { + log::debug!( + "nomount: driver={} version={}", + probe.state.as_str(), + probe.version.as_deref().unwrap_or("-") + ); +} + +// --------------------------------------------------------------------------- +// CLI entry points +// --------------------------------------------------------------------------- + +/// Rules the module directory currently asks for. +/// +/// # Errors +/// Returns an error when the module tree cannot be walked. +#[cfg(target_os = "android")] +pub fn collect_plan() -> Result { + let cfg = crate::nomount_plan::PlanConfig::new( + std::path::Path::new(crate::defs::MODULE_DIR), + std::path::Path::new("/"), + std::path::Path::new("/system"), + ); + let fs = crate::nomount_plan::StdFs::new( + crate::magic_mount::read_opaque as fn(&std::path::Path) -> Result, + ); + crate::nomount_plan::collect_rules(&fs, &cfg) +} + +/// What the manager wants the visibility policy to be. +/// +/// The kernel holds the active state; this is the desired state, persisted so +/// it survives a reboot and so ksud does not have to enumerate app profiles. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UidConfig { + /// Apps that must see the stock filesystem. + pub uids: Vec, + /// Whether isolated processes see the stock filesystem too. + pub block_isolated: bool, +} + +/// Read the stored visibility policy: bare UIDs plus an optional +/// `isolated=` directive; blank lines and `#` comments are ignored. +/// +/// # Errors +/// Returns an error when the file exists but cannot be read, contains a +/// non-numeric UID, or carries an unrecognised directive. +pub fn read_uid_config(path: &std::path::Path) -> Result { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(UidConfig::default()); + } + Err(error) => { + return Err(error).with_context(|| format!("read {}", path.display())); + } + }; + let mut uids = Vec::new(); + let mut block_isolated = false; + for line in contents.lines() { + let entry = line.trim(); + if entry.is_empty() || entry.starts_with('#') { + continue; + } + if let Some(value) = entry.strip_prefix("isolated=") { + block_isolated = match value { + "on" | "1" | "true" => true, + "off" | "0" | "false" => false, + other => bail!("invalid isolated directive {other:?} in {}", path.display()), + }; + continue; + } + let uid: u32 = entry + .parse() + .with_context(|| format!("invalid uid {entry:?} in {}", path.display()))?; + uids.push(uid); + } + uids.sort_unstable(); + uids.dedup(); + Ok(UidConfig { + uids, + block_isolated, + }) +} + +/// Replace the stored visibility policy. +/// +/// # Errors +/// Returns an error when the file cannot be written. +pub fn write_uid_config(path: &std::path::Path, config: &UidConfig) -> Result<()> { + let mut body = String::new(); + if config.block_isolated { + body.push_str("isolated=on\n"); + } + for uid in &config.uids { + let _ = writeln!(body, "{uid}"); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(path, body).with_context(|| format!("write {}", path.display())) +} + +/// Make the kernel's visibility policy match `wanted`, and report the change. +/// +/// The kernel list is the source of truth for what is active, so the diff is +/// computed against it rather than against a cached guess. +/// +/// # Errors +/// Returns an error when the kernel refuses a change or the list cannot be read. +pub fn sync_uids(driver: &Driver, wanted: &UidConfig) -> Result<(usize, usize)> { + let current = driver.list_uids()?; + let added: Vec = wanted + .uids + .iter() + .copied() + .filter(|uid| !current.contains(uid)) + .collect(); + let removed: Vec = current + .iter() + .copied() + .filter(|uid| !wanted.uids.contains(uid)) + .collect(); + if !added.is_empty() { + driver.add_uids(&added)?; + } + if !removed.is_empty() { + driver.del_uids(&removed)?; + } + if driver.isolated_state()? != wanted.block_isolated { + driver.block_isolated(wanted.block_isolated)?; + } + Ok((added.len(), removed.len())) +} + +/// Per-boot NoMount record, read back by `ksud nomount status` and the manager. +#[derive(Debug, Clone, Default)] +pub struct RuntimeRecord { + pub boot_id: String, + pub driver_state: String, + pub driver_version: String, + pub rules_applied: usize, + pub rules_removed: usize, + pub uid_count: usize, + pub isolated_blocked: bool, + pub error: Option, +} + +/// Persist the per-boot NoMount record. +/// +/// # Errors +/// Returns an error when the record cannot be written. +pub fn write_runtime_record(path: &std::path::Path, record: &RuntimeRecord) -> Result<()> { + let mut body = String::new(); + let _ = writeln!(body, "boot_id={}", record.boot_id); + let _ = writeln!(body, "driver_state={}", record.driver_state); + let _ = writeln!(body, "driver_version={}", record.driver_version); + let _ = writeln!(body, "rules_applied={}", record.rules_applied); + let _ = writeln!(body, "rules_removed={}", record.rules_removed); + let _ = writeln!(body, "uid_count={}", record.uid_count); + let _ = writeln!(body, "isolated_blocked={}", record.isolated_blocked); + let _ = writeln!( + body, + "error={}", + record.error.as_deref().unwrap_or("").replace('\n', " ") + ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(path, body).with_context(|| format!("write {}", path.display())) +} + +/// Current boot id, used to tell this boot's record from a stale one. +#[must_use] +pub fn current_boot_id() -> String { + std::fs::read_to_string("/proc/sys/kernel/random/boot_id") + .map_or_else(|_| String::new(), |value| value.trim().to_string()) +} + +/// Outcome of one injection round, mirroring the per-boot record. +#[derive(Debug, Clone)] +pub struct InjectionResult { + pub driver_state: String, + pub driver_version: String, + pub applied: usize, + pub removed: usize, + pub skipped: usize, + pub uid_count: usize, + pub isolated_blocked: bool, + pub warnings: Vec, +} + +/// Probe (loading the embedded module when needed), apply the current module +/// rules and sync the stored visibility policy. +/// +/// Every step that can silently degrade is either recorded or returned as a +/// warning, so callers never have to guess whether the injection happened. +/// +/// # Errors +/// Returns an error when the driver cannot be made usable, the module tree +/// cannot be walked, or the kernel refuses the rules. +#[cfg(target_os = "android")] +pub fn apply_current_plan() -> Result { + let driver = Driver::kernel(); + let (probe, loaded) = probe_or_load()?; + if let Some(loaded) = loaded { + log::info!("nomount: driver loaded from {loaded}"); + } + let plan = collect_plan()?; + let (applied, removed) = driver.reconcile_rules(&plan.rules)?; + + let mut warnings = Vec::new(); + let mut uid_count = 0_usize; + let mut isolated_blocked = false; + match apply_stored_uids() { + Ok((added, dropped)) => { + log::info!("nomount: uid exclusions +{added}/-{dropped}"); + uid_count = driver.list_uids().map_or(0, |uids| uids.len()); + isolated_blocked = driver.isolated_state().unwrap_or(false); + } + Err(error) => { + // Without the exclusions, apps that should see the stock filesystem + // can observe injected files, so this is surfaced, not swallowed. + warnings.push(format!("UID exclusions not applied: {error:#}")); + } + } + + let record = RuntimeRecord { + boot_id: current_boot_id(), + driver_state: probe.state.as_str().to_string(), + driver_version: probe.version.clone().unwrap_or_default(), + rules_applied: applied, + rules_removed: removed, + uid_count, + isolated_blocked, + error: warnings.first().cloned(), + }; + if let Err(error) = + write_runtime_record(std::path::Path::new(crate::defs::NOMOUNT_RUNTIME), &record) + { + warnings.push(format!("runtime record not written: {error:#}")); + } + + Ok(InjectionResult { + driver_state: probe.state.as_str().to_string(), + driver_version: probe.version.unwrap_or_default(), + applied, + removed, + skipped: plan.skipped.len(), + uid_count, + isolated_blocked, + warnings, + }) +} + +/// Status reported by `ksud nomount status`. +#[derive(Debug, Clone)] +pub struct Status { + pub probe: DriverProbe, + pub expected_rules: usize, + pub applied_rules: usize, + pub uid_count: usize, + pub isolated_blocked: bool, + pub error: Option, +} + +impl Status { + /// # Errors + /// Returns an error when the module tree cannot be walked. + #[cfg(target_os = "android")] + pub fn collect() -> Result { + let driver = Driver::kernel(); + let probe = driver.probe(); + let plan = collect_plan()?; + let mut status = Self { + probe, + expected_rules: plan.rules.len(), + applied_rules: 0, + uid_count: 0, + isolated_blocked: false, + error: None, + }; + if !status.probe.ready() { + status.error = Some("driver is not ready".to_string()); + return Ok(status); + } + status.applied_rules = driver.list_rules().map_or(0, |rules| rules.len()); + status.uid_count = driver.list_uids().map_or(0, |uids| uids.len()); + status.isolated_blocked = driver.isolated_state().unwrap_or(false); + Ok(status) + } + + /// # Errors + /// Returns an error when writing to stdout fails. + pub fn print(&self, json: bool) -> Result<()> { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "driver_state": self.probe.state.as_str(), + "driver_version": self.probe.version, + "api_ok": self.probe.ready(), + "rules_expected": self.expected_rules, + "rules_applied": self.applied_rules, + "uid_count": self.uid_count, + "isolated_blocked": self.isolated_blocked, + "error": self.error, + }); + writeln!(out, "{payload}").context("failed to write nomount status")?; + } else { + writeln!(out, "schema_version=1").context("failed to write nomount status")?; + writeln!(out, "driver_state={}", self.probe.state.as_str()) + .context("failed to write nomount status")?; + writeln!( + out, + "driver_version={}", + self.probe.version.as_deref().unwrap_or("") + ) + .context("failed to write nomount status")?; + writeln!(out, "api_ok={}", self.probe.ready()) + .context("failed to write nomount status")?; + writeln!(out, "rules_expected={}", self.expected_rules) + .context("failed to write nomount status")?; + writeln!(out, "rules_applied={}", self.applied_rules) + .context("failed to write nomount status")?; + writeln!(out, "uid_count={}", self.uid_count) + .context("failed to write nomount status")?; + writeln!(out, "isolated_blocked={}", self.isolated_blocked) + .context("failed to write nomount status")?; + writeln!(out, "error={}", self.error.as_deref().unwrap_or("")) + .context("failed to write nomount status")?; + } + out.flush().context("failed to flush nomount status")?; + Ok(()) + } +} + +/// Apply the current module rules, optionally without touching the kernel. +/// +/// A dry run reports what the modules ask for; the real run reconciles the +/// kernel state and only reports success when the rules landed. +/// +/// # Errors +/// Returns an error when the plan cannot be built or the driver refuses the +/// rules; a refused injection is never reported as success. +#[cfg(target_os = "android")] +pub fn inject(dry_run: bool, json: bool) -> Result<()> { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + if dry_run { + let plan = collect_plan()?; + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "dry_run": true, + "rules": plan.rules.len(), + "skipped": plan.skipped.len(), + }); + writeln!(out, "{payload}").context("failed to write dry run")?; + } else { + writeln!(out, "rules={}", plan.rules.len()).context("failed to write dry run")?; + for (id, reason) in &plan.skipped { + writeln!(out, "skipped={id} ({reason})").context("failed to write dry run")?; + } + } + return out.flush().context("failed to flush dry run"); + } + + let result = apply_current_plan()?; + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "driver_state": result.driver_state, + "driver_version": result.driver_version, + "rules_applied": result.applied, + "rules_removed": result.removed, + "skipped": result.skipped, + "uid_count": result.uid_count, + "isolated_blocked": result.isolated_blocked, + "warnings": result.warnings, + }); + writeln!(out, "{payload}").context("failed to write injection result")?; + } else { + writeln!( + out, + "rules_applied={} rules_removed={} uid_count={}", + result.applied, result.removed, result.uid_count + ) + .context("failed to write injection result")?; + for warning in &result.warnings { + writeln!(out, "warning={warning}").context("failed to write injection result")?; + } + } + out.flush().context("failed to flush injection result") +} + +/// Drop every rule but keep UID exclusions. +/// +/// # Errors +/// Returns an error when the kernel refuses the command. +pub fn clear_rules() -> Result<()> { + Driver::kernel().clear_rules() +} + +/// Print the rules the kernel currently holds. +/// +/// # Errors +/// Returns an error when the kernel refuses the command or writing fails. +pub fn print_rules(json: bool) -> Result<()> { + let rules = Driver::kernel().list_rules()?; + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "rules": rules + .iter() + .map(|rule| serde_json::json!({ + "virtual": rule.virtual_path, + "real": rule.real_path, + "whiteout": rule.whiteout, + "uid": rule.uid, + })) + .collect::>(), + }); + writeln!(out, "{payload}").context("failed to write rules")?; + } else { + for rule in &rules { + if rule.whiteout { + writeln!(out, "{} (whiteout)", rule.virtual_path) + } else { + writeln!( + out, + "{} -> {}", + rule.virtual_path, + rule.real_path.as_deref().unwrap_or("-") + ) + } + .context("failed to write rules")?; + } + } + out.flush().context("failed to flush rules") +} + +/// Make the kernel's UID exclusions match the stored list. +/// +/// # Errors +/// Returns an error when the list cannot be read or the kernel refuses a change. +#[cfg(target_os = "android")] +pub fn apply_stored_uids() -> Result<(usize, usize)> { + let wanted = read_uid_config(std::path::Path::new(crate::defs::NOMOUNT_UIDS))?; + sync_uids(&Driver::kernel(), &wanted) +} + +/// Load the embedded NoMount module for the running KMI. +/// +/// The loader rewrites vermagic and relocates symbols, so a successful load is +/// not proof of a working driver: callers must probe afterwards. +/// +/// # Errors +/// Returns an error when the KMI cannot be determined, this build carries no +/// module for it, or the loader refuses the module. +#[cfg(target_os = "android")] +pub fn load_embedded_driver() -> Result { + if module_loaded(&std::fs::read_to_string("/proc/modules").unwrap_or_default()) { + return Ok("already loaded".to_string()); + } + let kmi = crate::boot_patch::get_current_kmi() + .context("nomount: cannot determine the running KMI")?; + let name = format!("{kmi}{ASSET_SUFFIX}"); + let data = crate::assets::get_asset_data(&name) + .with_context(|| format!("nomount: this build has no module for {kmi}"))?; + let params = std::ffi::CString::new("").context("nomount: empty module parameters")?; + ksuinit::load_module(&data, ¶ms) + .with_context(|| format!("nomount: loading {name} failed"))?; + log::info!("nomount: loaded {name}"); + Ok(name) +} + +/// Probe the driver, loading the embedded module first when there is none. +/// +/// # Errors +/// Returns an error when a load is needed and fails, or when the driver answers +/// with an unusable API. +#[cfg(target_os = "android")] +pub fn probe_or_load() -> Result<(DriverProbe, Option)> { + let driver = Driver::kernel(); + let first = driver.probe(); + if first.ready() { + return Ok((first, None)); + } + let loaded = load_embedded_driver()?; + let second = driver.probe(); + ensure_usable(&second)?; + Ok((second, Some(loaded))) +} + +/// How a `ksud nomount uid` subcommand edits the stored list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UidEdit { + Set, + Add, + Del, + Isolated(bool), +} + +/// Edit the stored UID list and apply it to the kernel. +/// +/// # Errors +/// Returns an error when the stored list cannot be read or written, or when the +/// kernel refuses a change. +#[cfg(target_os = "android")] +pub fn uid_edit(mode: Option, uids: &[u32], json: bool) -> Result<()> { + let driver = Driver::kernel(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let Some(edit) = mode else { + return print_uid_state(&driver, json, &mut out); + }; + + let path = std::path::Path::new(crate::defs::NOMOUNT_UIDS); + let mut config = read_uid_config(path)?; + match edit { + UidEdit::Set => config.uids = uids.to_vec(), + UidEdit::Add => config.uids.extend_from_slice(uids), + UidEdit::Del => config.uids.retain(|uid| !uids.contains(uid)), + UidEdit::Isolated(state) => config.block_isolated = state, + } + config.uids.sort_unstable(); + config.uids.dedup(); + write_uid_config(path, &config)?; + let (added, removed) = sync_uids(&driver, &config)?; + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "stored": config.uids, + "isolated_blocked": config.block_isolated, + "added": added, + "removed": removed, + }); + writeln!(out, "{payload}").context("failed to write uid result")?; + } else { + writeln!( + out, + "stored={} isolated_blocked={} added={added} removed={removed}", + config.uids.len(), + config.block_isolated + ) + .context("failed to write uid result")?; + } + out.flush().context("failed to flush uid result") +} + +#[cfg(target_os = "android")] +fn print_uid_state( + driver: &Driver, + json: bool, + out: &mut impl Write, +) -> Result<()> { + let active = driver.list_uids()?; + let stored = read_uid_config(std::path::Path::new(crate::defs::NOMOUNT_UIDS))?; + if json { + let payload = serde_json::json!({ + "schema_version": 1, + "active": active, + "stored": stored.uids, + "isolated_blocked": driver.isolated_state().unwrap_or(false), + }); + writeln!(out, "{payload}").context("failed to write uid list")?; + } else { + writeln!(out, "active={}", join_uids(&active)).context("failed to write uid list")?; + writeln!(out, "stored={}", join_uids(&stored.uids)).context("failed to write uid list")?; + } + out.flush().context("failed to flush uid list") +} + +fn join_uids(uids: &[u32]) -> String { + uids.iter() + .map(u32::to_string) + .collect::>() + .join(",") +} + +#[cfg(test)] +#[path = "nomount_tests.rs"] +mod tests; diff --git a/userspace/ksud/src/nomount_plan.rs b/userspace/ksud/src/nomount_plan.rs new file mode 100644 index 000000000..957ba0f06 --- /dev/null +++ b/userspace/ksud/src/nomount_plan.rs @@ -0,0 +1,309 @@ +//! NoMount rule generation from the module directory. +//! +//! This replaces the upstream metamodule's `metamount.sh` + `rule-paths.sh` +//! pair. It walks every mountable module once and produces the ordered rule +//! list the backend applies, reusing the entry classification style of +//! [`crate::mount_plan`] so the same cases stay host-testable. +//! +//! Deliberate deviation from upstream: for a directory that replaces its target +//! (an opaque directory or one containing `.replace`) upstream adds a *whiteout* +//! rule without the directory flag, and the kernel then rejects every child rule +//! under it with `-ENOTDIR` (`kernel/src/nomount.c`, `nomount_generate_virtual_topology`). +//! This generator instead redirects the directory itself to the module's copy, +//! which is what "replace" means and which keeps the children reachable, and +//! stops descending because the module directory listing already provides them. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::nomount::Rule; + +/// Partition directories probed inside each module, in upstream order. +pub const TARGET_PARTITIONS: [&str; 21] = [ + "system", + "system_ext", + "vendor", + "odm", + "product", + "apex", + "oem", + "optics", + "prism", + "mi_ext", + "my_bigball", + "my_carrier", + "my_company", + "my_engineering", + "my_heytap", + "my_manifest", + "my_preload", + "my_product", + "my_region", + "my_reserve", + "my_stock", +]; + +/// Names that are remapped out of `/system` when a module nests them there. +pub const REMAPPED_PARTITIONS: [&str; 9] = [ + "vendor", + "system_ext", + "product", + "odm", + "apex", + "oem", + "optics", + "prism", + "mi_ext", +]; + +/// Prefix of OEM partitions that are always remapped out of `/system`. +pub const REMAPPED_PREFIX: &str = "my_"; + +/// Marker meaning "replace the directory that contains this file". +pub const REPLACE_MARKER: &str = ".replace"; + +/// Module lifecycle marks that keep a module out of the injection. +pub const SKIP_MARKS: [&str; 3] = ["disable", "remove", "skip_mount"]; + +/// Entry classification used by the walker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + File, + Dir, + Symlink, + /// A character device with `rdev == 0`: Magisk's "delete this path". + Whiteout, + /// Sockets, FIFOs and block devices: not injectable. + Other, +} + +/// Filesystem access, injected so tests can drive every case without root. +pub trait PlanFs { + /// Entry names inside `dir`, unsorted. + /// + /// # Errors + /// Returns an error when the directory cannot be read. + fn read_dir(&self, dir: &Path) -> Result>; + + /// Classify `path` without following a final symlink. + /// + /// # Errors + /// Returns an error when the path cannot be stat'ed for a reason other than + /// "not found". + fn kind(&self, path: &Path) -> Result; + + /// Whether `path` exists (following symlinks). + /// + /// # Errors + /// Returns an error when the check itself fails. + fn exists(&self, path: &Path) -> Result; + + /// Whether a directory carries `trusted.overlay.opaque=y`. + /// + /// # Errors + /// Returns an error when the attribute cannot be read. + fn is_opaque(&self, path: &Path) -> Result; +} + +/// The real filesystem. +pub struct StdFs { + opaque: F, +} + +impl Result> StdFs { + pub const fn new(opaque: F) -> Self { + Self { opaque } + } +} + +impl Result> PlanFs for StdFs { + fn read_dir(&self, dir: &Path) -> Result> { + let mut names = Vec::new(); + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?; + if let Some(name) = entry.file_name().to_str() { + names.push(name.to_string()); + } + } + Ok(names) + } + + fn kind(&self, path: &Path) -> Result { + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(EntryKind::Other); + } + Err(error) => return Err(error).with_context(|| format!("stat {}", path.display())), + }; + let kind = if meta.is_symlink() { + EntryKind::Symlink + } else if meta.is_dir() { + EntryKind::Dir + } else if meta.is_file() { + EntryKind::File + } else if is_whiteout(&meta) { + EntryKind::Whiteout + } else { + EntryKind::Other + }; + Ok(kind) + } + + fn exists(&self, path: &Path) -> Result { + Ok(path.exists()) + } + + fn is_opaque(&self, path: &Path) -> Result { + (self.opaque)(path) + } +} + +/// Whether a character device is the `0/0` whiteout marker. +#[must_use] +pub fn is_whiteout(meta: &std::fs::Metadata) -> bool { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + meta.file_type().is_char_device() && meta.rdev() == 0 +} + +/// Where the module tree lives and which partitions to consider. +#[derive(Debug, Clone, Copy)] +pub struct PlanConfig<'a> { + pub modules_dir: &'a Path, + /// Real root, normally `/`. + pub root: &'a Path, + /// Real system root, normally `/system`. + pub system_root: &'a Path, + pub partitions: &'a [&'a str], +} + +impl<'a> PlanConfig<'a> { + #[must_use] + pub const fn new(modules_dir: &'a Path, root: &'a Path, system_root: &'a Path) -> Self { + PlanConfig { + modules_dir, + root, + system_root, + partitions: &TARGET_PARTITIONS, + } + } +} + +/// Result of one plan: the ordered rules plus what was skipped. +#[derive(Debug, Default, Clone)] +pub struct Plan { + pub rules: Vec, + /// Module ids that were skipped, with the reason. + pub skipped: Vec<(String, String)>, +} + +/// Map a module-relative path (for example `system/etc/hosts`) to its virtual +/// path, remapping nested partitions the way upstream's `rule-paths.sh` does. +#[must_use] +pub fn virtual_path(relative: &str) -> String { + if let Some(rest) = relative.strip_prefix("system/") { + let (first, tail) = rest.split_once('/').unwrap_or((rest, "")); + if is_remapped(first) { + return if tail.is_empty() { + format!("/{first}") + } else { + format!("/{first}/{tail}") + }; + } + } + format!("/{relative}") +} + +fn is_remapped(name: &str) -> bool { + REMAPPED_PARTITIONS.contains(&name) || name.starts_with(REMAPPED_PREFIX) +} + +/// Build the rule list for every mountable module. +/// +/// # Errors +/// Returns an error when the modules directory or a reachable module subtree +/// cannot be read. +pub fn collect_rules(fs: &impl PlanFs, cfg: &PlanConfig<'_>) -> Result { + let mut plan = Plan::default(); + let mut modules = fs.read_dir(cfg.modules_dir)?; + modules.sort_unstable(); + + for id in modules { + let module = cfg.modules_dir.join(&id); + if fs.kind(&module)? != EntryKind::Dir { + continue; + } + if let Some(mark) = SKIP_MARKS + .iter() + .find(|mark| fs.exists(&module.join(mark)).unwrap_or(false)) + { + plan.skipped.push((id.clone(), (*mark).to_string())); + continue; + } + let mut contributed = false; + for partition in cfg.partitions { + let dir = module.join(partition); + if fs.kind(&dir)? != EntryKind::Dir { + continue; + } + let real = cfg.root.join(partition); + let nested = cfg.system_root.join(partition); + if !fs.exists(&real)? && !fs.exists(&nested)? { + continue; + } + contributed |= walk(fs, &dir, partition, &mut plan.rules)?; + } + if !contributed { + plan.skipped.push((id, "no partition content".to_string())); + } + } + Ok(plan) +} + +/// Walk one partition directory, appending rules in a stable order. +/// +/// Returns whether anything was contributed. +fn walk(fs: &impl PlanFs, dir: &Path, relative: &str, rules: &mut Vec) -> Result { + let mut names = fs.read_dir(dir)?; + names.sort_unstable(); + + let replace = names.iter().any(|name| name == REPLACE_MARKER) || fs.is_opaque(dir)?; + if replace { + // Redirect the directory to the module's copy: the module listing then + // serves its children, and the real directory's entries are not merged. + rules.push(Rule::redirect( + virtual_path(relative), + dir.to_string_lossy().to_string(), + )); + return Ok(true); + } + + let mut contributed = false; + for name in names { + let child = dir.join(&name); + let child_relative = format!("{relative}/{name}"); + match fs.kind(&child)? { + EntryKind::Dir => { + contributed |= walk(fs, &child, &child_relative, rules)?; + } + EntryKind::File | EntryKind::Symlink => { + rules.push(Rule::redirect( + virtual_path(&child_relative), + child.to_string_lossy().to_string(), + )); + contributed = true; + } + EntryKind::Whiteout => { + rules.push(Rule::whiteout(virtual_path(&child_relative))); + contributed = true; + } + EntryKind::Other => {} + } + } + Ok(contributed) +} + +#[cfg(test)] +#[path = "nomount_plan_tests.rs"] +mod tests; diff --git a/userspace/ksud/src/nomount_plan_tests.rs b/userspace/ksud/src/nomount_plan_tests.rs new file mode 100644 index 000000000..62b1eb2d2 --- /dev/null +++ b/userspace/ksud/src/nomount_plan_tests.rs @@ -0,0 +1,284 @@ +//! Host tests for NoMount rule generation. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use super::*; + +/// An in-memory module tree, so whiteouts and opaque directories can be tested +/// without root or a real character device. +#[derive(Default)] +struct FakeFs { + dirs: HashMap>, + kinds: HashMap, + opaque: HashSet, + present: HashSet, +} + +impl FakeFs { + fn dir(mut self, path: &str, names: &[&str]) -> Self { + self.dirs.insert( + PathBuf::from(path), + names.iter().map(ToString::to_string).collect(), + ); + self.kinds.insert(PathBuf::from(path), EntryKind::Dir); + self + } + + fn entry(mut self, path: &str, kind: EntryKind) -> Self { + self.kinds.insert(PathBuf::from(path), kind); + self + } + + fn opaque(mut self, path: &str) -> Self { + self.opaque.insert(PathBuf::from(path)); + self + } + + /// Mark a real filesystem path as existing (the device side, not the module). + fn present(mut self, path: &str) -> Self { + self.present.insert(PathBuf::from(path)); + self + } +} + +impl PlanFs for FakeFs { + fn read_dir(&self, dir: &Path) -> Result> { + Ok(self.dirs.get(dir).cloned().unwrap_or_default()) + } + + fn kind(&self, path: &Path) -> Result { + Ok(self.kinds.get(path).copied().unwrap_or(EntryKind::Other)) + } + + fn exists(&self, path: &Path) -> Result { + Ok(self.present.contains(path) || self.kinds.contains_key(path)) + } + + fn is_opaque(&self, path: &Path) -> Result { + Ok(self.opaque.contains(path)) + } +} + +fn config<'a>(modules: &'a Path, root: &'a Path, system: &'a Path) -> PlanConfig<'a> { + PlanConfig::new(modules, root, system) +} + +const MODULES: &str = "/data/adb/modules"; +const MODULE: &str = "/data/adb/modules/m"; + +fn base() -> FakeFs { + FakeFs::default() + .dir(MODULES, &["m"]) + .dir(MODULE, &["system"]) + .present("/system") + .present("/vendor") +} + +#[test] +fn virtual_paths_remap_nested_partitions() { + assert_eq!(virtual_path("system/etc/hosts"), "/system/etc/hosts"); + assert_eq!( + virtual_path("system/vendor/lib/libfoo.so"), + "/vendor/lib/libfoo.so" + ); + assert_eq!(virtual_path("system/vendor"), "/vendor"); + assert_eq!(virtual_path("system/system_ext/app/x"), "/system_ext/app/x"); + assert_eq!(virtual_path("system/my_bigball/app/x"), "/my_bigball/app/x"); + assert_eq!(virtual_path("vendor/etc/x"), "/vendor/etc/x"); + assert_eq!(virtual_path("system/my_product"), "/my_product"); +} + +#[test] +fn files_symlinks_whiteouts_and_other_kinds() { + let fs = base() + .dir(&format!("{MODULE}/system"), &["etc", "lib"]) + .dir(&format!("{MODULE}/system/etc"), &["hosts", "gone", "fifo"]) + .entry(&format!("{MODULE}/system/etc/hosts"), EntryKind::File) + .entry(&format!("{MODULE}/system/etc/gone"), EntryKind::Whiteout) + .entry(&format!("{MODULE}/system/etc/fifo"), EntryKind::Other) + .dir(&format!("{MODULE}/system/lib"), &["libfoo.so"]) + .entry( + &format!("{MODULE}/system/lib/libfoo.so"), + EntryKind::Symlink, + ); + + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert_eq!( + plan.rules, + vec![ + // Sorted directory order: fifo (skipped), gone, hosts, then lib. + Rule::whiteout("/system/etc/gone"), + Rule::redirect("/system/etc/hosts", format!("{MODULE}/system/etc/hosts")), + Rule::redirect( + "/system/lib/libfoo.so", + format!("{MODULE}/system/lib/libfoo.so") + ), + ] + ); + assert!(plan.skipped.is_empty()); +} + +#[test] +fn lifecycle_marks_skip_a_module() { + for mark in SKIP_MARKS { + let fs = base() + .dir(&format!("{MODULE}/system"), &["etc"]) + .dir(&format!("{MODULE}/system/etc"), &["hosts"]) + .entry(&format!("{MODULE}/system/etc/hosts"), EntryKind::File) + .entry(&format!("{MODULE}/{mark}"), EntryKind::File); + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert!(plan.rules.is_empty(), "{mark} must skip the module"); + assert_eq!(plan.skipped, vec![("m".to_string(), mark.to_string())]); + } +} + +#[test] +fn an_opaque_directory_is_replaced_without_descending() { + let fs = base() + .dir(&format!("{MODULE}/system"), &["etc"]) + .dir(&format!("{MODULE}/system/etc"), &["app"]) + .dir(&format!("{MODULE}/system/etc/app"), &["config.xml"]) + .entry( + &format!("{MODULE}/system/etc/app/config.xml"), + EntryKind::File, + ) + .opaque(&format!("{MODULE}/system/etc/app")); + + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert_eq!( + plan.rules, + vec![Rule::redirect( + "/system/etc/app", + format!("{MODULE}/system/etc/app") + )], + "a replaced directory redirects to the module copy and its children come from that listing" + ); +} + +#[test] +fn a_replace_marker_replaces_its_directory() { + let fs = base() + .dir(&format!("{MODULE}/system"), &["etc"]) + .dir(&format!("{MODULE}/system/etc"), &["app"]) + .dir( + &format!("{MODULE}/system/etc/app"), + &[REPLACE_MARKER, "config.xml"], + ) + .entry( + &format!("{MODULE}/system/etc/app/{REPLACE_MARKER}"), + EntryKind::File, + ) + .entry( + &format!("{MODULE}/system/etc/app/config.xml"), + EntryKind::File, + ); + + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert_eq!( + plan.rules, + vec![Rule::redirect( + "/system/etc/app", + format!("{MODULE}/system/etc/app") + )] + ); +} + +#[test] +fn nested_partitions_under_system_are_remapped() { + let fs = base() + .dir(&format!("{MODULE}/system"), &["vendor"]) + .dir(&format!("{MODULE}/system/vendor"), &["etc"]) + .dir(&format!("{MODULE}/system/vendor/etc"), &["audio.conf"]) + .entry( + &format!("{MODULE}/system/vendor/etc/audio.conf"), + EntryKind::File, + ); + + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert_eq!( + plan.rules, + vec![Rule::redirect( + "/vendor/etc/audio.conf", + format!("{MODULE}/system/vendor/etc/audio.conf") + )] + ); +} + +#[test] +fn modules_without_partition_content_are_reported() { + let fs = base().dir(&format!("{MODULE}/system"), &[]); + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert!(plan.rules.is_empty()); + assert_eq!( + plan.skipped, + vec![("m".to_string(), "no partition content".to_string())] + ); +} + +#[test] +fn partitions_missing_on_the_device_are_skipped() { + let fs = base() + .dir(&format!("{MODULE}/odm"), &["etc"]) + .dir(&format!("{MODULE}/odm/etc"), &["x"]) + .entry(&format!("{MODULE}/odm/etc/x"), EntryKind::File); + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + assert!(plan.rules.is_empty()); + assert_eq!( + plan.skipped, + vec![("m".to_string(), "no partition content".to_string())] + ); +} + +#[test] +fn modules_are_processed_in_a_stable_order() { + let fs = FakeFs::default() + .dir(MODULES, &["zeta", "alpha"]) + .dir("/data/adb/modules/zeta", &["system"]) + .dir("/data/adb/modules/alpha", &["system"]) + .present("/system") + .dir("/data/adb/modules/zeta/system", &["z"]) + .entry("/data/adb/modules/zeta/system/z", EntryKind::File) + .dir("/data/adb/modules/alpha/system", &["a"]) + .entry("/data/adb/modules/alpha/system/a", EntryKind::File); + + let plan = collect_rules( + &fs, + &config(Path::new(MODULES), Path::new("/"), Path::new("/system")), + ) + .unwrap(); + let order: Vec<&str> = plan + .rules + .iter() + .map(|rule| rule.virtual_path.as_str()) + .collect(); + assert_eq!(order, vec!["/system/a", "/system/z"]); +} diff --git a/userspace/ksud/src/nomount_tests.rs b/userspace/ksud/src/nomount_tests.rs new file mode 100644 index 000000000..e579a3864 --- /dev/null +++ b/userspace/ksud/src/nomount_tests.rs @@ -0,0 +1,528 @@ +//! Host tests for the NoMount transport and wire encoding. + +use std::cell::RefCell; +use std::rc::Rc; + +use super::*; + +/// How the fake kernel behaves. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Answers everything, keeps what it is given. + Ready, + /// Refuses every command, as an unregistered key type does. + Silent, + /// Accepts rules but reports none back. + ForgetsRules, + /// Answers the version but refuses the listing. + ListingRefused, +} + +struct FakeState { + rules: Vec, + uids: Vec, + isolated: bool, + commands: Vec, +} + +struct FakeKernel { + mode: Mode, + version: &'static str, + state: RefCell, +} + +impl FakeKernel { + fn new(mode: Mode) -> Self { + Self { + mode, + version: "21", + state: RefCell::new(FakeState { + rules: Vec::new(), + uids: Vec::new(), + isolated: false, + commands: Vec::new(), + }), + } + } + + fn with_version(mut self, version: &'static str) -> Self { + self.version = version; + self + } + + fn commands(&self) -> Vec { + self.state.borrow().commands.clone() + } + + fn written_rules(&self) -> Vec { + self.state.borrow().rules.clone() + } +} + +fn decode_command(raw: u32) -> Command { + match raw { + 1 => Command::GetVersion, + 2 => Command::AddRule, + 3 => Command::DelRule, + 4 => Command::AddUid, + 5 => Command::DelUid, + 7 => Command::ClearRules, + 9 => Command::GetList, + 10 => Command::GetUids, + 11 => Command::BlockIsolatedUids, + 12 => Command::GetIsolatedState, + other => panic!("unexpected command {other}"), + } +} + +impl Transport for Rc { + fn send(&self, payload: &mut Payload) -> Result<()> { + self.dispatch(payload) + } +} + +impl FakeKernel { + fn dispatch(&self, payload: &mut Payload) -> Result<()> { + let magic = payload.magic; + assert_eq!(magic, MAGIC, "payload magic must be stamped"); + let command = decode_command(payload.cmd); + self.state.borrow_mut().commands.push(command); + + if self.mode == Mode::Silent { + payload.status = -1; + let command = decode_command(payload.cmd); + bail!("nomount: command {command:?} failed with -1"); + } + + match command { + Command::GetVersion => { + let bytes = self.version.as_bytes(); + payload.buffer[..bytes.len()].copy_from_slice(bytes); + payload.data_size = u32::try_from(bytes.len())?; + } + Command::GetList => { + if self.mode == Mode::ListingRefused { + payload.status = -1; + bail!("nomount: listing refused"); + } + let rules: Vec = if self.mode == Mode::ForgetsRules { + Vec::new() + } else { + self.state.borrow().rules.clone() + }; + let start = usize::try_from(payload.arg1)?; + let mut cursor = 0_usize; + for rule in rules.iter().skip(start) { + let encoded = encode_batches(std::slice::from_ref(rule))?; + let record = encoded.first().cloned().unwrap_or_default(); + if cursor + record.len() > BUFFER_SIZE { + break; + } + payload.buffer[cursor..cursor + record.len()].copy_from_slice(&record); + cursor += record.len(); + } + payload.data_size = u32::try_from(cursor)?; + payload.arg1 = u32::try_from(start + rules.len().saturating_sub(start))?; + } + Command::AddRule => { + let len = usize::try_from(payload.data_size)?; + let decoded = parse_list_records(&payload.buffer[..len])?; + let mut state = self.state.borrow_mut(); + // Mirror the kernel: a rule for the same virtual path and uid + // replaces the previous one instead of stacking. + for rule in decoded { + state.rules.retain(|existing| { + existing.virtual_path != rule.virtual_path || existing.uid != rule.uid + }); + state.rules.push(rule); + } + } + Command::DelRule => { + let len = usize::try_from(payload.data_size)?; + let mut cursor = 0_usize; + let mut doomed = Vec::new(); + while cursor + 6 <= len { + let v_len = usize::from(u16::from_le_bytes([ + payload.buffer[cursor + 4], + payload.buffer[cursor + 5], + ])); + doomed.push( + String::from_utf8_lossy(&payload.buffer[cursor + 6..cursor + 6 + v_len]) + .to_string(), + ); + cursor += 6 + v_len; + } + self.state + .borrow_mut() + .rules + .retain(|rule| !doomed.contains(&rule.virtual_path)); + } + Command::AddUid => self.state.borrow_mut().uids.push(payload.target_uid), + Command::DelUid => self + .state + .borrow_mut() + .uids + .retain(|uid| *uid != payload.target_uid), + Command::ClearRules => self.state.borrow_mut().rules.clear(), + Command::GetUids => { + let start = usize::try_from(payload.arg1)?; + let mut cursor = 0_usize; + let mut written = 0_usize; + for uid in self.state.borrow().uids.iter().skip(start) { + if cursor + 4 > BUFFER_SIZE { + break; + } + payload.buffer[cursor..cursor + 4].copy_from_slice(&uid.to_le_bytes()); + cursor += 4; + written += 1; + } + payload.data_size = u32::try_from(cursor)?; + payload.arg1 = u32::try_from(start + written)?; + } + // The kernel replies with "1\n" or "0\n". + Command::BlockIsolatedUids => { + self.state.borrow_mut().isolated = payload.arg1 == 1; + } + Command::GetIsolatedState => { + let isolated = self.state.borrow().isolated; + payload.buffer[0] = if isolated { b'1' } else { b'0' }; + payload.buffer[1] = b'\n'; + payload.data_size = 2; + } + } + payload.status = 0; + Ok(()) + } +} + +/// Build a driver plus a handle that can still inspect the fake kernel. +fn driver(mode: Mode) -> (Driver>, Rc) { + let kernel = Rc::new(FakeKernel::new(mode)); + (Driver::new(Rc::clone(&kernel)), kernel) +} + +/// As [`driver`], with a specific API version string. +fn driver_versioned(mode: Mode, version: &'static str) -> Driver> { + Driver::new(Rc::new(FakeKernel::new(mode).with_version(version))) +} + +#[test] +fn payload_is_exactly_one_page() { + assert_eq!(size_of::(), PAGE_SIZE); + assert_eq!(std::mem::offset_of!(Payload, buffer), 28); + assert_eq!(BUFFER_SIZE, 4068); + assert_eq!(MAGIC.to_be_bytes(), *b"\0NOMOUNT"); +} + +#[test] +fn api_version_parses_the_leading_integer() { + assert_eq!(parse_api_version("21"), Some(21)); + assert_eq!(parse_api_version("21 (1234)"), Some(21)); + assert_eq!(parse_api_version(" 20\n"), Some(20)); + assert_eq!(parse_api_version("unknown"), None); + assert_eq!(parse_api_version(""), None); +} + +#[test] +fn module_loaded_matches_proc_modules() { + assert!(module_loaded( + "nomount 123456 0 - Live 0x0\nother 1 0 - Live\n" + )); + assert!(!module_loaded("nomountx 123456 0 - Live\n")); + assert!(!module_loaded("")); +} + +#[test] +fn redirect_and_whiteout_records_follow_the_wire_layout() { + let redirect = Rule::redirect("/system/etc/hosts", "/data/adb/modules/m/system/etc/hosts"); + let batches = encode_batches(std::slice::from_ref(&redirect)).unwrap(); + assert_eq!(batches.len(), 1); + let bytes = &batches[0]; + assert_eq!( + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + 0 + ); + assert_eq!( + u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), + 0 + ); + let v_len = usize::from(u16::from_le_bytes([bytes[8], bytes[9]])); + let r_len = usize::from(u16::from_le_bytes([bytes[10], bytes[11]])); + assert_eq!(v_len, "/system/etc/hosts".len()); + assert_eq!(r_len, "/data/adb/modules/m/system/etc/hosts".len()); + assert_eq!(&bytes[12..12 + v_len], b"/system/etc/hosts"); + + let whiteout = Rule::whiteout("/system/etc/removed"); + let batches = encode_batches(std::slice::from_ref(&whiteout)).unwrap(); + let bytes = &batches[0]; + assert_eq!( + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + WHITEOUT_FLAG + ); + let r_len = usize::from(u16::from_le_bytes([bytes[10], bytes[11]])); + assert_eq!(r_len, 0); + assert_eq!(bytes.len(), 12 + "/system/etc/removed".len()); +} + +#[test] +fn batches_split_before_overflowing_the_payload_buffer() { + let rules: Vec = (0..80) + .map(|index| { + Rule::redirect( + format!("/system/etc/file-{index:04}"), + format!("/data/adb/modules/m/system/etc/file-{index:04}"), + ) + }) + .collect(); + let batches = encode_batches(&rules).unwrap(); + assert!(batches.len() > 1, "expected the rules to be split"); + for batch in &batches { + assert!(batch.len() <= BUFFER_SIZE); + } + let total: usize = batches.iter().map(Vec::len).sum(); + assert_eq!(total, rules.iter().map(record_len).sum::()); +} + +fn record_len(rule: &Rule) -> usize { + 12 + rule.virtual_path.len() + rule.real_path.as_deref().unwrap_or_default().len() +} + +#[test] +fn redirect_without_a_target_is_rejected() { + let broken = Rule { + virtual_path: "/system/etc/hosts".to_string(), + real_path: None, + whiteout: false, + uid: 0, + }; + assert!(encode_batches(std::slice::from_ref(&broken)).is_err()); +} + +#[test] +fn rule_lists_round_trip_and_reject_truncation() { + let rules = vec![ + Rule::redirect("/system/etc/hosts", "/data/adb/modules/m/system/etc/hosts"), + Rule::whiteout("/system/etc/gone"), + ]; + let bytes = encode_batches(&rules).unwrap().concat(); + assert_eq!(parse_list_records(&bytes).unwrap(), rules); + assert!(parse_list_records(&bytes[..bytes.len() - 1]).is_err()); +} + +#[test] +fn apply_rules_reports_the_readback_count() { + let (driver, kernel) = driver(Mode::Ready); + let rules = vec![Rule::redirect( + "/system/etc/hosts", + "/data/adb/modules/m/system/etc/hosts", + )]; + assert_eq!(driver.apply_rules(&rules).unwrap(), 1); + assert_eq!(kernel.written_rules(), rules); +} + +#[test] +fn apply_rules_fails_when_the_kernel_does_not_keep_them() { + let (driver, _kernel) = driver(Mode::ForgetsRules); + let rules = vec![Rule::redirect( + "/system/etc/hosts", + "/data/adb/modules/m/system/etc/hosts", + )]; + let error = driver.apply_rules(&rules).unwrap_err().to_string(); + assert!( + error.contains("did not accept"), + "unexpected error: {error}" + ); +} + +#[test] +fn probe_reports_absent_outdated_and_ready() { + let (absent, _kernel) = driver(Mode::Silent); + assert_eq!(absent.probe().state, DriverState::Absent); + + let outdated = driver_versioned(Mode::Ready, "20"); + let probe = outdated.probe(); + assert_eq!(probe.state, DriverState::Outdated); + assert_eq!(probe.version.as_deref(), Some("20")); + assert!(ensure_usable(&probe).is_err()); + + let (refused, _kernel) = driver(Mode::ListingRefused); + assert_eq!(refused.probe().state, DriverState::Outdated); + + let (ready, _kernel) = driver(Mode::Ready); + let probe = ready.probe(); + assert!(probe.ready()); + assert_eq!(probe.version.as_deref(), Some("21")); + assert!(ensure_usable(&probe).is_ok()); +} + +#[test] +fn uid_exclusions_are_applied_and_listed() { + let (driver, kernel) = driver(Mode::Ready); + driver.add_uids(&[1000, 10123]).unwrap(); + assert_eq!(driver.list_uids().unwrap(), vec![1000, 10123]); + driver.del_uids(&[1000]).unwrap(); + assert_eq!(driver.list_uids().unwrap(), vec![10123]); + driver.del_uids(&[10123]).unwrap(); + assert!(driver.list_uids().unwrap().is_empty()); + // Every step must have reached the kernel, whatever the paging did. + assert!(kernel.commands().contains(&Command::AddUid)); + assert!(kernel.commands().contains(&Command::DelUid)); +} + +#[test] +fn deleting_rules_batches_and_clears_state() { + let (driver, _kernel) = driver(Mode::Ready); + let rules: Vec = (0..60) + .map(|index| { + Rule::redirect( + format!("/system/etc/file-{index:04}"), + format!("/data/adb/modules/m/system/etc/file-{index:04}"), + ) + }) + .collect(); + driver.apply_rules(&rules).unwrap(); + assert_eq!(driver.list_rules().unwrap().len(), rules.len()); + + let paths: Vec = rules.iter().map(|rule| rule.virtual_path.clone()).collect(); + driver.delete_rules(&paths).unwrap(); + assert!(driver.list_rules().unwrap().is_empty()); +} + +#[test] +fn reconcile_rules_drops_stale_paths() { + let (driver, kernel) = driver(Mode::Ready); + driver + .apply_rules(&[ + Rule::redirect("/system/etc/old", "/data/adb/modules/m/system/etc/old"), + Rule::redirect("/system/etc/keep", "/data/adb/modules/m/system/etc/keep"), + ]) + .unwrap(); + + let (applied, removed) = driver + .reconcile_rules(&[Rule::redirect( + "/system/etc/keep", + "/data/adb/modules/m/system/etc/keep", + )]) + .unwrap(); + assert_eq!((applied, removed), (1, 1)); + let paths: Vec = kernel + .written_rules() + .into_iter() + .map(|rule| rule.virtual_path) + .collect(); + assert_eq!(paths, vec!["/system/etc/keep"]); +} + +#[test] +fn a_later_rule_for_the_same_path_wins() { + // The kernel replaces a rule with the same virtual path and uid, so the + // last module to claim a path wins. Documented because the built-in engine + // resolves the same conflict the other way (first wins). + let (driver, kernel) = driver(Mode::Ready); + driver + .apply_rules(&[Rule::redirect( + "/system/etc/hosts", + "/data/adb/modules/first/system/etc/hosts", + )]) + .unwrap(); + driver + .apply_rules(&[Rule::redirect( + "/system/etc/hosts", + "/data/adb/modules/second/system/etc/hosts", + )]) + .unwrap(); + let rules = kernel.written_rules(); + assert_eq!(rules.len(), 1); + assert_eq!( + rules[0].real_path.as_deref(), + Some("/data/adb/modules/second/system/etc/hosts") + ); +} + +#[test] +fn visibility_config_round_trips_and_rejects_junk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nomount_uids"); + + // Missing file: nothing wanted, nothing blocked. + assert_eq!(read_uid_config(&path).unwrap(), UidConfig::default()); + + write_uid_config( + &path, + &UidConfig { + uids: vec![1000, 10123], + block_isolated: true, + }, + ) + .unwrap(); + let parsed = read_uid_config(&path).unwrap(); + assert_eq!(parsed.uids, vec![1000, 10123]); + assert!(parsed.block_isolated); + + std::fs::write(&path, "isolated=on\nnot-a-uid\n").unwrap(); + assert!(read_uid_config(&path).is_err()); + std::fs::write(&path, "isolated=maybe\n").unwrap(); + assert!(read_uid_config(&path).is_err()); +} + +#[test] +fn sync_uids_applies_the_diff_and_the_isolated_flag() { + let (driver, _kernel) = driver(Mode::Ready); + driver.add_uids(&[1000, 2000]).unwrap(); + + let (added, removed) = sync_uids( + &driver, + &UidConfig { + uids: vec![2000, 3000], + block_isolated: true, + }, + ) + .unwrap(); + assert_eq!((added, removed), (1, 1)); + assert_eq!(driver.list_uids().unwrap(), vec![2000, 3000]); + assert!(driver.isolated_state().unwrap()); + + // A second identical call changes nothing. + let (added, removed) = sync_uids( + &driver, + &UidConfig { + uids: vec![2000, 3000], + block_isolated: true, + }, + ) + .unwrap(); + assert_eq!((added, removed), (0, 0)); +} + +#[test] +fn runtime_record_is_key_value_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nomount_runtime"); + write_runtime_record( + &path, + &RuntimeRecord { + boot_id: "boot-1".to_string(), + driver_state: "ready".to_string(), + driver_version: "21".to_string(), + rules_applied: 42, + rules_removed: 3, + uid_count: 2, + isolated_blocked: true, + error: None, + }, + ) + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + for expected in [ + "boot_id=boot-1", + "driver_state=ready", + "driver_version=21", + "rules_applied=42", + "rules_removed=3", + "uid_count=2", + "isolated_blocked=true", + "error=", + ] { + assert!(text.contains(expected), "missing {expected} in {text}"); + } +} From c571cb44791874deb4b3df5e834c1fcb69c034e6 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:15:17 +0800 Subject: [PATCH 05/24] manager: Add the NoMount page, provider switch and mode migration The NoMount backend is now visible and controllable, and the default backend moves to NoMount with the built-in engine as its fallback. - NomountScreen reports the driver, the injection and the visibility state. Every row that can look healthy is gated on the driver answering, and the read state is never replaced by an invented fallback. - FolkMountStatus accepts schema 1 and 2 and reads boot_warning, so a stale ksud or a stale app degrades to one unavailable row instead of a hard failure. - New mount modes: nomount (NoMount, falls back to the built-in engine) and off (inject nothing). The settings dropdown, provider labels and strings follow. - A missing mount_mode now means: keep the previous behaviour when this device already mounted modules (upgrade), otherwise select nomount, and record the decision so the default applies only once. Verified with ./gradlew :app:compileDebugKotlin and :app:testDebugUnitTest. Not included: pushing App Profile "unmount modules" into the UID list, which needs an enumeration API the manager does not have yet. --- .../weishu/kernelsu/data/model/FolkMount.kt | 23 +- .../kernelsu/data/model/NomountStatus.kt | 68 ++++++ .../data/repository/SettingsRepository.kt | 5 + .../data/repository/SettingsRepositoryImpl.kt | 32 +++ .../me/weishu/kernelsu/ui/MainActivity.kt | 3 + .../weishu/kernelsu/ui/navigation3/Routes.kt | 4 + .../ui/screen/nomount/NomountScreen.kt | 201 ++++++++++++++++++ .../settings/SettingsCategoryContent.kt | 62 +++++- .../ui/screen/settings/SettingsScreen.kt | 1 + .../ui/screen/settings/SettingsUiState.kt | 6 + .../kernelsu/ui/viewmodel/NomountViewModel.kt | 80 +++++++ .../ui/viewmodel/SettingsViewModel.kt | 4 + .../src/main/res/values-zh-rCN/strings.xml | 30 +++ manager/app/src/main/res/values/strings.xml | 30 +++ userspace/ksud/src/cli.rs | 4 +- userspace/ksud/src/magic_mount.rs | 51 ++++- 16 files changed, 587 insertions(+), 17 deletions(-) create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkMount.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkMount.kt index 8c8bdf53d..a5d840b2f 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkMount.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/FolkMount.kt @@ -11,7 +11,9 @@ import org.json.JSONObject enum class FolkMountMode(val token: String) { AUTO("auto"), BUILTIN("builtin"), - METAMODULE("metamodule"); + METAMODULE("metamodule"), + NOMOUNT("nomount"), + OFF("off"); companion object { fun fromToken(token: String?): FolkMountMode? = @@ -25,7 +27,9 @@ enum class FolkMountMode(val token: String) { */ enum class FolkMountProvider(val token: String) { BUILTIN("builtin"), - METAMODULE("metamodule"); + METAMODULE("metamodule"), + NOMOUNT("nomount"), + NONE("none"); companion object { fun fromToken(token: String?): FolkMountProvider? = @@ -34,14 +38,14 @@ enum class FolkMountProvider(val token: String) { } /** - * Decoded `ksud mount status --json` payload (`schema_version = 1`). + * Decoded `ksud mount status --json` payload (`schema_version` 1 or 2). * * Property names map one-to-one to the ksud JSON keys: * [configuredMode] <- `configured_mode`, [nextProvider] <- `next_provider`, * [metamoduleId] <- `metamodule_id`, [metamoduleEnabled] <- `metamodule_enabled`, * [bootId] <- `boot_id`, [bootProvider] <- `boot_provider`, * [bootResult] <- `boot_result`, [targetCount] <- `target_count`, - * [error] <- `error`. + * [bootWarning] <- `boot_warning` (schema 2), [error] <- `error`. */ @Immutable data class FolkMountStatus( @@ -53,10 +57,14 @@ data class FolkMountStatus( val bootProvider: FolkMountProvider?, val bootResult: String, val targetCount: Int?, + /** Provider-specific degradation for this boot, e.g. a NoMount fallback. */ + val bootWarning: String?, val error: String?, ) { companion object { - const val SCHEMA_VERSION = 1 + /** Newest schema this app understands; older schemas stay readable. */ + const val SCHEMA_VERSION = 2 + private const val OLDEST_SCHEMA_VERSION = 1 /** * Decode a `ksud mount status --json` object. @@ -66,6 +74,10 @@ data class FolkMountStatus( * than falling back to a default mode. */ fun fromJson(json: JSONObject): FolkMountStatus { + val schema = json.optInt("schema_version", -1) + check(schema in OLDEST_SCHEMA_VERSION..SCHEMA_VERSION) { + "unsupported folk mount schema_version $schema" + } val configuredMode = FolkMountMode.fromToken(json.optStringOrNull("configured_mode")) ?: error("folk mount status has no valid configured_mode") return FolkMountStatus( @@ -77,6 +89,7 @@ data class FolkMountStatus( bootProvider = FolkMountProvider.fromToken(json.optStringOrNull("boot_provider")), bootResult = json.optString("boot_result", "unknown"), targetCount = json.optIntOrNull("target_count"), + bootWarning = json.optStringOrNull("boot_warning"), error = json.optStringOrNull("error"), ) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt new file mode 100644 index 000000000..2491d9fe2 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt @@ -0,0 +1,68 @@ +package me.weishu.kernelsu.data.model + +import androidx.compose.runtime.Immutable +import org.json.JSONObject + +/** + * Driver state reported by `ksud nomount status --json`. + * + * The backend distinguishes three states on purpose: a module that loaded but + * does not answer must never be reported as ready. + */ +enum class NomountDriverState(val token: String) { + ABSENT("absent"), + OUTDATED("outdated"), + READY("ready"); + + companion object { + fun fromToken(token: String?): NomountDriverState? = + entries.firstOrNull { it.token == token } + } +} + +/** Decoded `ksud nomount status --json` payload (`schema_version = 1`). */ +@Immutable +data class NomountStatus( + val driverState: NomountDriverState, + val driverVersion: String?, + val apiOk: Boolean, + val rulesExpected: Int, + val rulesApplied: Int, + val uidCount: Int, + val isolatedBlocked: Boolean, + val error: String?, +) { + /** True when the driver answers and the requested rules are all in place. */ + val injected: Boolean + get() = apiOk && rulesApplied >= rulesExpected && rulesExpected > 0 + + companion object { + const val SCHEMA_VERSION = 1 + + /** + * @throws IllegalStateException when the payload is not a NoMount status + * this app understands; callers treat that as a read failure rather than + * inventing a state. + */ + fun fromJson(json: JSONObject): NomountStatus { + check(json.optInt("schema_version", -1) == SCHEMA_VERSION) { + "unsupported nomount schema_version ${json.optInt("schema_version", -1)}" + } + val state = NomountDriverState.fromToken(json.optStringOrNull("driver_state")) + ?: error("nomount status has no valid driver_state") + return NomountStatus( + driverState = state, + driverVersion = json.optStringOrNull("driver_version"), + apiOk = json.optBoolean("api_ok", false), + rulesExpected = json.optInt("rules_expected", 0), + rulesApplied = json.optInt("rules_applied", 0), + uidCount = json.optInt("uid_count", 0), + isolatedBlocked = json.optBoolean("isolated_blocked", false), + error = json.optStringOrNull("error"), + ) + } + } +} + +private fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key, "").takeIf { it.isNotEmpty() } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt index dea799d3e..26d7e856b 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt @@ -3,6 +3,7 @@ package me.weishu.kernelsu.data.repository import me.weishu.kernelsu.data.model.FolkMountMode import me.weishu.kernelsu.data.model.FolkZygiskStatus import me.weishu.kernelsu.data.model.FolkMountStatus +import me.weishu.kernelsu.data.model.NomountStatus import me.weishu.kernelsu.data.model.ModuleSortGroup import me.weishu.kernelsu.data.modulestore.StoreSourceKind @@ -84,6 +85,10 @@ interface SettingsRepository { suspend fun getFolkZygiskStatus(): Result suspend fun setFolkZygiskEnabled(enabled: Boolean): Result suspend fun setFolkMountMode(mode: FolkMountMode): Result + suspend fun getNomountStatus(): Result + suspend fun setNomountIsolated(enabled: Boolean): Result + suspend fun injectNomountRules(): Result + suspend fun clearNomountRules(): Result suspend fun getAdbRootStatus(): String suspend fun getAdbRootPersistValue(): Long? diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt index 7eda7f223..5ecda7e21 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt @@ -15,6 +15,7 @@ import me.weishu.kernelsu.data.model.FolkMountMode import me.weishu.kernelsu.data.model.FolkZygiskStatus import me.weishu.kernelsu.data.model.FolkMountStatus import me.weishu.kernelsu.data.model.HomeLayoutStyle +import me.weishu.kernelsu.data.model.NomountStatus import me.weishu.kernelsu.data.model.ModuleCustomOrderStore import me.weishu.kernelsu.data.model.ModuleSortGroup import me.weishu.kernelsu.data.model.ModuleSortPriorityStore @@ -336,6 +337,37 @@ class SettingsRepositoryImpl : SettingsRepository { } } + override suspend fun getNomountStatus(): Result = withContext(Dispatchers.IO) { + runCatching { + val result = execKsudResult("nomount status --json") + check(result.isSuccess) { "ksud nomount status failed (exit ${result.code})" } + val payload = result.out.firstOrNull { it.isNotBlank() } + ?: error("ksud nomount status returned no payload") + NomountStatus.fromJson(JSONObject(payload)) + } + } + + override suspend fun setNomountIsolated(enabled: Boolean): Result = withContext(Dispatchers.IO) { + runCatching { + val state = if (enabled) "on" else "off" + check(execKsud("nomount uid isolated $state", true)) { + "ksud nomount uid isolated $state failed" + } + } + } + + override suspend fun injectNomountRules(): Result = withContext(Dispatchers.IO) { + runCatching { + check(execKsud("nomount inject", true)) { "ksud nomount inject failed" } + } + } + + override suspend fun clearNomountRules(): Result = withContext(Dispatchers.IO) { + runCatching { + check(execKsud("nomount clear", true)) { "ksud nomount clear failed" } + } + } + override suspend fun getAdbRootStatus(): String = getFeatureStatus("adb_root") override suspend fun getShellSuAdbOnlyStatus(): String = getFeatureStatus("shell_su_adb_only") diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt index bbdd728e0..41022e20d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt @@ -284,6 +284,9 @@ open class MainActivity : FragmentActivity() { entry { AboutScreen() } entry { SulogScreen() } entry { me.weishu.kernelsu.ui.screen.SusfsScreen() } + entry { + me.weishu.kernelsu.ui.screen.nomount.NomountScreen() + } entry { ColorPaletteScreen() } entry { AppProfileTemplateScreen() } entry { key -> TemplateEditorScreen(key.template, key.readOnly) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt index 967577d9e..213150139 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt @@ -53,6 +53,10 @@ sealed interface Route : NavKey, Parcelable { @Serializable data object Susfs : Route + @Parcelize + @Serializable + data object Nomount : Route + @Parcelize @Serializable data object ColorPalette : Route diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt new file mode 100644 index 000000000..ad069a374 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -0,0 +1,201 @@ +package me.weishu.kernelsu.ui.screen.nomount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import me.weishu.kernelsu.R +import me.weishu.kernelsu.data.model.NomountDriverState +import me.weishu.kernelsu.data.model.NomountStatus +import me.weishu.kernelsu.ui.component.material.SegmentedColumn +import me.weishu.kernelsu.ui.component.material.SegmentedListItem +import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem +import me.weishu.kernelsu.ui.navigation3.LocalNavigator +import me.weishu.kernelsu.ui.viewmodel.NomountUiState +import me.weishu.kernelsu.ui.viewmodel.NomountViewModel + +/** + * NoMount driver, injection and visibility state. + * + * Every row that can look healthy is gated on the driver actually answering: + * an unusable driver disables the controls instead of offering actions that + * would silently do nothing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NomountScreen() { + val viewModel = viewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current + val scrollBehavior = TopAppBarDefaults + .exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose { } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_nomount)) }, + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .verticalScroll(rememberScrollState()) + .padding(PaddingValues(16.dp)), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DriverSection(state, viewModel) + InjectionSection(state, viewModel) + VisibilitySection(state, viewModel) + } + } +} + +@Composable +private fun DriverSection(state: NomountUiState, viewModel: NomountViewModel) { + SegmentedColumn(title = stringResource(R.string.nomount_section_driver)) { + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_driver_state)) }, + supportingContent = { Text(driverSummary(state)) }, + leadingContent = { Icon(Icons.Filled.Layers, null) }, + onClick = viewModel::refresh, + trailingContent = { Icon(Icons.Filled.Refresh, null) }, + ) + } + } +} + +@Composable +private fun driverSummary(state: NomountUiState): String { + if (state.readError) return stringResource(R.string.nomount_read_error) + val status = state.status ?: return stringResource(R.string.nomount_loading) + val label = stringResource( + when (status.driverState) { + NomountDriverState.ABSENT -> R.string.nomount_driver_absent + NomountDriverState.OUTDATED -> R.string.nomount_driver_outdated + NomountDriverState.READY -> R.string.nomount_driver_ready + } + ) + val version = status.driverVersion + return if (version.isNullOrEmpty()) label else "$label ($version)" +} + +@Composable +private fun InjectionSection(state: NomountUiState, viewModel: NomountViewModel) { + val status = state.status + val ready = status?.apiOk == true && !state.isWriting + SegmentedColumn(title = stringResource(R.string.nomount_section_injection)) { + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_rules)) }, + supportingContent = { Text(rulesSummary(status)) }, + leadingContent = { Icon(Icons.Filled.PlayArrow, null) }, + enabled = ready, + onClick = viewModel::injectRules, + trailingContent = { Icon(Icons.Filled.PlayArrow, null) }, + ) + } + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_clear_rules)) }, + supportingContent = { Text(stringResource(R.string.nomount_clear_rules_summary)) }, + leadingContent = { Icon(Icons.Filled.DeleteSweep, null) }, + enabled = ready, + onClick = viewModel::clearRules, + ) + } + item(visible = state.actionError) { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_action_failed)) }, + supportingContent = { + Text(stringResource(R.string.nomount_action_failed_summary)) + }, + leadingContent = { Icon(Icons.Filled.Refresh, null) }, + enabled = false, + onClick = {}, + ) + } + } +} + +@Composable +private fun rulesSummary(status: NomountStatus?): String { + val state = status ?: return stringResource(R.string.nomount_loading) + val summary = stringResource( + R.string.nomount_rules_value, + state.rulesApplied, + state.rulesExpected, + ) + return if (state.injected) summary else "$summary\n${stringResource(R.string.nomount_not_injected)}" +} + +@Composable +private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel) { + val status = state.status + val ready = status?.apiOk == true && !state.isWriting + SegmentedColumn(title = stringResource(R.string.nomount_section_visibility)) { + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_uid_exclusions)) }, + supportingContent = { + Text(stringResource(R.string.nomount_uid_exclusions_value, status?.uidCount ?: 0)) + }, + overlineContent = { Text(stringResource(R.string.nomount_uid_source_hint)) }, + leadingContent = { Icon(Icons.Filled.VisibilityOff, null) }, + enabled = false, + onClick = {}, + ) + } + item { + SegmentedSwitchItem( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.nomount_block_isolated), + summary = stringResource(R.string.nomount_block_isolated_summary), + checked = status?.isolatedBlocked == true, + enabled = ready, + onCheckedChange = viewModel::setIsolated, + ) + } + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt index 97b949d80..8b23357da 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt @@ -41,6 +41,7 @@ import me.weishu.kernelsu.R import me.weishu.kernelsu.data.model.FolkMountMode import me.weishu.kernelsu.data.model.FolkMountProvider import me.weishu.kernelsu.data.model.HomeLayoutStyle +import me.weishu.kernelsu.data.model.NomountDriverState import me.weishu.kernelsu.data.model.SupportLinksStyle import me.weishu.kernelsu.ui.component.KsuIsValid import me.weishu.kernelsu.ui.component.material.SegmentedColumn @@ -437,6 +438,8 @@ fun FunctionCategoryContent(uiState: SettingsUiState, actions: SettingsScreenAct stringResource(id = R.string.settings_folk_mount_mode_auto), stringResource(id = R.string.settings_folk_mount_mode_builtin), stringResource(id = R.string.settings_folk_mount_mode_metamodule), + stringResource(id = R.string.settings_folk_mount_mode_nomount), + stringResource(id = R.string.settings_folk_mount_mode_off), ) val summary = when { uiState.isFolkMountLoading -> @@ -451,6 +454,8 @@ fun FunctionCategoryContent(uiState: SettingsUiState, actions: SettingsScreenAct FolkMountMode.AUTO -> R.string.settings_folk_mount_mode_auto_summary FolkMountMode.BUILTIN -> R.string.settings_folk_mount_mode_builtin_summary FolkMountMode.METAMODULE -> R.string.settings_folk_mount_mode_metamodule_summary + FolkMountMode.NOMOUNT -> R.string.settings_folk_mount_mode_nomount_summary + FolkMountMode.OFF -> R.string.settings_folk_mount_mode_off_summary null -> R.string.settings_folk_mount_summary } ) @@ -465,14 +470,25 @@ fun FunctionCategoryContent(uiState: SettingsUiState, actions: SettingsScreenAct stringResource(id = R.string.settings_folk_mount_provider_metamodule) ) + FolkMountProvider.NOMOUNT -> stringResource( + id = R.string.settings_folk_mount_boot_provider, + stringResource(id = R.string.settings_folk_mount_provider_nomount) + ) + + FolkMountProvider.NONE -> stringResource( + id = R.string.settings_folk_mount_provider_none + ) + null -> stringResource(id = R.string.settings_folk_mount_boot_provider_unknown) } - val compatHint = if ( - mode == FolkMountMode.BUILTIN && folkMountStatus.metamoduleEnabled - ) { - stringResource(id = R.string.settings_folk_mount_metamodule_compat_hint) - } else { - null + val compatHint = when { + mode == FolkMountMode.BUILTIN && folkMountStatus.metamoduleEnabled -> + stringResource(id = R.string.settings_folk_mount_metamodule_compat_hint) + + mode == FolkMountMode.NOMOUNT && uiState.nomountStatus?.apiOk == false -> + stringResource(id = R.string.settings_folk_mount_nomount_unavailable_hint) + + else -> null } listOfNotNull( base, @@ -493,6 +509,40 @@ fun FunctionCategoryContent(uiState: SettingsUiState, actions: SettingsScreenAct FolkMountMode.entries.getOrNull(index)?.let(actions.onSetFolkMountMode) } ) + }, + { + // NoMount is its own page: the row only reports the driver + // and injection state, never a success it did not observe. + val nomount = uiState.nomountStatus + val nomountSummary = when { + uiState.isNomountLoading -> stringResource(R.string.nomount_loading) + uiState.nomountReadError -> stringResource(R.string.nomount_read_error) + nomount == null -> stringResource(R.string.nomount_loading) + !nomount.apiOk -> stringResource( + id = R.string.nomount_driver_unavailable, + stringResource( + id = when (nomount.driverState) { + NomountDriverState.ABSENT -> R.string.nomount_driver_absent + NomountDriverState.OUTDATED -> R.string.nomount_driver_outdated + NomountDriverState.READY -> R.string.nomount_driver_ready + } + ) + ) + else -> stringResource( + id = R.string.nomount_rules_value, + nomount.rulesApplied, + nomount.rulesExpected, + ) + } + SegmentedListItem( + headlineContent = { Text(stringResource(id = R.string.settings_nomount)) }, + supportingContent = { Text(nomountSummary) }, + leadingContent = { Icon(Icons.Filled.Layers, null) }, + onClick = actions.onOpenNomount, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) + }, + ) } ) ) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt index 1bc0f8761..f87b528e5 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt @@ -74,6 +74,7 @@ fun rememberSettingsActions( onSetSulogEnabled = viewModel::setSulogEnabled, onSetAvcSpoofEnabled = viewModel::setAvcSpoofEnabled, onSetFolkMountMode = viewModel::setFolkMountMode, + onOpenNomount = { navigator.push(Route.Nomount) }, onSetFolkZygiskEnabled = viewModel::setFolkZygiskEnabled, onSetAdbRootEnabled = viewModel::setAdbRootEnabled, onSetShellSuAdbOnlyEnabled = viewModel::setShellSuAdbOnlyEnabled, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt index 9e64abe9c..d856d8616 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsUiState.kt @@ -7,6 +7,7 @@ import me.weishu.kernelsu.data.model.FolkMountMode import me.weishu.kernelsu.data.model.FolkMountStatus import me.weishu.kernelsu.data.model.FolkZygiskStatus import me.weishu.kernelsu.data.model.HomeLayoutStyle +import me.weishu.kernelsu.data.model.NomountStatus import me.weishu.kernelsu.data.model.SupportLinksStyle @Immutable @@ -54,6 +55,10 @@ data class SettingsUiState( val isFolkMountLoading: Boolean = false, val isFolkMountWriting: Boolean = false, val folkMountReadError: Boolean = false, + // NoMount backend + val nomountStatus: NomountStatus? = null, + val isNomountLoading: Boolean = true, + val nomountReadError: Boolean = false, val folkZygiskStatus: FolkZygiskStatus? = null, val isFolkZygiskLoading: Boolean = true, val folkZygiskReadError: Boolean = false, @@ -101,6 +106,7 @@ data class SettingsScreenActions( val onSetSulogEnabled: (Boolean) -> Unit, val onSetAvcSpoofEnabled: (Boolean) -> Unit, val onSetFolkMountMode: (FolkMountMode) -> Unit, + val onOpenNomount: () -> Unit, val onSetFolkZygiskEnabled: (Boolean) -> Unit, val onSetAdbRootEnabled: (Boolean) -> Unit, val onSetShellSuAdbOnlyEnabled: (Boolean) -> Unit, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt new file mode 100644 index 000000000..2ef07306f --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt @@ -0,0 +1,80 @@ +package me.weishu.kernelsu.ui.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.weishu.kernelsu.data.model.NomountStatus +import me.weishu.kernelsu.data.repository.SettingsRepository +import me.weishu.kernelsu.data.repository.SettingsRepositoryImpl + +/** + * NoMount detail screen state. + * + * A failed read is kept as a flag instead of a fallback status: the screen must + * never show a plausible-looking driver state it did not actually receive. + */ +@Immutable +data class NomountUiState( + val isLoading: Boolean = true, + val isWriting: Boolean = false, + val status: NomountStatus? = null, + val readError: Boolean = false, + val actionError: Boolean = false, +) + +class NomountViewModel( + private val repo: SettingsRepository = SettingsRepositoryImpl() +) : ViewModel() { + + private val _uiState = MutableStateFlow(NomountUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + if (_uiState.value.isWriting) return + _uiState.update { it.copy(isLoading = true, actionError = false) } + viewModelScope.launch { + val result = repo.getNomountStatus() + _uiState.update { + it.copy( + isLoading = false, + status = result.getOrNull() ?: it.status, + readError = result.isFailure, + ) + } + } + } + + fun injectRules() = runAction { repo.injectNomountRules() } + + fun clearRules() = runAction { repo.clearNomountRules() } + + fun setIsolated(enabled: Boolean) = runAction { repo.setNomountIsolated(enabled) } + + private fun runAction(block: suspend () -> Result) { + if (_uiState.value.isWriting) return + _uiState.update { it.copy(isWriting = true, actionError = false) } + viewModelScope.launch { + val result = block() + // Re-read instead of assuming the action produced the state we asked + // for: the kernel may have refused part of it. + val refreshed = repo.getNomountStatus() + _uiState.update { + it.copy( + isWriting = false, + actionError = result.isFailure, + status = refreshed.getOrNull() ?: it.status, + readError = refreshed.isFailure, + ) + } + } + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt index 2b6edab88..a58cd441a 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/SettingsViewModel.kt @@ -93,6 +93,7 @@ class SettingsViewModel( val isLateLoadMode = Natives.isLateLoadMode val folkMountResult = repo.getFolkMountStatus() + val nomountResult = repo.getNomountStatus() val folkZygiskResult = repo.getFolkZygiskStatus() if (generation != refreshGeneration) return@launch @@ -137,6 +138,9 @@ class SettingsViewModel( folkMountStatus = folkMountResult.getOrNull(), folkMountReadError = folkMountResult.isFailure, isFolkMountLoading = false, + nomountStatus = nomountResult.getOrNull(), + nomountReadError = nomountResult.isFailure, + isNomountLoading = false, folkZygiskStatus = folkZygiskResult.getOrNull(), folkZygiskReadError = folkZygiskResult.isFailure, isFolkZygiskLoading = false, diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 5c3fb491c..9ad5cbbb2 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -483,16 +483,46 @@ 自动 内建 Folk Mount 仅 metamodule + NoMount(VFS 重定向) + 关闭挂载 启用 metamodule 时使用 metamodule,否则使用内建引擎。 始终使用内建 Folk Mount 引擎。 始终由 metamodule 负责挂载。 + 通过 NoMount VFS 驱动注入模块;驱动不可用时回退到内建引擎。 + 不注入任何内容;已安装模块在选择后端前保持不生效。 内建 Metamodule + NoMount + 无(已关闭挂载) 本次启动:%1$s 本次启动尚未挂载 重启后生效。 存在已启用的 metamodule;其脚本可能与内建引擎不兼容。 + NoMount 驱动无响应;本次启动已回退到内建引擎。 修改 Folk Mount 模式失败。 + NoMount + 驱动 + 注入 + 可见性 + NoMount 驱动 + 不可用 + 版本不匹配 + 就绪 + 驱动不可用(%1$s) + 重定向规则 + 已生效 %1$d / %2$d 条规则 + 未注入 + 清空规则 + 清除全部重定向;重启前模块都不再注入。 + 操作失败 + 内核拒绝了本次变更,请检查驱动状态后重试。 + 例外应用 + %1$d 个应用看到原厂文件系统 + 跟随各应用 Profile 的「卸载模块」设置 + 对隔离进程隐藏 + 隔离进程(WebView、SDK 沙箱等)同样只能看到原厂文件系统。 + 正在读取状态… + 状态不可用,请确保 ksud 已更新。 日志文件 加载 SU 日志失败 SU 日志未启用 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 632ba4e1b..89c8a4d28 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -295,16 +295,46 @@ Automatic Built-in Folk Mount Metamodule only + NoMount (VFS redirection) + Mounting off Use the metamodule when one is enabled, otherwise the built-in engine. Always use the built-in Folk Mount engine. Always let the metamodule handle mounting. + Inject modules through the NoMount VFS driver. Falls back to the built-in engine when the driver is unavailable. + Inject nothing. Installed modules stay inactive until a backend is selected. Built-in Metamodule + NoMount + Nothing (mounting off) This boot: %1$s Not mounted yet this boot Takes effect after reboot. An enabled metamodule is present; its scripts may not work with the built-in engine. + The NoMount driver is not answering; this boot falls back to the built-in engine. Failed to change the Folk Mount mode. + NoMount + Driver + Injection + Visibility + NoMount driver + Not available + Version mismatch + Ready + Driver unavailable (%1$s) + Redirection rules + %1$d of %2$d rules active + Not injected + Clear rules + Removes every redirection until the next reboot. Modules stop being injected. + Action failed + The kernel refused the change. Check the driver state and try again. + Excluded apps + %1$d apps see the stock filesystem + Follows each app profile\'s \"unmount modules\" setting + Hide from isolated processes + Isolated processes (WebView, SDK sandboxes) also see the stock filesystem. + Reading status… + Status unavailable. ksud must be up to date. Kernel does not support this feature This feature is managed by a module Enable (Default) diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 3c5b39c1d..7575653a7 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -537,9 +537,9 @@ enum Mount { json: bool, }, - /// Set mount mode: auto | builtin | metamodule + /// Set mount mode: auto | builtin | metamodule | nomount | off SetMode { - /// one of: auto, builtin, metamodule + /// one of: auto, builtin, metamodule, nomount, off mode: String, }, } diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index 7c064aca9..f9ffa65c1 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -29,6 +29,8 @@ pub enum MountMode { Builtin, Metamodule, Nomount, + /// Inject nothing: modules stay inert until a provider is chosen. + Off, } impl MountMode { @@ -39,6 +41,7 @@ impl MountMode { Self::Builtin => "builtin", Self::Metamodule => "metamodule", Self::Nomount => "nomount", + Self::Off => "off", } } @@ -50,6 +53,7 @@ impl MountMode { "builtin" => Some(Self::Builtin), "metamodule" => Some(Self::Metamodule), "nomount" => Some(Self::Nomount), + "off" => Some(Self::Off), _ => None, } } @@ -61,6 +65,8 @@ pub enum Provider { Builtin, Metamodule, Nomount, + /// No provider runs this boot. + None, } impl Provider { @@ -70,6 +76,7 @@ impl Provider { Self::Builtin => "builtin", Self::Metamodule => "metamodule", Self::Nomount => "nomount", + Self::None => "none", } } } @@ -147,9 +154,12 @@ struct RuntimeRecord { /// Read the configured mount mode from [`defs::FOLK_MOUNT_CONFIG`]. /// -/// A missing file or an unrecognised value falls back to [`MountMode::Auto`] -/// (the latter with a warning). Other I/O errors are returned so an unreadable -/// configuration never silently selects a provider. +/// A missing file selects [`MountMode::Nomount`] for a fresh install and +/// [`MountMode::Auto`] for a device that already mounted modules with an +/// earlier version, so upgrading never silently stops injecting them. An +/// unrecognised value falls back to [`MountMode::Auto`] with a warning. Other +/// I/O errors are returned so an unreadable configuration never silently +/// selects a provider. /// /// # Errors /// Returns an error when the configuration file exists but cannot be read. @@ -164,7 +174,23 @@ pub fn read_mount_mode() -> Result { ); MountMode::Auto })), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(MountMode::Auto), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // An earlier boot left a runtime record, so this device already had + // modules injected: keep the previous behaviour instead of switching + // the backend behind the user's back. + let upgraded = Path::new(defs::FOLK_MOUNT_RUNTIME).exists(); + let mode = if upgraded { + warn!("folk mount: no mode recorded but modules were mounted before; keeping auto"); + MountMode::Auto + } else { + MountMode::Nomount + }; + // Record the decision so the default only applies once. + if let Err(e) = write_mount_mode(mode) { + warn!("folk mount: could not persist the default mode: {e:#}"); + } + Ok(mode) + } Err(e) => Err(e).with_context(|| format!("failed to read {}", defs::FOLK_MOUNT_CONFIG)), } } @@ -228,6 +254,7 @@ fn metamodule_state() -> (Option, bool) { const fn provider_for(mode: MountMode, metamodule_enabled: bool) -> Provider { match mode { + MountMode::Off => Provider::None, MountMode::Metamodule => Provider::Metamodule, MountMode::Nomount => Provider::Nomount, MountMode::Auto if metamodule_enabled => Provider::Metamodule, @@ -698,6 +725,8 @@ fn run_provider(provider: Provider) -> ProviderRun { error, }, }, + // Handled before this point: mounting is disabled for this boot. + Provider::None => ProviderRun::Ok(MountOutcome::empty(Provider::None)), } } @@ -728,6 +757,20 @@ pub fn mount_selected_provider() -> Result { } let provider = resolve_provider()?; + if provider == Provider::None { + info!("folk mount: mounting is disabled (mode=off)"); + let boot_id = current_boot_id().unwrap_or_default(); + finish_runtime( + &boot_id, + provider, + "disabled", + false, + 0, + None, + None, + ); + return Ok(MountOutcome::empty(Provider::None)); + } ensure_init_mnt_ns()?; let boot_id = current_boot_id().unwrap_or_default(); From da6a15319436e68d5f8f4705370fcb0d742d343e Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:16:37 +0800 Subject: [PATCH 06/24] docs: Describe the built-in NoMount backend and its mount modes Also apply rustfmt to the provider dispatch. --- README.md | 6 ++++++ README_CN.md | 6 ++++++ userspace/ksud/src/magic_mount.rs | 10 +--------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 018cc01c0..f11427aa4 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ SUSFS management is capability-gated. The controls appear only on kernels that s Folk-Mount is the built-in module mount engine. It can run in auto, built-in, or metamodule mode. +### NoMount + +NoMount is the built-in VFS redirection backend: instead of mounting anything it takes over path resolution and directory iteration in a kernel module, so `/proc/mounts` stays clean. The module ships inside ksud (embedded per KMI, built by this repository); the backend needs neither a metamodule nor the `nm` binary. + +The manager exposes the driver state, the number of active rules, visibility exclusions and a "mounting off" mode; the backend can be set to automatic, built-in Folk Mount, metamodule only, NoMount or off. Choosing NoMount falls back to the built-in engine when the driver is unavailable and says so on the settings page. Visibility follows each app profile's "unmount modules" setting. + ## Security The manager authenticates itself by APK signature: only the trusted manager and apps the user has explicitly claimed can manage the kernel. Root access is whitelist-based, so only granted apps can reach `su`. See [SECURITY.md](./SECURITY.md) for vulnerability reporting. diff --git a/README_CN.md b/README_CN.md index b47c97a35..4e625734e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -59,6 +59,12 @@ SUSFS 管理按能力开关。仅在支持的内核上显示相关控件,每 Folk-Mount 是内置的模块挂载引擎,可在自动、内置或 metamodule 模式间切换。 +### NoMount + +NoMount 是内建的 VFS 路径重定向后端:不产生任何挂载,而是由内核模块直接接管路径解析与目录遍历,因此不会污染 `/proc/mounts`。内核模块随 ksud 一起分发(按 KMI 内嵌,由本仓库自行编译),后端本身不依赖模块或 `nm` 二进制。 + +管理器提供驱动状态、注入规则数、可见性例外与「关闭挂载」;挂载模式可选 `自动 / 内建 Folk Mount / 仅 metamodule / NoMount / 关闭`。选择 NoMount 时若驱动不可用会自动回退到内建引擎,并在设置页说明原因;可见性跟随各应用 Profile 的「卸载模块」设置。 + ## 安全提示 管理器通过 APK 签名验证自身身份:只有受信任的管理器以及用户显式认领的应用才能管理内核。Root 权限基于白名单,只有被授权的应用才能访问 `su`。漏洞上报请参阅 [SECURITY.md](./SECURITY.md)。 diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index f9ffa65c1..b32261c31 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -760,15 +760,7 @@ pub fn mount_selected_provider() -> Result { if provider == Provider::None { info!("folk mount: mounting is disabled (mode=off)"); let boot_id = current_boot_id().unwrap_or_default(); - finish_runtime( - &boot_id, - provider, - "disabled", - false, - 0, - None, - None, - ); + finish_runtime(&boot_id, provider, "disabled", false, 0, None, None); return Ok(MountOutcome::empty(Provider::None)); } ensure_init_mnt_ns()?; From ede5a16d69289cb3a90d4001f61cef4ec5654abc Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:33:20 +0800 Subject: [PATCH 07/24] nomount: Keep kernel-created virtual directories and label them Device testing showed the kernel creates intermediate directories to host rules whose parent path does not exist, and that reconcile treated those as stale: it deleted them and the kernel rebuilt them on the next add, reporting a pointless rules_removed=3 on every injection. - reconcile_rules skips rules flagged as kernel-created virtual directories. - Rule carries the virtual_dir flag, nomount list prints "(virtual dir)" and the JSON output exposes it. Verified on a real device (CPH2723, android15-6.6): two consecutive injections now report rules_removed=0 with the kernel's three virtual directories intact. --- userspace/ksud/src/nomount.rs | 14 ++++++ userspace/ksud/src/nomount_tests.rs | 73 ++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index 70a60828c..d8a02eda0 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -37,6 +37,9 @@ pub const ASSET_SUFFIX: &str = "_nomount.ko"; /// Whiteout marker: a character device with `rdev == 0`. pub const WHITEOUT_FLAG: u32 = 1 << 2; +/// Intermediate directory the kernel created to host a deeper rule. +pub const VIRTUAL_DIR_FLAG: u32 = 1 << 1; + /// Driver state as reported by [`probe`], never inferred from a load attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DriverState { @@ -96,6 +99,8 @@ pub struct Rule { pub virtual_path: String, pub real_path: Option, pub whiteout: bool, + /// True for directories the kernel created to host deeper rules. + pub virtual_dir: bool, pub uid: u32, } @@ -106,6 +111,7 @@ impl Rule { virtual_path: virtual_path.into(), real_path: None, whiteout: true, + virtual_dir: false, uid: 0, } } @@ -116,6 +122,7 @@ impl Rule { virtual_path: virtual_path.into(), real_path: Some(real_path.into()), whiteout: false, + virtual_dir: false, uid: 0, } } @@ -487,6 +494,9 @@ impl Driver { let stale: Vec = self .list_rules()? .into_iter() + // Directories the kernel created to host deeper rules are not ours + // to drop: they disappear on their own when their children go. + .filter(|rule| !rule.virtual_dir) .map(|rule| rule.virtual_path) .filter(|path| !wanted.contains(path.as_str())) .collect(); @@ -620,6 +630,7 @@ pub fn parse_list_records(data: &[u8]) -> Result> { virtual_path, real_path, whiteout: flags & WHITEOUT_FLAG != 0, + virtual_dir: flags & VIRTUAL_DIR_FLAG != 0, uid, }); cursor = end; @@ -1081,6 +1092,7 @@ pub fn print_rules(json: bool) -> Result<()> { "virtual": rule.virtual_path, "real": rule.real_path, "whiteout": rule.whiteout, + "virtual_dir": rule.virtual_dir, "uid": rule.uid, })) .collect::>(), @@ -1090,6 +1102,8 @@ pub fn print_rules(json: bool) -> Result<()> { for rule in &rules { if rule.whiteout { writeln!(out, "{} (whiteout)", rule.virtual_path) + } else if rule.virtual_dir { + writeln!(out, "{} (virtual dir)", rule.virtual_path) } else { writeln!( out, diff --git a/userspace/ksud/src/nomount_tests.rs b/userspace/ksud/src/nomount_tests.rs index e579a3864..3eaada341 100644 --- a/userspace/ksud/src/nomount_tests.rs +++ b/userspace/ksud/src/nomount_tests.rs @@ -75,6 +75,37 @@ fn decode_command(raw: u32) -> Command { } } +/// Encode one rule the way the kernel would report it. +/// +/// The client encoder only produces redirects and whiteouts; the kernel also +/// reports the intermediate directories it created, which carry no target. +fn kernel_record(rule: &Rule) -> Vec { + let flags = if rule.whiteout { + WHITEOUT_FLAG + } else if rule.virtual_dir { + VIRTUAL_DIR_FLAG + } else { + 0 + }; + let real = rule.real_path.as_deref().unwrap_or_default(); + let mut out = Vec::new(); + out.extend_from_slice(&flags.to_le_bytes()); + out.extend_from_slice(&rule.uid.to_le_bytes()); + out.extend_from_slice( + &u16::try_from(rule.virtual_path.len()) + .expect("path fits") + .to_le_bytes(), + ); + out.extend_from_slice( + &u16::try_from(real.len()) + .expect("target fits") + .to_le_bytes(), + ); + out.extend_from_slice(rule.virtual_path.as_bytes()); + out.extend_from_slice(real.as_bytes()); + out +} + impl Transport for Rc { fn send(&self, payload: &mut Payload) -> Result<()> { self.dispatch(payload) @@ -113,8 +144,7 @@ impl FakeKernel { let start = usize::try_from(payload.arg1)?; let mut cursor = 0_usize; for rule in rules.iter().skip(start) { - let encoded = encode_batches(std::slice::from_ref(rule))?; - let record = encoded.first().cloned().unwrap_or_default(); + let record = kernel_record(rule); if cursor + record.len() > BUFFER_SIZE { break; } @@ -293,6 +323,7 @@ fn redirect_without_a_target_is_rejected() { virtual_path: "/system/etc/hosts".to_string(), real_path: None, whiteout: false, + virtual_dir: false, uid: 0, }; assert!(encode_batches(std::slice::from_ref(&broken)).is_err()); @@ -439,6 +470,44 @@ fn a_later_rule_for_the_same_path_wins() { ); } +#[test] +fn reconcile_keeps_kernel_created_virtual_directories() { + let (driver, kernel) = driver(Mode::Ready); + // The fake kernel reports one of our rules and one directory it made up. + kernel.state.borrow_mut().rules.push(Rule { + virtual_path: "/system/etc/hosts".to_string(), + real_path: Some("/data/adb/modules/m/system/etc/hosts".to_string()), + whiteout: false, + virtual_dir: false, + uid: 0, + }); + kernel.state.borrow_mut().rules.push(Rule { + virtual_path: "/system/etc/newdir".to_string(), + real_path: None, + whiteout: false, + virtual_dir: true, + uid: 0, + }); + + let (applied, removed) = driver + .reconcile_rules(&[Rule::redirect( + "/system/etc/hosts", + "/data/adb/modules/m/system/etc/hosts", + )]) + .unwrap(); + assert_eq!( + (applied, removed), + (1, 0), + "the virtual directory must survive" + ); + assert!( + kernel + .written_rules() + .iter() + .any(|rule| rule.virtual_dir && rule.virtual_path == "/system/etc/newdir") + ); +} + #[test] fn visibility_config_round_trips_and_rejects_junk() { let dir = tempfile::tempdir().unwrap(); From 2793d43ae3a290a4612c1ca63b915343a1f63318 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:44:05 +0800 Subject: [PATCH 08/24] nomount: Report the injected rule count and separate it from the kernel total Device testing showed the NoMount provider left `target_count` empty in `ksud mount status`, and that the two commands disagreed on the rule count: `nomount status` reported everything the kernel holds (including the intermediate directories the kernel creates itself), while `mount status` reported what we published. - The per-boot record now carries the count for every provider that knows it, which is every provider except the metamodule's opaque script. - `nomount status` reports `rules_applied` for the rules we published and `kernel_rules` for the kernel total, so the manager and the CLI agree. Verified on a real device: mount status shows target_count=101 and nomount status shows rules_applied=101 with kernel_rules=104. --- userspace/ksud/src/magic_mount.rs | 6 ++++-- userspace/ksud/src/nomount.rs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index b32261c31..5484bb80a 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -807,7 +807,9 @@ pub fn mount_selected_provider() -> Result { &boot_id, provider, result, - provider == Provider::Builtin, + // The metamodule is a black box; the built-in engine and the + // NoMount backend both know how many targets they published. + provider != Provider::Metamodule, outcome.target_count, None, warning, @@ -828,7 +830,7 @@ pub fn mount_selected_provider() -> Result { &boot_id, provider, result, - provider == Provider::Builtin, + provider != Provider::Metamodule, outcome.target_count, Some(format!("{error:#}")), fallback, diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index d8a02eda0..39abeec4b 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -931,6 +931,8 @@ pub struct Status { pub probe: DriverProbe, pub expected_rules: usize, pub applied_rules: usize, + /// Everything the kernel holds, including directories it created itself. + pub kernel_rules: usize, pub uid_count: usize, pub isolated_blocked: bool, pub error: Option, @@ -948,6 +950,7 @@ impl Status { probe, expected_rules: plan.rules.len(), applied_rules: 0, + kernel_rules: 0, uid_count: 0, isolated_blocked: false, error: None, @@ -956,7 +959,12 @@ impl Status { status.error = Some("driver is not ready".to_string()); return Ok(status); } - status.applied_rules = driver.list_rules().map_or(0, |rules| rules.len()); + if let Ok(rules) = driver.list_rules() { + // Report the rules we published separately from the directories the + // kernel made up for them, so the two never contradict each other. + status.applied_rules = rules.iter().filter(|rule| !rule.virtual_dir).count(); + status.kernel_rules = rules.len(); + } status.uid_count = driver.list_uids().map_or(0, |uids| uids.len()); status.isolated_blocked = driver.isolated_state().unwrap_or(false); Ok(status) @@ -975,6 +983,7 @@ impl Status { "api_ok": self.probe.ready(), "rules_expected": self.expected_rules, "rules_applied": self.applied_rules, + "kernel_rules": self.kernel_rules, "uid_count": self.uid_count, "isolated_blocked": self.isolated_blocked, "error": self.error, @@ -996,6 +1005,8 @@ impl Status { .context("failed to write nomount status")?; writeln!(out, "rules_applied={}", self.applied_rules) .context("failed to write nomount status")?; + writeln!(out, "kernel_rules={}", self.kernel_rules) + .context("failed to write nomount status")?; writeln!(out, "uid_count={}", self.uid_count) .context("failed to write nomount status")?; writeln!(out, "isolated_blocked={}", self.isolated_blocked) From 5b50754664c79ce7cc2164feb05cb5f8c007960a Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 01:48:32 +0800 Subject: [PATCH 09/24] manager: Match the NoMount page top bar to the app design The page used a plain Scaffold with a compact TopAppBar and a bare arrow icon, which does not match the other pages. It now uses the shared ExpressiveScaffold, LargeFlexibleTopAppBar, TopBarBackButton and expressiveTopAppBarColors, with a Refresh action like the Susfs and Sulog pages have. Checked on device: the bar collapses while scrolling and the back button keeps the shared styling. --- .../ui/screen/nomount/NomountScreen.kt | 25 +++++++++++-------- .../src/main/res/values-zh-rCN/strings.xml | 1 + manager/app/src/main/res/values/strings.xml | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index ad069a374..44528ab7d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.PlayArrow @@ -16,10 +15,9 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold +import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -34,9 +32,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel import me.weishu.kernelsu.R import me.weishu.kernelsu.data.model.NomountDriverState import me.weishu.kernelsu.data.model.NomountStatus +import me.weishu.kernelsu.ui.component.material.ExpressiveScaffold import me.weishu.kernelsu.ui.component.material.SegmentedColumn import me.weishu.kernelsu.ui.component.material.SegmentedListItem import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem +import me.weishu.kernelsu.ui.component.material.TopBarBackButton +import me.weishu.kernelsu.ui.component.material.expressiveTopAppBarColors import me.weishu.kernelsu.ui.navigation3.LocalNavigator import me.weishu.kernelsu.ui.viewmodel.NomountUiState import me.weishu.kernelsu.ui.viewmodel.NomountViewModel @@ -62,24 +63,26 @@ fun NomountScreen() { onPauseOrDispose { } } - Scaffold( + ExpressiveScaffold( topBar = { - TopAppBar( + LargeFlexibleTopAppBar( title = { Text(stringResource(R.string.settings_nomount)) }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + navigationIcon = { TopBarBackButton(onClick = { navigator.pop() }) }, + actions = { + TextButton(onClick = viewModel::refresh) { + Text(stringResource(R.string.nomount_refresh)) } }, + colors = expressiveTopAppBarColors(), scrollBehavior = scrollBehavior, ) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + } ) { inner -> Column( modifier = Modifier .fillMaxSize() .padding(inner) + .nestedScroll(scrollBehavior.nestedScrollConnection) .verticalScroll(rememberScrollState()) .padding(PaddingValues(16.dp)), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 9ad5cbbb2..0f8e39bc6 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -523,6 +523,7 @@ 隔离进程(WebView、SDK 沙箱等)同样只能看到原厂文件系统。 正在读取状态… 状态不可用,请确保 ksud 已更新。 + 刷新 日志文件 加载 SU 日志失败 SU 日志未启用 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 89c8a4d28..13a65c7ad 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -335,6 +335,7 @@ Isolated processes (WebView, SDK sandboxes) also see the stock filesystem. Reading status… Status unavailable. ksud must be up to date. + Refresh Kernel does not support this feature This feature is managed by a module Enable (Default) From 54f15671f5459b37d7c837cd266cb40511ba3945 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 02:09:21 +0800 Subject: [PATCH 10/24] nomount: Add per-module hot load, unload, rule counts and skip mount The upstream metamodule UI could inject or drop a single module's rules without rebooting, and showed how many files each module injects. Both are now available to the built-in backend. - nomount_plan exposes per-module collection, including a variant that ignores disable/skip marks because a module that was just turned off still has the paths that need removing. - `ksud nomount hot-load|hot-unload ` and per-module counts plus the UID arrays in `nomount status` (schema 2). - `ksud module skip [--off]` writes the skip_mount marker the planners already honour. Verified on device: hot-unload takes the module from 101 rules to 0, hot-load restores 101, and the skip marker toggles. --- userspace/ksud/src/cli.rs | 34 ++++++++- userspace/ksud/src/module.rs | 20 +++++ userspace/ksud/src/nomount.rs | 119 ++++++++++++++++++++++++++++- userspace/ksud/src/nomount_plan.rs | 67 ++++++++++++++-- 4 files changed, 228 insertions(+), 12 deletions(-) diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 7575653a7..43d1c1a4a 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -351,6 +351,15 @@ enum Module { id: String, }, + /// keep a module enabled but stop injecting its files + Skip { + /// module id + id: String, + /// remove the skip mark instead of setting it + #[arg(long)] + off: bool, + }, + /// run action for module Action { // module id @@ -573,6 +582,18 @@ enum Nomount { /// Drop every redirection rule; UID exclusions are kept Clear, + /// Add one module's rules to the running kernel without a reboot + HotLoad { + /// module id + id: String, + }, + + /// Remove one module's rules from the running kernel without a reboot + HotUnload { + /// module id + id: String, + }, + /// Manage the UID exclusions (apps that see the stock filesystem) Uid { #[command(subcommand)] @@ -591,7 +612,7 @@ enum NomountUid { /// Replace the stored list with these UIDs, then apply it Set { - #[arg(required = true)] + /// UIDs to keep; omit to clear the list uids: Vec, }, @@ -717,6 +738,7 @@ pub fn run() -> Result<()> { Module::Uninstall { id } => module::uninstall_module(&id), Module::Enable { id } => module::enable_module(&id), Module::Disable { id } => module::disable_module(&id), + Module::Skip { id, off } => module::set_skip_mount(&id, !off), Module::Action { id } => module::run_action(&id), Module::List => module::list_modules(), Module::Config { internal, command } => { @@ -1010,6 +1032,16 @@ pub fn run() -> Result<()> { Nomount::Inject { dry_run, json } => crate::nomount::inject(dry_run, json), Nomount::List { json } => crate::nomount::print_rules(json), Nomount::Clear => crate::nomount::clear_rules(), + Nomount::HotLoad { id } => { + let applied = crate::nomount::hot_load(&id)?; + println!("rules_applied={applied}"); + Ok(()) + } + Nomount::HotUnload { id } => { + let removed = crate::nomount::hot_unload(&id)?; + println!("rules_removed={removed}"); + Ok(()) + } Nomount::Uid { command } => match command { NomountUid::List { json } => crate::nomount::uid_edit(None, &[], json), NomountUid::Set { uids } => { diff --git a/userspace/ksud/src/module.rs b/userspace/ksud/src/module.rs index efef36398..1e5a581cc 100644 --- a/userspace/ksud/src/module.rs +++ b/userspace/ksud/src/module.rs @@ -848,6 +848,26 @@ pub fn run_action(id: &str) -> Result<()> { exec_script(&action_script_path, ScriptWait::Forever) } +/// Mark or unmark a module as `skip_mount`: it keeps running, but the mount +/// backends stop injecting its files. +/// +/// # Errors +/// Returns an error for an invalid id, a missing module, or a failed write. +pub fn set_skip_mount(id: &str, skip: bool) -> Result<()> { + validate_module_id(id)?; + let module_path = Path::new(defs::MODULE_DIR).join(id); + ensure!(module_path.exists(), "Module {id} not found"); + let marker = module_path.join(defs::SKIP_MOUNT_FILE_NAME); + if skip { + ensure_file_exists(&marker)?; + } else if let Err(e) = std::fs::remove_file(&marker) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(e).with_context(|| format!("remove {}", marker.display())); + } + Ok(()) +} + pub fn enable_module(id: &str) -> Result<()> { validate_module_id(id)?; diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index 39abeec4b..9b64a295b 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -688,6 +688,81 @@ pub fn log_probe(probe: &DriverProbe) { /// Returns an error when the module tree cannot be walked. #[cfg(target_os = "android")] pub fn collect_plan() -> Result { + collect_plan_filtered(None) +} + +/// Rules one module currently asks for; an empty plan when it is off. +/// +/// # Errors +/// Returns an error when the module tree cannot be walked. +#[cfg(target_os = "android")] +pub fn collect_plan_for(id: &str) -> Result { + collect_plan_filtered(Some(id)) +} + +#[cfg(target_os = "android")] +fn collect_plan_filtered(only: Option<&str>) -> Result { + let cfg = crate::nomount_plan::PlanConfig::new( + std::path::Path::new(crate::defs::MODULE_DIR), + std::path::Path::new("/"), + std::path::Path::new("/system"), + ); + let fs = crate::nomount_plan::StdFs::new( + crate::magic_mount::read_opaque as fn(&std::path::Path) -> Result, + ); + only.map_or_else( + || crate::nomount_plan::collect_rules(&fs, &cfg), + |id| crate::nomount_plan::collect_rules_for(&fs, &cfg, id), + ) +} + +/// Rule counts per module, for the per-module list in the manager. +/// +/// # Errors +/// Returns an error when the module tree cannot be walked. +#[cfg(target_os = "android")] +pub fn collect_rule_counts() -> Result> { + let cfg = crate::nomount_plan::PlanConfig::new( + std::path::Path::new(crate::defs::MODULE_DIR), + std::path::Path::new("/"), + std::path::Path::new("/system"), + ); + let fs = crate::nomount_plan::StdFs::new( + crate::magic_mount::read_opaque as fn(&std::path::Path) -> Result, + ); + crate::nomount_plan::rule_counts(&fs, &cfg) +} + +/// Generate one module's rules and add them to the running kernel. +/// +/// The module keeps its place in the boot-time plan; a disabled or skipped +/// module contributes nothing and says so. +/// +/// # Errors +/// Returns an error when the driver is not usable or the kernel refuses a rule. +#[cfg(target_os = "android")] +pub fn hot_load(id: &str) -> Result { + let driver = Driver::kernel(); + ensure_usable(&driver.probe())?; + let plan = collect_plan_for(id)?; + if plan.rules.is_empty() + && let Some((_, reason)) = plan.skipped.first() + { + bail!("nomount: module {id} is {reason}"); + } + driver.apply_rules(&plan.rules) +} + +/// Remove one module's rules from the running kernel. +/// +/// The module's own marks are ignored on purpose: a module that was just +/// disabled still has the paths it injected, and those are the ones to drop. +/// +/// # Errors +/// Returns an error when the module tree cannot be walked or the kernel +/// refuses a deletion. +#[cfg(target_os = "android")] +pub fn hot_unload(id: &str) -> Result { let cfg = crate::nomount_plan::PlanConfig::new( std::path::Path::new(crate::defs::MODULE_DIR), std::path::Path::new("/"), @@ -696,7 +771,15 @@ pub fn collect_plan() -> Result { let fs = crate::nomount_plan::StdFs::new( crate::magic_mount::read_opaque as fn(&std::path::Path) -> Result, ); - crate::nomount_plan::collect_rules(&fs, &cfg) + let plan = crate::nomount_plan::collect_rules_ignoring_marks(&fs, &cfg, id)?; + let paths: Vec = plan + .rules + .iter() + .map(|rule| rule.virtual_path.clone()) + .collect(); + let count = paths.len(); + Driver::kernel().delete_rules(&paths)?; + Ok(count) } /// What the manager wants the visibility policy to be. @@ -933,6 +1016,12 @@ pub struct Status { pub applied_rules: usize, /// Everything the kernel holds, including directories it created itself. pub kernel_rules: usize, + /// Rule count per module that contributes rules. + pub modules: Vec<(String, usize)>, + /// UIDs the stored policy wants excluded from redirection. + pub uids: Vec, + /// UIDs the kernel is excluding right now. + pub uids_active: Vec, pub uid_count: usize, pub isolated_blocked: bool, pub error: Option, @@ -951,6 +1040,10 @@ impl Status { expected_rules: plan.rules.len(), applied_rules: 0, kernel_rules: 0, + modules: collect_rule_counts().unwrap_or_default(), + uids: read_uid_config(std::path::Path::new(crate::defs::NOMOUNT_UIDS)) + .map_or_else(|_| Vec::new(), |config| config.uids), + uids_active: Vec::new(), uid_count: 0, isolated_blocked: false, error: None, @@ -965,7 +1058,10 @@ impl Status { status.applied_rules = rules.iter().filter(|rule| !rule.virtual_dir).count(); status.kernel_rules = rules.len(); } - status.uid_count = driver.list_uids().map_or(0, |uids| uids.len()); + if let Ok(uids) = driver.list_uids() { + status.uid_count = uids.len(); + status.uids_active = uids; + } status.isolated_blocked = driver.isolated_state().unwrap_or(false); Ok(status) } @@ -977,20 +1073,27 @@ impl Status { let mut out = stdout.lock(); if json { let payload = serde_json::json!({ - "schema_version": 1, + "schema_version": 2, "driver_state": self.probe.state.as_str(), "driver_version": self.probe.version, "api_ok": self.probe.ready(), "rules_expected": self.expected_rules, "rules_applied": self.applied_rules, "kernel_rules": self.kernel_rules, + "modules": self + .modules + .iter() + .map(|(id, rules)| serde_json::json!({ "id": id, "rules": rules })) + .collect::>(), + "uids": self.uids, + "uids_active": self.uids_active, "uid_count": self.uid_count, "isolated_blocked": self.isolated_blocked, "error": self.error, }); writeln!(out, "{payload}").context("failed to write nomount status")?; } else { - writeln!(out, "schema_version=1").context("failed to write nomount status")?; + writeln!(out, "schema_version=2").context("failed to write nomount status")?; writeln!(out, "driver_state={}", self.probe.state.as_str()) .context("failed to write nomount status")?; writeln!( @@ -1007,6 +1110,14 @@ impl Status { .context("failed to write nomount status")?; writeln!(out, "kernel_rules={}", self.kernel_rules) .context("failed to write nomount status")?; + for (id, rules) in &self.modules { + writeln!(out, "module={id} rules={rules}") + .context("failed to write nomount status")?; + } + writeln!(out, "uids={}", join_uids(&self.uids)) + .context("failed to write nomount status")?; + writeln!(out, "uids_active={}", join_uids(&self.uids_active)) + .context("failed to write nomount status")?; writeln!(out, "uid_count={}", self.uid_count) .context("failed to write nomount status")?; writeln!(out, "isolated_blocked={}", self.isolated_blocked) diff --git a/userspace/ksud/src/nomount_plan.rs b/userspace/ksud/src/nomount_plan.rs index 957ba0f06..440c86fce 100644 --- a/userspace/ksud/src/nomount_plan.rs +++ b/userspace/ksud/src/nomount_plan.rs @@ -225,18 +225,71 @@ fn is_remapped(name: &str) -> bool { /// Returns an error when the modules directory or a reachable module subtree /// cannot be read. pub fn collect_rules(fs: &impl PlanFs, cfg: &PlanConfig<'_>) -> Result { - let mut plan = Plan::default(); + collect_rules_filtered(fs, cfg, None, true) +} + +/// Build the rule list for one module, for the per-module hot load/unload path. +/// +/// # Errors +/// Returns an error when the modules directory or the module subtree cannot be +/// read. An unknown id yields an empty plan. +pub fn collect_rules_for(fs: &impl PlanFs, cfg: &PlanConfig<'_>, id: &str) -> Result { + collect_rules_filtered(fs, cfg, Some(id), true) +} + +/// Every rule a module would contribute, ignoring its `disable`/`skip_mount` +/// marks: the paths a hot unload has to remove even though the module is off. +/// +/// # Errors +/// Returns an error when the module subtree cannot be read. +pub fn collect_rules_ignoring_marks( + fs: &impl PlanFs, + cfg: &PlanConfig<'_>, + id: &str, +) -> Result { + collect_rules_filtered(fs, cfg, Some(id), false) +} + +/// Rule count for every module that contributes rules, in plan order. +/// +/// # Errors +/// Returns an error when the modules directory or a module subtree cannot be +/// read. +pub fn rule_counts(fs: &impl PlanFs, cfg: &PlanConfig<'_>) -> Result> { + let mut counts = Vec::new(); + for id in module_ids(fs, cfg)? { + let plan = collect_rules_filtered(fs, cfg, Some(&id), true)?; + if !plan.rules.is_empty() { + counts.push((id, plan.rules.len())); + } + } + Ok(counts) +} + +fn module_ids(fs: &impl PlanFs, cfg: &PlanConfig<'_>) -> Result> { let mut modules = fs.read_dir(cfg.modules_dir)?; modules.sort_unstable(); + modules.retain(|id| matches!(fs.kind(&cfg.modules_dir.join(id)), Ok(EntryKind::Dir))); + Ok(modules) +} - for id in modules { - let module = cfg.modules_dir.join(&id); - if fs.kind(&module)? != EntryKind::Dir { +fn collect_rules_filtered( + fs: &impl PlanFs, + cfg: &PlanConfig<'_>, + only: Option<&str>, + honor_marks: bool, +) -> Result { + let mut plan = Plan::default(); + + for id in module_ids(fs, cfg)? { + if only.is_some_and(|only| only != id) { continue; } - if let Some(mark) = SKIP_MARKS - .iter() - .find(|mark| fs.exists(&module.join(mark)).unwrap_or(false)) + let module = cfg.modules_dir.join(&id); + if honor_marks + && let Some(mark) = SKIP_MARKS + .iter() + .find(|mark| fs.exists(&module.join(mark)).unwrap_or(false)) { plan.skipped.push((id.clone(), (*mark).to_string())); continue; From 191b20701b4a63a493b0261557bc9130ec62751b Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 02:09:21 +0800 Subject: [PATCH 11/24] manager: Add the NoMount module list and app exclusion picker Closes the two features the upstream metamodule WebUI had and the built-in page lacked. - The NoMount page lists every module that contributes rules with its rule count; tapping a module injects it immediately and the trailing button drops it until the next reboot. - "Excluded apps" opens a picker over the installed apps (search, show system apps, per-row checkbox) whose selection is saved in one go, plus a clear action on the page. The exclusion list is what hides injected files from an app now that nothing is mounted. - NomountStatus reads schema 1 and 2 and carries the module and UID lists. Verified on device: selecting two apps persists uids 10370/10496 into the kernel policy, clearing empties it, and the module list renders with counts. --- .../kernelsu/data/model/NomountStatus.kt | 44 ++++- .../data/repository/SettingsRepository.kt | 4 + .../data/repository/SettingsRepositoryImpl.kt | 43 +++++ .../me/weishu/kernelsu/ui/MainActivity.kt | 3 + .../weishu/kernelsu/ui/navigation3/Routes.kt | 4 + .../screen/nomount/NomountExclusionsScreen.kt | 181 ++++++++++++++++++ .../ui/screen/nomount/NomountScreen.kt | 75 +++++++- .../kernelsu/ui/viewmodel/NomountViewModel.kt | 79 ++++++++ .../src/main/res/values-zh-rCN/strings.xml | 13 ++ manager/app/src/main/res/values/strings.xml | 13 ++ 10 files changed, 452 insertions(+), 7 deletions(-) create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt index 2491d9fe2..ec337c10d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt @@ -20,7 +20,14 @@ enum class NomountDriverState(val token: String) { } } -/** Decoded `ksud nomount status --json` payload (`schema_version = 1`). */ +/** One module that contributes redirection rules. */ +@Immutable +data class NomountModule( + val id: String, + val rules: Int, +) + +/** Decoded `ksud nomount status --json` payload (`schema_version` 1 or 2). */ @Immutable data class NomountStatus( val driverState: NomountDriverState, @@ -28,6 +35,14 @@ data class NomountStatus( val apiOk: Boolean, val rulesExpected: Int, val rulesApplied: Int, + /** Everything the kernel holds, including directories it created. */ + val kernelRules: Int, + /** Modules that contribute rules, with their rule counts. */ + val modules: List, + /** UIDs the stored policy excludes from redirection. */ + val uids: List, + /** UIDs the kernel excludes right now. */ + val uidsActive: List, val uidCount: Int, val isolatedBlocked: Boolean, val error: String?, @@ -37,7 +52,9 @@ data class NomountStatus( get() = apiOk && rulesApplied >= rulesExpected && rulesExpected > 0 companion object { - const val SCHEMA_VERSION = 1 + /** Newest schema this app understands; older schemas stay readable. */ + const val SCHEMA_VERSION = 2 + private const val OLDEST_SCHEMA_VERSION = 1 /** * @throws IllegalStateException when the payload is not a NoMount status @@ -45,17 +62,31 @@ data class NomountStatus( * inventing a state. */ fun fromJson(json: JSONObject): NomountStatus { - check(json.optInt("schema_version", -1) == SCHEMA_VERSION) { - "unsupported nomount schema_version ${json.optInt("schema_version", -1)}" + val schema = json.optInt("schema_version", -1) + check(schema in OLDEST_SCHEMA_VERSION..SCHEMA_VERSION) { + "unsupported nomount schema_version $schema" } val state = NomountDriverState.fromToken(json.optStringOrNull("driver_state")) ?: error("nomount status has no valid driver_state") + val modules = json.optJSONArray("modules")?.let { array -> + List(array.length()) { index -> + val entry = array.optJSONObject(index) ?: error("invalid module entry") + NomountModule( + id = entry.optStringOrNull("id") ?: error("module entry without id"), + rules = entry.optInt("rules", 0), + ) + } + }.orEmpty() return NomountStatus( driverState = state, driverVersion = json.optStringOrNull("driver_version"), apiOk = json.optBoolean("api_ok", false), rulesExpected = json.optInt("rules_expected", 0), rulesApplied = json.optInt("rules_applied", 0), + kernelRules = json.optInt("kernel_rules", json.optInt("rules_applied", 0)), + modules = modules, + uids = json.optIntList("uids"), + uidsActive = json.optIntList("uids_active"), uidCount = json.optInt("uid_count", 0), isolatedBlocked = json.optBoolean("isolated_blocked", false), error = json.optStringOrNull("error"), @@ -66,3 +97,8 @@ data class NomountStatus( private fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else optString(key, "").takeIf { it.isNotEmpty() } + +private fun JSONObject.optIntList(key: String): List { + val array = optJSONArray(key) ?: return emptyList() + return List(array.length()) { index -> array.optInt(index, 0) } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt index 26d7e856b..9c52f12e7 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt @@ -89,6 +89,10 @@ interface SettingsRepository { suspend fun setNomountIsolated(enabled: Boolean): Result suspend fun injectNomountRules(): Result suspend fun clearNomountRules(): Result + suspend fun setNomountUids(uids: List): Result + suspend fun hotLoadNomountModule(id: String): Result + suspend fun hotUnloadNomountModule(id: String): Result + suspend fun setModuleSkip(id: String, skip: Boolean): Result suspend fun getAdbRootStatus(): String suspend fun getAdbRootPersistValue(): Long? diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt index 5ecda7e21..6f5a56d76 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt @@ -40,6 +40,14 @@ private const val KEY_USE_SOFT_REBOOT = "soft_reboot" val DEFAULT_BACKUP_PARTITIONS = setOf("boot", "init_boot", "vendor_boot") /** Prefer soft reboot: always in jailbreak mode, or when the setting is enabled. */ +/** + * Single-quote a value for the root shell. + * + * Module ids are already validated by ksud; this keeps the command line + * unambiguous even if a caller passes something unexpected. + */ +private fun shellQuote(value: String): String = "'" + value.replace("'", "'\"'\"'") + "'" + fun isSoftRebootPreferred(): Boolean = Natives.isLateLoadMode || ksuApp.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_USE_SOFT_REBOOT, false) @@ -368,6 +376,41 @@ class SettingsRepositoryImpl : SettingsRepository { } } + override suspend fun setNomountUids(uids: List): Result = withContext(Dispatchers.IO) { + runCatching { + // UIDs are integers from the app list; nothing user-typed reaches the shell. + val list = uids.distinct().sorted().joinToString(" ") + // An empty list clears every exclusion. + val command = if (list.isEmpty()) "nomount uid set" else "nomount uid set $list" + check(execKsud(command, true)) { "ksud nomount uid set failed" } + } + } + + override suspend fun hotLoadNomountModule(id: String): Result = withContext(Dispatchers.IO) { + runCatching { + check(execKsud("nomount hot-load ${shellQuote(id)}", true)) { + "ksud nomount hot-load failed" + } + } + } + + override suspend fun hotUnloadNomountModule(id: String): Result = withContext(Dispatchers.IO) { + runCatching { + check(execKsud("nomount hot-unload ${shellQuote(id)}", true)) { + "ksud nomount hot-unload failed" + } + } + } + + override suspend fun setModuleSkip(id: String, skip: Boolean): Result = withContext(Dispatchers.IO) { + runCatching { + val flag = if (skip) "" else " --off" + check(execKsud("module skip ${shellQuote(id)}$flag", true)) { + "ksud module skip failed" + } + } + } + override suspend fun getAdbRootStatus(): String = getFeatureStatus("adb_root") override suspend fun getShellSuAdbOnlyStatus(): String = getFeatureStatus("shell_su_adb_only") diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt index 41022e20d..18e58abcc 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt @@ -287,6 +287,9 @@ open class MainActivity : FragmentActivity() { entry { me.weishu.kernelsu.ui.screen.nomount.NomountScreen() } + entry { + me.weishu.kernelsu.ui.screen.nomount.NomountExclusionsScreen() + } entry { ColorPaletteScreen() } entry { AppProfileTemplateScreen() } entry { key -> TemplateEditorScreen(key.template, key.readOnly) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt index 213150139..c4b54595d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/navigation3/Routes.kt @@ -57,6 +57,10 @@ sealed interface Route : NavKey, Parcelable { @Serializable data object Nomount : Route + @Parcelize + @Serializable + data object NomountExclusions : Route + @Parcelize @Serializable data object ColorPalette : Route diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt new file mode 100644 index 000000000..3cc0f951e --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt @@ -0,0 +1,181 @@ +package me.weishu.kernelsu.ui.screen.nomount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.component.material.ExpressiveScaffold +import me.weishu.kernelsu.ui.component.material.SegmentedColumn +import me.weishu.kernelsu.ui.component.material.SegmentedListItem +import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem +import me.weishu.kernelsu.ui.component.material.SegmentedTextField +import me.weishu.kernelsu.ui.component.material.TopBarBackButton +import me.weishu.kernelsu.ui.component.material.expressiveTopAppBarColors +import me.weishu.kernelsu.ui.navigation3.LocalNavigator +import me.weishu.kernelsu.ui.viewmodel.NomountViewModel + +/** + * Pick the apps that must see the stock filesystem. + * + * The selection is a list of UIDs: an app with a shared UID selects every app + * that reports it, which is why rows show the UID next to the label. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NomountExclusionsScreen() { + val viewModel = viewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current + val scrollBehavior = TopAppBarDefaults + .exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + LaunchedEffect(Unit) { viewModel.loadApps() } + + // The selection starts from what the kernel policy stores and is applied + // only when the user saves. + var selected by remember(state.status?.uids) { + mutableStateOf(state.status?.uids?.toSet() ?: emptySet()) + } + var query by rememberSaveable { mutableStateOf("") } + + val visible = state.apps.filter { app -> + (state.showSystemApps || !app.isSystem) && + (query.isBlank() || + app.label.contains(query, ignoreCase = true) || + app.packageName.contains(query, ignoreCase = true)) + } + + ExpressiveScaffold( + topBar = { + LargeFlexibleTopAppBar( + title = { Text(stringResource(R.string.nomount_exclusions_title)) }, + navigationIcon = { TopBarBackButton(onClick = { navigator.pop() }) }, + actions = { + TextButton( + enabled = !state.isWriting, + onClick = { + viewModel.saveExclusions(selected.toList()) + navigator.pop() + }, + ) { + Text(stringResource(R.string.nomount_save)) + } + }, + colors = expressiveTopAppBarColors(), + scrollBehavior = scrollBehavior, + ) + } + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + SegmentedColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + item { + SegmentedTextField( + label = stringResource(R.string.nomount_search_apps), + value = query, + onValueChange = { query = it }, + singleLine = true, + ) + } + item { + SegmentedSwitchItem( + title = stringResource(R.string.nomount_show_system_apps), + checked = state.showSystemApps, + onCheckedChange = viewModel::setShowSystemApps, + ) + } + } + + if (state.appsLoading) { + Text( + text = stringResource(R.string.nomount_loading), + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + Text( + text = stringResource( + R.string.nomount_selected_count, + selected.size, + ), + style = androidx.compose.material3.MaterialTheme.typography.titleSmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), + ) + } + items(visible, key = { it.uid }) { app -> + val checked = selected.contains(app.uid) + SegmentedListItem( + enabled = !state.isWriting, + onClick = { + selected = if (checked) { + selected + app.uid + } else { + selected - app.uid + } + }, + headlineContent = { Text(app.label) }, + supportingContent = { + Text( + stringResource( + R.string.nomount_app_uid, + app.packageName, + app.uid, + ) + ) + }, + trailingContent = { + androidx.compose.material3.Checkbox( + checked = checked, + // The checkbox is its own touch target; the row + // toggles too, so either gesture works. + onCheckedChange = { nowChecked -> + selected = if (nowChecked) { + selected + app.uid + } else { + selected - app.uid + } + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index 44528ab7d..76bdf3759 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.PlayArrow @@ -39,6 +40,7 @@ import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem import me.weishu.kernelsu.ui.component.material.TopBarBackButton import me.weishu.kernelsu.ui.component.material.expressiveTopAppBarColors import me.weishu.kernelsu.ui.navigation3.LocalNavigator +import me.weishu.kernelsu.ui.navigation3.Route import me.weishu.kernelsu.ui.viewmodel.NomountUiState import me.weishu.kernelsu.ui.viewmodel.NomountViewModel @@ -89,6 +91,7 @@ fun NomountScreen() { ) { DriverSection(state, viewModel) InjectionSection(state, viewModel) + ModulesSection(state, viewModel) VisibilitySection(state, viewModel) } } @@ -177,17 +180,37 @@ private fun rulesSummary(status: NomountStatus?): String { private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel) { val status = state.status val ready = status?.apiOk == true && !state.isWriting + val navigator = LocalNavigator.current SegmentedColumn(title = stringResource(R.string.nomount_section_visibility)) { item { SegmentedListItem( headlineContent = { Text(stringResource(R.string.nomount_uid_exclusions)) }, supportingContent = { - Text(stringResource(R.string.nomount_uid_exclusions_value, status?.uidCount ?: 0)) + Text( + stringResource( + R.string.nomount_uid_exclusions_value, + status?.uidsActive?.size ?: (status?.uidCount ?: 0), + ) + ) }, overlineContent = { Text(stringResource(R.string.nomount_uid_source_hint)) }, leadingContent = { Icon(Icons.Filled.VisibilityOff, null) }, - enabled = false, - onClick = {}, + enabled = ready, + onClick = { navigator.push(Route.NomountExclusions) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) + }, + ) + } + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_clear_exclusions)) }, + supportingContent = { + Text(stringResource(R.string.nomount_clear_exclusions_summary)) + }, + leadingContent = { Icon(Icons.Filled.DeleteSweep, null) }, + enabled = ready && status?.uids?.isNotEmpty() == true, + onClick = viewModel::clearExclusions, ) } item { @@ -202,3 +225,49 @@ private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel } } } + +/** + * Per-module control: how many rules each module asks for, and the hot + * load/unload the metamodule's WebUI offered without a reboot. + */ +@Composable +private fun ModulesSection(state: NomountUiState, viewModel: NomountViewModel) { + val status = state.status + val modules = status?.modules.orEmpty() + if (modules.isEmpty()) return + val ready = status?.apiOk == true && !state.isWriting + SegmentedColumn(title = stringResource(R.string.nomount_section_modules)) { + item { + SegmentedListItem( + headlineContent = { + Text(stringResource(R.string.nomount_modules_count, modules.size)) + }, + supportingContent = { Text(stringResource(R.string.nomount_modules_hint)) }, + leadingContent = { Icon(Icons.Filled.Layers, null) }, + enabled = false, + onClick = {}, + ) + } + modules.forEach { module -> + item(key = module.id) { + SegmentedListItem( + headlineContent = { Text(module.id) }, + supportingContent = { + Text(stringResource(R.string.nomount_module_rules, module.rules)) + }, + leadingContent = { Icon(Icons.Filled.PlayArrow, null) }, + enabled = ready, + onClick = { viewModel.hotLoad(module.id) }, + trailingContent = { + TextButton( + enabled = ready, + onClick = { viewModel.hotUnload(module.id) }, + ) { + Text(stringResource(R.string.nomount_hot_unload)) + } + }, + ) + } + } + } +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt index 2ef07306f..54f5daacb 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt @@ -8,9 +8,21 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.weishu.kernelsu.data.model.NomountStatus import me.weishu.kernelsu.data.repository.SettingsRepository import me.weishu.kernelsu.data.repository.SettingsRepositoryImpl +import me.weishu.kernelsu.ui.util.execKsudResult + +/** One row of the exclusion picker. */ +@Immutable +data class ExclusionApp( + val uid: Int, + val label: String, + val packageName: String, + val isSystem: Boolean, + val excluded: Boolean, +) /** * NoMount detail screen state. @@ -25,6 +37,9 @@ data class NomountUiState( val status: NomountStatus? = null, val readError: Boolean = false, val actionError: Boolean = false, + val apps: List = emptyList(), + val appsLoading: Boolean = true, + val showSystemApps: Boolean = false, ) class NomountViewModel( @@ -59,6 +74,39 @@ class NomountViewModel( fun setIsolated(enabled: Boolean) = runAction { repo.setNomountIsolated(enabled) } + /** Add a module's rules without waiting for a reboot. */ + fun hotLoad(id: String) = runAction { repo.hotLoadNomountModule(id) } + + /** Drop a module's rules without waiting for a reboot. */ + fun hotUnload(id: String) = runAction { repo.hotUnloadNomountModule(id) } + + /** Keep a module running but stop injecting its files (and re-apply at once). */ + fun setSkip(id: String, skip: Boolean) = + runAction { + repo.setModuleSkip(id, skip).also { result -> + if (result.isSuccess) repo.injectNomountRules() + } + } + + fun setShowSystemApps(show: Boolean) { + _uiState.update { it.copy(showSystemApps = show) } + } + + /** Load the installed apps so the exclusion picker can offer them. */ + fun loadApps() { + if (_uiState.value.apps.isNotEmpty() || !_uiState.value.appsLoading) return + viewModelScope.launch { + val apps = NomountAppList.load() + _uiState.update { it.copy(apps = apps, appsLoading = false) } + } + } + + /** Persist the exclusions chosen in the picker. */ + fun saveExclusions(uids: List) = runAction { repo.setNomountUids(uids) } + + /** Drop every exclusion. */ + fun clearExclusions() = runAction { repo.setNomountUids(emptyList()) } + private fun runAction(block: suspend () -> Result) { if (_uiState.value.isWriting) return _uiState.update { it.copy(isWriting = true, actionError = false) } @@ -78,3 +126,34 @@ class NomountViewModel( } } } + +/** + * Installed apps for the picker, loaded through the manager's own app list. + * + * The exclusion list is a list of UIDs, so a shared UID (for example a shared + * system user id) selects every app that reports it. + */ +private object NomountAppList { + suspend fun load(): List = withContext(kotlinx.coroutines.Dispatchers.IO) { + val result = execKsudResult("nomount status --json") + val excluded = runCatching { + org.json.JSONObject(result.out.firstOrNull { it.isNotBlank() }.orEmpty()) + .optJSONArray("uids") + ?.let { array -> List(array.length()) { index -> array.optInt(index, 0) } } + .orEmpty() + }.getOrDefault(emptyList()) + SuperUserViewModel().let { vm -> + if (SuperUserViewModel.apps.isEmpty()) vm.fetchAppList() + } + SuperUserViewModel.apps.map { app -> + ExclusionApp( + uid = app.uid, + label = app.label, + packageName = app.packageName, + isSystem = ((app.packageInfo.applicationInfo?.flags ?: 0) + and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0, + excluded = excluded.contains(app.uid), + ) + }.distinctBy { it.uid } + } +} diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 0f8e39bc6..87c8a35ce 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -524,6 +524,19 @@ 正在读取状态… 状态不可用,请确保 ksud 已更新。 刷新 + 模块 + %1$d 个模块贡献规则 + 点击模块可立即注入;右侧按钮会把它撤下,直到下次重启。 + %1$d 条规则 + 卸载 + 清空例外 + 所有应用重新看到注入的文件。 + 例外应用 + 搜索应用 + 显示系统应用 + 已选择 %1$d 个应用 + %1$s · uid %2$d + 保存 日志文件 加载 SU 日志失败 SU 日志未启用 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 13a65c7ad..0315a6a2d 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -336,6 +336,19 @@ Reading status… Status unavailable. ksud must be up to date. Refresh + Modules + %1$d modules contribute rules + Tap a module to inject it now; the button on the right removes it until the next reboot. + %1$d rules + Unload + Clear exclusions + Every app sees the injected files again. + Excluded apps + Search apps + Show system apps + %1$d apps selected + %1$s · uid %2$d + Save Kernel does not support this feature This feature is managed by a module Enable (Default) From de685f02a7064628bbb2edc13a6b98282fbb80aa Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 02:31:13 +0800 Subject: [PATCH 12/24] nomount: Defer to an installed NoMount module and guard metamodule installs Two writers on the same paths is the one conflict the built-in backend cannot resolve: the kernel keeps the last rule written. Both sides are now handled. - A metamodule that ships its own NoMount tool or LKM owns injection: the backend detects it, reports it as `deferred_to` in `nomount status`, refuses to write rules itself, and at boot hands the mount stage to that module. - Installing a metamodule while the built-in backend is the injector is refused with a message that says what to change, because that metamodule would inject on its own. - `nomount inject --dry-run --verbose` prints every rule and skip reason, which is what the comparison against upstream's metamount.sh needs. Verified on device with a stub metamodule: deferred_to=nomount-stub, owns_injection=false, inject and hot-load both refuse with exit 1, and a regular module still installs normally. --- userspace/ksud/src/cli.rs | 9 ++- userspace/ksud/src/magic_mount.rs | 10 +++ userspace/ksud/src/module.rs | 19 ++++++ userspace/ksud/src/nomount.rs | 100 +++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 43d1c1a4a..2bc8c411c 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -570,6 +570,9 @@ enum Nomount { /// Emit a single JSON object instead of key=value lines #[arg(long)] json: bool, + /// With --dry-run, print every rule and every skip reason + #[arg(long)] + verbose: bool, }, /// Show the rules the kernel currently holds @@ -1029,7 +1032,11 @@ pub fn run() -> Result<()> { crate::nomount::log_probe(&status.probe); status.print(json) } - Nomount::Inject { dry_run, json } => crate::nomount::inject(dry_run, json), + Nomount::Inject { + dry_run, + json, + verbose, + } => crate::nomount::inject(dry_run, json, verbose), Nomount::List { json } => crate::nomount::print_rules(json), Nomount::Clear => crate::nomount::clear_rules(), Nomount::HotLoad { id } => { diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index 5484bb80a..a1955b524 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -663,6 +663,16 @@ fn run_metamodule() -> Result { /// API has to answer afterwards, because the loader rewrites vermagic and /// relocates symbols and can therefore install a module that does not work. fn run_nomount() -> Result { + // An installed module that drives NoMount itself owns injection: running our + // own rules as well would make the two writers race on the same paths. + if let Some(owner) = crate::nomount::installed_driver_module() { + warn!("nomount: module {owner} owns NoMount injection; deferring to it"); + let mut outcome = run_metamodule()?; + outcome.warnings.push(format!( + "Deferred to the installed NoMount module {owner} (it drives the same driver)" + )); + return Ok(outcome); + } let result = crate::nomount::apply_current_plan()?; for warning in &result.warnings { warn!("nomount: {warning}"); diff --git a/userspace/ksud/src/module.rs b/userspace/ksud/src/module.rs index 1e5a581cc..f7f894b7a 100644 --- a/userspace/ksud/src/module.rs +++ b/userspace/ksud/src/module.rs @@ -712,6 +712,25 @@ fn install_module_to_system(zip: &str) -> Result<()> { if is_metamodule { info!("Installing metamodule: {module_id}"); + // A metamodule injects on its own through its own scripts. While the + // built-in NoMount backend owns injection, installing one would put two + // injectors on the same paths. + if crate::nomount::owns_injection().unwrap_or(false) { + println!("\n❌ Installation Failed"); + println!("┌────────────────────────────────"); + println!("│ The built-in NoMount backend is active"); + println!("│"); + println!("│ A metamodule injects files on its own, so it and NoMount"); + println!("│ would both own the same paths."); + println!("│"); + println!("│ To install this metamodule:"); + println!("│ 1. Set the mount mode to auto, builtin or off"); + println!("│ 2. Reboot your device"); + println!("│ 3. Install the new metamodule"); + println!("└─────────────────────────────────\n"); + bail!("NO_MOUNT_ACTIVE: NoMount owns module injection; switch the mount mode first"); + } + // Check if there's already a metamodule installed if metamodule::has_metamodule() && let Some(existing_path) = metamodule::get_metamodule_path() diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index 9b64a295b..ce7d05c4a 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -742,6 +742,9 @@ pub fn collect_rule_counts() -> Result> { /// Returns an error when the driver is not usable or the kernel refuses a rule. #[cfg(target_os = "android")] pub fn hot_load(id: &str) -> Result { + if let Some(owner) = installed_driver_module() { + bail!("nomount: module {owner} owns NoMount injection; not writing rules as well"); + } let driver = Driver::kernel(); ensure_usable(&driver.probe())?; let plan = collect_plan_for(id)?; @@ -956,6 +959,9 @@ pub struct InjectionResult { /// cannot be walked, or the kernel refuses the rules. #[cfg(target_os = "android")] pub fn apply_current_plan() -> Result { + if let Some(owner) = installed_driver_module() { + bail!("nomount: module {owner} owns NoMount injection; not writing rules as well"); + } let driver = Driver::kernel(); let (probe, loaded) = probe_or_load()?; if let Some(loaded) = loaded { @@ -1022,6 +1028,10 @@ pub struct Status { pub uids: Vec, /// UIDs the kernel is excluding right now. pub uids_active: Vec, + /// Non-empty when an installed module owns injection instead of us. + pub deferred_to: Option, + /// True when this backend is the one injecting right now. + pub owns_injection: bool, pub uid_count: usize, pub isolated_blocked: bool, pub error: Option, @@ -1044,6 +1054,8 @@ impl Status { uids: read_uid_config(std::path::Path::new(crate::defs::NOMOUNT_UIDS)) .map_or_else(|_| Vec::new(), |config| config.uids), uids_active: Vec::new(), + deferred_to: installed_driver_module(), + owns_injection: false, uid_count: 0, isolated_blocked: false, error: None, @@ -1052,6 +1064,11 @@ impl Status { status.error = Some("driver is not ready".to_string()); return Ok(status); } + status.owns_injection = matches!( + crate::magic_mount::read_mount_mode().unwrap_or(crate::magic_mount::MountMode::Auto), + crate::magic_mount::MountMode::Nomount + ) && status.deferred_to.is_none() + && status.probe.ready(); if let Ok(rules) = driver.list_rules() { // Report the rules we published separately from the directories the // kernel made up for them, so the two never contradict each other. @@ -1087,6 +1104,8 @@ impl Status { .collect::>(), "uids": self.uids, "uids_active": self.uids_active, + "deferred_to": self.deferred_to, + "owns_injection": self.owns_injection, "uid_count": self.uid_count, "isolated_blocked": self.isolated_blocked, "error": self.error, @@ -1118,6 +1137,14 @@ impl Status { .context("failed to write nomount status")?; writeln!(out, "uids_active={}", join_uids(&self.uids_active)) .context("failed to write nomount status")?; + writeln!( + out, + "deferred_to={}", + self.deferred_to.as_deref().unwrap_or("") + ) + .context("failed to write nomount status")?; + writeln!(out, "owns_injection={}", self.owns_injection) + .context("failed to write nomount status")?; writeln!(out, "uid_count={}", self.uid_count) .context("failed to write nomount status")?; writeln!(out, "isolated_blocked={}", self.isolated_blocked) @@ -1139,7 +1166,7 @@ impl Status { /// Returns an error when the plan cannot be built or the driver refuses the /// rules; a refused injection is never reported as success. #[cfg(target_os = "android")] -pub fn inject(dry_run: bool, json: bool) -> Result<()> { +pub fn inject(dry_run: bool, json: bool, verbose: bool) -> Result<()> { let stdout = std::io::stdout(); let mut out = stdout.lock(); if dry_run { @@ -1157,6 +1184,21 @@ pub fn inject(dry_run: bool, json: bool) -> Result<()> { for (id, reason) in &plan.skipped { writeln!(out, "skipped={id} ({reason})").context("failed to write dry run")?; } + if verbose { + for rule in &plan.rules { + if rule.whiteout { + writeln!(out, "whiteout={}", rule.virtual_path) + } else { + writeln!( + out, + "rule={} -> {}", + rule.virtual_path, + rule.real_path.as_deref().unwrap_or("-") + ) + } + .context("failed to write dry run")?; + } + } } return out.flush().context("failed to flush dry run"); } @@ -1250,6 +1292,62 @@ pub fn apply_stored_uids() -> Result<(usize, usize)> { sync_uids(&Driver::kernel(), &wanted) } +/// An installed module that drives NoMount itself, if there is one. +/// +/// The upstream NoMount distribution is a metamodule that ships its own `nm` +/// tool and LKM. When one of those is installed it owns injection: our backend +/// defers to it instead of becoming a second writer of the same rules, which +/// the kernel would resolve as last-writer-wins. +/// +/// The test is deliberately narrow: a metamodule whose id is `nomount`, that +/// ships `bin/nm`, or that carries a NoMount LKM. A metamodule that only mounts +/// files (overlayfs, magic mount) is not one. +#[cfg(target_os = "android")] +#[must_use] +pub fn installed_driver_module() -> Option { + let path = crate::metamodule::get_metamodule_path()?; + if path.join(crate::defs::DISABLE_FILE_NAME).exists() { + return None; + } + let id = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string(); + let ships_tool = path.join("bin/nm").exists(); + let ships_lkm = ["lkm/nomount.ko", "nomount.ko"] + .iter() + .any(|candidate| path.join(candidate).exists()); + if id == "nomount" || ships_tool || ships_lkm { + Some(id) + } else { + None + } +} + +/// True when the built-in backend is the one injecting right now. +/// +/// Used to block installing a metamodule that would inject on its own while +/// this backend is active: two injectors would fight over the same paths. +/// +/// # Errors +/// Returns an error when the mount mode cannot be read. +#[cfg(target_os = "android")] +pub fn owns_injection() -> Result { + if !matches!( + crate::magic_mount::read_mount_mode()?, + crate::magic_mount::MountMode::Nomount + ) { + return Ok(false); + } + // An installed module that drives the same driver wins: we are not the + // injector even though the mode says NoMount. + if installed_driver_module().is_some() { + return Ok(false); + } + Ok(Driver::kernel().probe().ready()) +} + /// Load the embedded NoMount module for the running KMI. /// /// The loader rewrites vermagic and relocates symbols, so a successful load is From 6b8bf617503ffe12de33b7cbc1b1b48dc3ee6e20 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 02:31:17 +0800 Subject: [PATCH 13/24] manager: Add clear-everything and follow-profile actions to NoMount - "Clear everything" drops the rules and the exclusions in one action, with a summary that says it lasts until the next reboot. - The exclusion picker can select the apps whose profile hides modules from them, which is the same decision the kernel already answers per UID; it honours the picker's own visibility (system apps only when shown) and the result is visible as a count before saving. - The driver card explains when an installed module owns injection instead of this backend. Verified on device: clear-everything takes the rules to 0 from the UI and the follow action selects the listed apps (168 of them on this device), with no write until Save. --- .../kernelsu/data/model/NomountStatus.kt | 6 ++++ .../data/repository/SettingsRepository.kt | 1 + .../data/repository/SettingsRepositoryImpl.kt | 9 ++++++ .../screen/nomount/NomountExclusionsScreen.kt | 20 +++++++++++++ .../ui/screen/nomount/NomountScreen.kt | 30 +++++++++++++++++++ .../kernelsu/ui/viewmodel/NomountViewModel.kt | 21 +++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 6 ++++ manager/app/src/main/res/values/strings.xml | 6 ++++ 8 files changed, 99 insertions(+) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt index ec337c10d..ecfbf6ec7 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt @@ -43,6 +43,10 @@ data class NomountStatus( val uids: List, /** UIDs the kernel excludes right now. */ val uidsActive: List, + /** Module id when an installed module owns injection instead of this backend. */ + val deferredTo: String?, + /** True when the built-in backend is the one injecting right now. */ + val ownsInjection: Boolean, val uidCount: Int, val isolatedBlocked: Boolean, val error: String?, @@ -87,6 +91,8 @@ data class NomountStatus( modules = modules, uids = json.optIntList("uids"), uidsActive = json.optIntList("uids_active"), + deferredTo = json.optStringOrNull("deferred_to"), + ownsInjection = json.optBoolean("owns_injection", false), uidCount = json.optInt("uid_count", 0), isolatedBlocked = json.optBoolean("isolated_blocked", false), error = json.optStringOrNull("error"), diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt index 9c52f12e7..786104c79 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepository.kt @@ -89,6 +89,7 @@ interface SettingsRepository { suspend fun setNomountIsolated(enabled: Boolean): Result suspend fun injectNomountRules(): Result suspend fun clearNomountRules(): Result + suspend fun clearNomountEverything(): Result suspend fun setNomountUids(uids: List): Result suspend fun hotLoadNomountModule(id: String): Result suspend fun hotUnloadNomountModule(id: String): Result diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt index 6f5a56d76..9a256c2d5 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/repository/SettingsRepositoryImpl.kt @@ -376,6 +376,15 @@ class SettingsRepositoryImpl : SettingsRepository { } } + override suspend fun clearNomountEverything(): Result = withContext(Dispatchers.IO) { + runCatching { + // Rules first, then the exclusions: if a step fails, the failure is + // reported instead of leaving a half-cleared state looking done. + check(execKsud("nomount clear", true)) { "ksud nomount clear failed" } + check(execKsud("nomount uid set", true)) { "ksud nomount uid set failed" } + } + } + override suspend fun setNomountUids(uids: List): Result = withContext(Dispatchers.IO) { runCatching { // UIDs are integers from the app list; nothing user-typed reaches the shell. diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt index 3cc0f951e..ea1a747b1 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountExclusionsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -27,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.material.ExpressiveScaffold import me.weishu.kernelsu.ui.component.material.SegmentedColumn @@ -61,6 +63,7 @@ fun NomountExclusionsScreen() { mutableStateOf(state.status?.uids?.toSet() ?: emptySet()) } var query by rememberSaveable { mutableStateOf("") } + val scope = rememberCoroutineScope() val visible = state.apps.filter { app -> (state.showSystemApps || !app.isSystem) && @@ -112,6 +115,23 @@ fun NomountExclusionsScreen() { onCheckedChange = viewModel::setShowSystemApps, ) } + item { + SegmentedListItem( + headlineContent = { + Text(stringResource(R.string.nomount_follow_profiles)) + }, + supportingContent = { + Text(stringResource(R.string.nomount_follow_profiles_summary)) + }, + enabled = !state.appsLoading && !state.isWriting, + onClick = { + scope.launch { + selected = selected + + viewModel.profileExcludedUids(state.showSystemApps) + } + }, + ) + } } if (state.appsLoading) { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index 76bdf3759..bc3b7adbb 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -109,6 +109,24 @@ private fun DriverSection(state: NomountUiState, viewModel: NomountViewModel) { trailingContent = { Icon(Icons.Filled.Refresh, null) }, ) } + item(visible = state.status?.deferredTo != null) { + // Another module drives the same driver: say so instead of showing a + // healthy backend that is not the one injecting. + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_deferred)) }, + supportingContent = { + Text( + stringResource( + R.string.nomount_deferred_summary, + state.status?.deferredTo.orEmpty(), + ) + ) + }, + leadingContent = { Icon(Icons.Filled.Layers, null) }, + enabled = false, + onClick = {}, + ) + } } } @@ -213,6 +231,18 @@ private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel onClick = viewModel::clearExclusions, ) } + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_clear_everything)) }, + supportingContent = { + Text(stringResource(R.string.nomount_clear_everything_summary)) + }, + leadingContent = { Icon(Icons.Filled.DeleteSweep, null) }, + enabled = ready && + ((status?.rulesApplied ?: 0) > 0 || status?.uids?.isNotEmpty() == true), + onClick = viewModel::clearEverything, + ) + } item { SegmentedSwitchItem( icon = Icons.Filled.VisibilityOff, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt index 54f5daacb..957835ebe 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/NomountViewModel.kt @@ -72,6 +72,9 @@ class NomountViewModel( fun clearRules() = runAction { repo.clearNomountRules() } + /** Drop every rule and every exclusion. */ + fun clearEverything() = runAction { repo.clearNomountEverything() } + fun setIsolated(enabled: Boolean) = runAction { repo.setNomountIsolated(enabled) } /** Add a module's rules without waiting for a reboot. */ @@ -107,6 +110,24 @@ class NomountViewModel( /** Drop every exclusion. */ fun clearExclusions() = runAction { repo.setNomountUids(emptyList()) } + /** + * UIDs whose app profile hides modules from them. + * + * The kernel answers that per UID and the default profile hides modules from + * every non-root app, so this usually selects almost everything; the picker + * shows the count before the user saves. + */ + suspend fun profileExcludedUids(includeSystem: Boolean): List = + withContext(kotlinx.coroutines.Dispatchers.IO) { + _uiState.value.apps + .filter { app -> + (includeSystem || !app.isSystem) && + runCatching { me.weishu.kernelsu.Natives.uidShouldUmount(app.uid) } + .getOrDefault(false) + } + .map { app -> app.uid } + } + private fun runAction(block: suspend () -> Result) { if (_uiState.value.isWriting) return _uiState.update { it.copy(isWriting = true, actionError = false) } diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 87c8a35ce..46aa167cb 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -537,6 +537,12 @@ 已选择 %1$d 个应用 %1$s · uid %2$d 保存 + 跟随应用 Profile + 在列表范围内选中那些 Profile 里"卸载模块"的应用。默认非 root Profile 对所有应用生效,所以通常会选中其中绝大多数。 + 清空全部 + 清除全部规则与例外,直到下次重启。 + 注入由其它模块接管 + 已安装的模块 %1$s 驱动同一个内核驱动;内置后端已让位,避免两边同时写规则。 日志文件 加载 SU 日志失败 SU 日志未启用 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 0315a6a2d..cd7d09654 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -349,6 +349,12 @@ %1$d apps selected %1$s · uid %2$d Save + Follow app profiles + Select the listed apps whose profile hides modules from them. The default profile hides them from every non-root app, so this usually selects almost all of them. + Clear everything + Drops every rule and every exclusion until the next reboot. + Another module owns NoMount + The installed module %1$s drives the same driver; the built-in backend steps aside so only one of them writes rules. Kernel does not support this feature This feature is managed by a module Enable (Default) From 0bd80b2dde1df5e29222e73019f091fb7e77e23d Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 02:52:16 +0800 Subject: [PATCH 14/24] nomount: Handle a kernel built-in driver and record state changes A kernel with CONFIG_NOMOUNT=y needs no load at all: everything here talks to the key API, and the probe already answers, so the built-in path worked. Two things were missing around it. - An outdated driver (built-in or an LKM someone else loaded) is no longer followed by a load attempt: a registered key type always makes a second registration fail, so the load could only produce a misleading error. The backend now reports the version it found and refuses to inject. - `nomount status` reports `driver_kind` (builtin or module) and the last action that changed kernel state, so "who emptied the rules" does not depend on the kernel ring buffer. The manager shows the kind next to the version. Verified on device: driver_kind=module, and last_action flips from empty to inject after an injection. --- .../kernelsu/data/model/NomountStatus.kt | 3 + .../ui/screen/nomount/NomountScreen.kt | 11 +- .../src/main/res/values-zh-rCN/strings.xml | 2 + manager/app/src/main/res/values/strings.xml | 2 + userspace/ksud/src/defs.rs | 2 + userspace/ksud/src/magic_mount.rs | 4 +- userspace/ksud/src/nomount.rs | 127 +++++++++++++++++- userspace/ksud/src/nomount_tests.rs | 20 +++ 8 files changed, 166 insertions(+), 5 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt index ecfbf6ec7..4bbb2aae9 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt @@ -47,6 +47,8 @@ data class NomountStatus( val deferredTo: String?, /** True when the built-in backend is the one injecting right now. */ val ownsInjection: Boolean, + /** `builtin` when the kernel carries the driver, `module` for an LKM. */ + val driverKind: String?, val uidCount: Int, val isolatedBlocked: Boolean, val error: String?, @@ -93,6 +95,7 @@ data class NomountStatus( uidsActive = json.optIntList("uids_active"), deferredTo = json.optStringOrNull("deferred_to"), ownsInjection = json.optBoolean("owns_injection", false), + driverKind = json.optStringOrNull("driver_kind"), uidCount = json.optInt("uid_count", 0), isolatedBlocked = json.optBoolean("isolated_blocked", false), error = json.optStringOrNull("error"), diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index bc3b7adbb..00ac7b9e7 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -142,7 +142,16 @@ private fun driverSummary(state: NomountUiState): String { } ) val version = status.driverVersion - return if (version.isNullOrEmpty()) label else "$label ($version)" + val kind = when (status.driverKind) { + "builtin" -> stringResource(R.string.nomount_driver_kind_builtin) + "module" -> stringResource(R.string.nomount_driver_kind_module) + else -> null + } + return listOfNotNull( + label, + version?.takeIf { it.isNotEmpty() }?.let { "($it)" }, + kind, + ).joinToString(" ") } @Composable diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 46aa167cb..d07b12841 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -508,6 +508,8 @@ 不可用 版本不匹配 就绪 + 内核内建 + 内核模块 驱动不可用(%1$s) 重定向规则 已生效 %1$d / %2$d 条规则 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index cd7d09654..fe1ccfdde 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -320,6 +320,8 @@ Not available Version mismatch Ready + kernel built-in + kernel module Driver unavailable (%1$s) Redirection rules %1$d of %2$d rules active diff --git a/userspace/ksud/src/defs.rs b/userspace/ksud/src/defs.rs index f6533aaa0..d305e2d55 100644 --- a/userspace/ksud/src/defs.rs +++ b/userspace/ksud/src/defs.rs @@ -69,6 +69,8 @@ mod android { /// UIDs that must see the stock filesystem, one per line. Written by the /// manager from the app profiles and replayed into the kernel on boot. pub const NOMOUNT_UIDS: &str = concatcp!(WORKING_DIR, "nomount_uids"); + /// Last action that changed NoMount kernel state, for diagnostics. + pub const NOMOUNT_LAST_ACTION: &str = concatcp!(WORKING_DIR, "nomount_last_action"); pub const KSU_BACKUP_DIR: &str = WORKING_DIR; pub const KSU_BACKUP_FILE_PREFIX: &str = "ksu_backup_"; diff --git a/userspace/ksud/src/magic_mount.rs b/userspace/ksud/src/magic_mount.rs index a1955b524..4c5ceb937 100644 --- a/userspace/ksud/src/magic_mount.rs +++ b/userspace/ksud/src/magic_mount.rs @@ -788,8 +788,8 @@ pub fn mount_selected_provider() -> Result { warning: None, })?; - // NoMount is opt-in, so a driver that cannot be made usable must not leave - // the modules unmounted: fall back to the built-in engine and say so. + // A driver that cannot be made usable must not leave the modules + // unmounted: fall back to the built-in engine and say why. let (provider, run, fallback) = match run_provider(provider) { ProviderRun::Failed { error, .. } if provider == Provider::Nomount => { warn!("nomount: {error:#}; falling back to the built-in engine"); diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index ce7d05c4a..5b3f9a485 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -62,6 +62,39 @@ impl DriverState { } } +/// How the driver got into the kernel. +/// +/// Both work identically from here on: everything below talks to the key API, +/// so a kernel with `CONFIG_NOMOUNT=y` needs no load and no special case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverKind { + /// Built into the kernel; nothing to load or unload. + Builtin, + /// A loadable module is present in `/proc/modules`. + Module, +} + +impl DriverKind { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Builtin => "builtin", + Self::Module => "module", + } + } +} + +/// Whether loading the embedded module can help. +/// +/// Only when the API is absent: a registered key type means something already +/// provides it (built into the kernel, or a module another loader put there), +/// and registering it again fails. That also means an outdated built-in driver +/// cannot be fixed from userspace, so callers must say so instead of trying. +#[must_use] +pub const fn should_load(probe: &DriverProbe) -> bool { + matches!(probe.state, DriverState::Absent) +} + /// A probe result: the driver state plus the version string when one was read. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DriverProbe { @@ -753,7 +786,9 @@ pub fn hot_load(id: &str) -> Result { { bail!("nomount: module {id} is {reason}"); } - driver.apply_rules(&plan.rules) + let applied = driver.apply_rules(&plan.rules); + log_action(&format!("hot-load {id}")); + applied } /// Remove one module's rules from the running kernel. @@ -782,6 +817,7 @@ pub fn hot_unload(id: &str) -> Result { .collect(); let count = paths.len(); Driver::kernel().delete_rules(&paths)?; + log_action(&format!("hot-unload {id}")); Ok(count) } @@ -891,6 +927,38 @@ pub fn sync_uids(driver: &Driver, wanted: &UidConfig) -> Result Ok((added.len(), removed.len())) } +/// Remember the last action that changed kernel state. +/// +/// Rules live in RAM and can be dropped by an explicit clear or a per-module +/// unload, so "why is the kernel injecting nothing" needs an answer that +/// survives the kernel log rotating out. +/// +/// # Errors +/// Returns an error when the record cannot be written. +pub fn record_action(path: &std::path::Path, action: &str) -> Result<()> { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write( + path, + format!("time={stamp}\naction={}\n", action.replace('\n', " ")), + ) + .with_context(|| format!("write {}", path.display())) +} + +/// The last recorded state change, for `nomount status`. +#[must_use] +pub fn read_last_action(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + content.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + (key == "action").then(|| value.to_string()) + }) +} + /// Per-boot NoMount record, read back by `ksud nomount status` and the manager. #[derive(Debug, Clone, Default)] pub struct RuntimeRecord { @@ -1032,6 +1100,10 @@ pub struct Status { pub deferred_to: Option, /// True when this backend is the one injecting right now. pub owns_injection: bool, + /// Built-in support or a loadable module; `None` while the API is silent. + pub driver_kind: Option, + /// Last action that changed kernel state, e.g. `clear` or `hot-unload `. + pub last_action: Option, pub uid_count: usize, pub isolated_blocked: bool, pub error: Option, @@ -1056,6 +1128,8 @@ impl Status { uids_active: Vec::new(), deferred_to: installed_driver_module(), owns_injection: false, + driver_kind: None, + last_action: read_last_action(std::path::Path::new(crate::defs::NOMOUNT_LAST_ACTION)), uid_count: 0, isolated_blocked: false, error: None, @@ -1069,6 +1143,13 @@ impl Status { crate::magic_mount::MountMode::Nomount ) && status.deferred_to.is_none() && status.probe.ready(); + status.driver_kind = status.probe.ready().then(|| { + if module_loaded(&std::fs::read_to_string("/proc/modules").unwrap_or_default()) { + DriverKind::Module + } else { + DriverKind::Builtin + } + }); if let Ok(rules) = driver.list_rules() { // Report the rules we published separately from the directories the // kernel made up for them, so the two never contradict each other. @@ -1106,6 +1187,8 @@ impl Status { "uids_active": self.uids_active, "deferred_to": self.deferred_to, "owns_injection": self.owns_injection, + "driver_kind": self.driver_kind.map(DriverKind::as_str), + "last_action": self.last_action, "uid_count": self.uid_count, "isolated_blocked": self.isolated_blocked, "error": self.error, @@ -1145,6 +1228,18 @@ impl Status { .context("failed to write nomount status")?; writeln!(out, "owns_injection={}", self.owns_injection) .context("failed to write nomount status")?; + writeln!( + out, + "driver_kind={}", + self.driver_kind.map_or("", DriverKind::as_str) + ) + .context("failed to write nomount status")?; + writeln!( + out, + "last_action={}", + self.last_action.as_deref().unwrap_or("") + ) + .context("failed to write nomount status")?; writeln!(out, "uid_count={}", self.uid_count) .context("failed to write nomount status")?; writeln!(out, "isolated_blocked={}", self.isolated_blocked) @@ -1204,6 +1299,7 @@ pub fn inject(dry_run: bool, json: bool, verbose: bool) -> Result<()> { } let result = apply_current_plan()?; + log_action("inject"); if json { let payload = serde_json::json!({ "schema_version": 1, @@ -1236,7 +1332,24 @@ pub fn inject(dry_run: bool, json: bool, verbose: bool) -> Result<()> { /// # Errors /// Returns an error when the kernel refuses the command. pub fn clear_rules() -> Result<()> { - Driver::kernel().clear_rules() + let result = Driver::kernel().clear_rules(); + log_action("clear"); + result +} + +/// Record a state change next to the runtime record; failures only warn. +fn log_action(action: &str) { + // Only the Android build has a persistent workspace to write to; host tests + // exercise the codec and the planner instead. + #[cfg(target_os = "android")] + { + let path = std::path::Path::new(crate::defs::NOMOUNT_LAST_ACTION); + if let Err(error) = record_action(path, action) { + log::warn!("nomount: could not record action {action}: {error:#}"); + } + } + #[cfg(not(target_os = "android"))] + let _ = action; } /// Print the rules the kernel currently holds. @@ -1385,6 +1498,16 @@ pub fn probe_or_load() -> Result<(DriverProbe, Option)> { if first.ready() { return Ok((first, None)); } + if !should_load(&first) { + // A registered key type means something already provides the API, and a + // second registration always fails: report the real reason instead of a + // confusing load error. + bail!( + "nomount: the kernel already provides NoMount (version {}) but this build needs API >= {MIN_API_VERSION}; \ + a built-in driver cannot be replaced at runtime", + first.version.as_deref().unwrap_or("unknown") + ); + } let loaded = load_embedded_driver()?; let second = driver.probe(); ensure_usable(&second)?; diff --git a/userspace/ksud/src/nomount_tests.rs b/userspace/ksud/src/nomount_tests.rs index 3eaada341..8add6c809 100644 --- a/userspace/ksud/src/nomount_tests.rs +++ b/userspace/ksud/src/nomount_tests.rs @@ -253,6 +253,26 @@ fn api_version_parses_the_leading_integer() { assert_eq!(parse_api_version(""), None); } +#[test] +fn only_an_absent_driver_is_worth_loading() { + // A registered key type cannot be registered twice, so a ready or outdated + // driver (kernel built-in included) must never be followed by a load. + assert!(should_load(&DriverProbe { + state: DriverState::Absent, + version: None, + })); + assert!(!should_load(&DriverProbe { + state: DriverState::Outdated, + version: Some("20".to_string()), + })); + assert!(!should_load(&DriverProbe { + state: DriverState::Ready, + version: Some("21".to_string()), + })); + assert_eq!(DriverKind::Builtin.as_str(), "builtin"); + assert_eq!(DriverKind::Module.as_str(), "module"); +} + #[test] fn module_loaded_matches_proc_modules() { assert!(module_loaded( From a827e5a04b17d9ba77787e11ee8effedff3d154c Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 03:03:43 +0800 Subject: [PATCH 15/24] nomount: Explain why the driver is unusable and fix dead diagnostics A kernel whose KMI this build ships no module for (legacy or custom kernels) already fell back to the built-in engine correctly, but the reason only appeared in the boot record and the page said "unavailable" with nothing behind it. - `nomount status` reports `unavailable_code` (no_asset_for_kmi, outdated, not_loaded, unknown_kmi) with the KMI or version it refers to. - The NoMount page and the settings row render that as a localized sentence that says what to do, for example "use Automatic or Built-in Folk Mount". - The reason was computed after the early return for an unusable driver, so it was dead code: it now runs before the return. Verified on device by building a ksud without the android15-6.6 module: the boot record names the missing asset, the CLI reports no_asset_for_kmi/android15-6.6, and the page shows the actionable sentence, while the built-in engine keeps injecting the modules. --- .../kernelsu/data/model/NomountStatus.kt | 6 ++ .../ui/screen/nomount/NomountScreen.kt | 15 +++++ .../settings/SettingsCategoryContent.kt | 4 +- .../src/main/res/values-zh-rCN/strings.xml | 4 ++ manager/app/src/main/res/values/strings.xml | 4 ++ userspace/ksud/src/nomount.rs | 58 +++++++++++++++++-- 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt index 4bbb2aae9..5b2b93afa 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/data/model/NomountStatus.kt @@ -49,6 +49,10 @@ data class NomountStatus( val ownsInjection: Boolean, /** `builtin` when the kernel carries the driver, `module` for an LKM. */ val driverKind: String?, + /** Machine-readable reason the driver is unusable; see the manager strings. */ + val unavailableCode: String?, + /** The KMI or version [unavailableCode] refers to. */ + val unavailableDetail: String?, val uidCount: Int, val isolatedBlocked: Boolean, val error: String?, @@ -96,6 +100,8 @@ data class NomountStatus( deferredTo = json.optStringOrNull("deferred_to"), ownsInjection = json.optBoolean("owns_injection", false), driverKind = json.optStringOrNull("driver_kind"), + unavailableCode = json.optStringOrNull("unavailable_code"), + unavailableDetail = json.optStringOrNull("unavailable_detail"), uidCount = json.optInt("uid_count", 0), isolatedBlocked = json.optBoolean("isolated_blocked", false), error = json.optStringOrNull("error"), diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index 00ac7b9e7..793e953ca 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -147,13 +147,28 @@ private fun driverSummary(state: NomountUiState): String { "module" -> stringResource(R.string.nomount_driver_kind_module) else -> null } + val reason = nomountUnavailableReason(status) return listOfNotNull( label, version?.takeIf { it.isNotEmpty() }?.let { "($it)" }, kind, + reason, ).joinToString(" ") } +/** Localized "why is it not usable", so the user gets an actionable sentence. */ +@Composable +internal fun nomountUnavailableReason(status: NomountStatus): String? { + val detail = status.unavailableDetail.orEmpty() + return when (status.unavailableCode) { + "no_asset_for_kmi" -> stringResource(R.string.nomount_unavailable_no_asset, detail) + "outdated" -> stringResource(R.string.nomount_unavailable_outdated, detail) + "not_loaded" -> stringResource(R.string.nomount_unavailable_not_loaded) + "unknown_kmi" -> stringResource(R.string.nomount_unavailable_unknown_kmi) + else -> null + } +} + @Composable private fun InjectionSection(state: NomountUiState, viewModel: NomountViewModel) { val status = state.status diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt index 8b23357da..0aaf0dd35 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsCategoryContent.kt @@ -48,6 +48,7 @@ import me.weishu.kernelsu.ui.component.material.SegmentedColumn import me.weishu.kernelsu.ui.component.material.SegmentedDropdownItem import me.weishu.kernelsu.ui.component.material.SegmentedListItem import me.weishu.kernelsu.ui.component.material.SegmentedSwitchItem +import me.weishu.kernelsu.ui.screen.nomount.nomountUnavailableReason import me.weishu.kernelsu.ui.component.profile.dialogs.SingleSelectDialog import me.weishu.kernelsu.ui.util.LauncherIconUtils @@ -518,7 +519,8 @@ fun FunctionCategoryContent(uiState: SettingsUiState, actions: SettingsScreenAct uiState.isNomountLoading -> stringResource(R.string.nomount_loading) uiState.nomountReadError -> stringResource(R.string.nomount_read_error) nomount == null -> stringResource(R.string.nomount_loading) - !nomount.apiOk -> stringResource( + // Say why it is unusable and what to do, not just that it is. + !nomount.apiOk -> nomountUnavailableReason(nomount) ?: stringResource( id = R.string.nomount_driver_unavailable, stringResource( id = when (nomount.driverState) { diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index d07b12841..8253d13c6 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -510,6 +510,10 @@ 就绪 内核内建 内核模块 + 本安装包没有 %1$s 对应的 NoMount 模块;请改用「自动」或「内建 Folk Mount」。 + 内核提供的是 NoMount %1$s,本版本需要 API 21 及以上;请升级内核。 + 驱动尚未加载;重启或执行一次注入即可。 + 无法把当前内核版本匹配到 NoMount 模块。 驱动不可用(%1$s) 重定向规则 已生效 %1$d / %2$d 条规则 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index fe1ccfdde..070178d7d 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -322,6 +322,10 @@ Ready kernel built-in kernel module + This package ships no NoMount module for %1$s; use Automatic or Built-in Folk Mount. + The kernel carries NoMount %1$s but this build needs API 21 or newer; update the kernel. + The driver is not loaded yet; reboot or inject once to load it. + The kernel version could not be matched to a NoMount module. Driver unavailable (%1$s) Redirection rules %1$d of %2$d rules active diff --git a/userspace/ksud/src/nomount.rs b/userspace/ksud/src/nomount.rs index 5b3f9a485..08854c7cd 100644 --- a/userspace/ksud/src/nomount.rs +++ b/userspace/ksud/src/nomount.rs @@ -1104,6 +1104,11 @@ pub struct Status { pub driver_kind: Option, /// Last action that changed kernel state, e.g. `clear` or `hot-unload `. pub last_action: Option, + /// Machine-readable reason the driver is unusable, for the manager to + /// translate: `no_asset_for_kmi`, `outdated`, `not_loaded` or `unknown_kmi`. + pub unavailable_code: Option<&'static str>, + /// The KMI or version the code refers to. + pub unavailable_detail: Option, pub uid_count: usize, pub isolated_blocked: bool, pub error: Option, @@ -1130,19 +1135,26 @@ impl Status { owns_injection: false, driver_kind: None, last_action: read_last_action(std::path::Path::new(crate::defs::NOMOUNT_LAST_ACTION)), + unavailable_code: None, + unavailable_detail: None, uid_count: 0, isolated_blocked: false, error: None, }; - if !status.probe.ready() { - status.error = Some("driver is not ready".to_string()); - return Ok(status); - } status.owns_injection = matches!( crate::magic_mount::read_mount_mode().unwrap_or(crate::magic_mount::MountMode::Auto), crate::magic_mount::MountMode::Nomount ) && status.deferred_to.is_none() && status.probe.ready(); + if !status.probe.ready() { + status.error = Some("driver is not ready".to_string()); + if status.deferred_to.is_none() { + let (code, detail) = unavailable_reason(&status.probe); + status.unavailable_code = Some(code); + status.unavailable_detail = detail; + } + return Ok(status); + } status.driver_kind = status.probe.ready().then(|| { if module_loaded(&std::fs::read_to_string("/proc/modules").unwrap_or_default()) { DriverKind::Module @@ -1189,6 +1201,8 @@ impl Status { "owns_injection": self.owns_injection, "driver_kind": self.driver_kind.map(DriverKind::as_str), "last_action": self.last_action, + "unavailable_code": self.unavailable_code, + "unavailable_detail": self.unavailable_detail, "uid_count": self.uid_count, "isolated_blocked": self.isolated_blocked, "error": self.error, @@ -1240,6 +1254,18 @@ impl Status { self.last_action.as_deref().unwrap_or("") ) .context("failed to write nomount status")?; + writeln!( + out, + "unavailable_code={}", + self.unavailable_code.unwrap_or("") + ) + .context("failed to write nomount status")?; + writeln!( + out, + "unavailable_detail={}", + self.unavailable_detail.as_deref().unwrap_or("") + ) + .context("failed to write nomount status")?; writeln!(out, "uid_count={}", self.uid_count) .context("failed to write nomount status")?; writeln!(out, "isolated_blocked={}", self.isolated_blocked) @@ -1405,6 +1431,30 @@ pub fn apply_stored_uids() -> Result<(usize, usize)> { sync_uids(&Driver::kernel(), &wanted) } +/// Why the driver cannot be used, as a code the manager can translate. +/// +/// The reason matters most on kernels this build does not ship a module for +/// (legacy or custom kernels): falling back to the built-in engine is correct, +/// but the user has to be told which of the cases they are in. +#[cfg(target_os = "android")] +fn unavailable_reason(probe: &DriverProbe) -> (&'static str, Option) { + match probe.state { + DriverState::Ready => ("ready", None), + DriverState::Outdated => ("outdated", probe.version.clone()), + DriverState::Absent => crate::boot_patch::get_current_kmi().map_or_else( + |_| ("unknown_kmi", None), + |kmi| { + let name = format!("{kmi}{ASSET_SUFFIX}"); + if crate::assets::get_asset_data(&name).is_ok() { + ("not_loaded", Some(kmi)) + } else { + ("no_asset_for_kmi", Some(kmi)) + } + }, + ), + } +} + /// An installed module that drives NoMount itself, if there is one. /// /// The upstream NoMount distribution is a metamodule that ships its own `nm` From 883041a28fa3c92237fc69c0c30b794f0b8ed203 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 03:15:04 +0800 Subject: [PATCH 16/24] manager: Spell out how NoMount changes file visibility Switching to NoMount flips who can see the injected files: the built-in Folk Mount hides them from apps whose profile says "unmount modules" (every non-root app by default), while NoMount starts with no exclusions at all. That was implicit before. - Selecting NoMount now asks for confirmation first and only writes the mode after the user agrees. The dialog names the change, why it happens and what to do to keep the old behaviour. - The NoMount page says it in two places: the exclusion row reads "every app can see the injected files" when the list is empty, and a Default visibility row explains the difference from Folk Mount. Verified on device: the dialog gates the write (the mode file stays unchanged until confirm), confirming switches to nomount, and both rows render. --- .../ui/screen/nomount/NomountScreen.kt | 21 ++++++++++++--- .../ui/screen/settings/SettingsScreen.kt | 26 ++++++++++++++++++- .../src/main/res/values-zh-rCN/strings.xml | 5 ++++ manager/app/src/main/res/values/strings.xml | 5 ++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt index 793e953ca..fe639dd82 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/nomount/NomountScreen.kt @@ -228,11 +228,13 @@ private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel SegmentedListItem( headlineContent = { Text(stringResource(R.string.nomount_uid_exclusions)) }, supportingContent = { + val count = status?.uidsActive?.size ?: (status?.uidCount ?: 0) Text( - stringResource( - R.string.nomount_uid_exclusions_value, - status?.uidsActive?.size ?: (status?.uidCount ?: 0), - ) + if (count == 0) { + stringResource(R.string.nomount_uid_exclusions_none) + } else { + stringResource(R.string.nomount_uid_exclusions_value, count) + } ) }, overlineContent = { Text(stringResource(R.string.nomount_uid_source_hint)) }, @@ -244,6 +246,17 @@ private fun VisibilitySection(state: NomountUiState, viewModel: NomountViewModel }, ) } + item { + SegmentedListItem( + headlineContent = { Text(stringResource(R.string.nomount_visibility_model)) }, + supportingContent = { + Text(stringResource(R.string.nomount_visibility_model_hint)) + }, + leadingContent = { Icon(Icons.Filled.VisibilityOff, null) }, + enabled = false, + onClick = {}, + ) + } item { SegmentedListItem( headlineContent = { Text(stringResource(R.string.nomount_clear_exclusions)) }, diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt index f87b528e5..859e599e1 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt @@ -2,6 +2,8 @@ package me.weishu.kernelsu.ui.screen.settings import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState @@ -14,6 +16,10 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import me.weishu.kernelsu.KernelSUApplication +import me.weishu.kernelsu.R +import me.weishu.kernelsu.data.model.FolkMountMode +import me.weishu.kernelsu.ui.component.dialog.ConfirmResult +import me.weishu.kernelsu.ui.component.dialog.rememberConfirmDialog import me.weishu.kernelsu.ui.navigation3.Navigator import me.weishu.kernelsu.ui.navigation3.Route import me.weishu.kernelsu.ui.viewmodel.SettingsViewModel @@ -57,6 +63,24 @@ fun rememberSettingsActions( ): SettingsScreenActions { val context = LocalContext.current val activity = LocalActivity.current + val scope = rememberCoroutineScope() + val confirm = rememberConfirmDialog() + // Switching to NoMount changes who can see the injected files, so confirm it + // once with the difference spelled out instead of changing it silently. + val onSetFolkMountMode: (FolkMountMode) -> Unit = { mode -> + if (mode == FolkMountMode.NOMOUNT) { + scope.launch { + val result = confirm.awaitConfirm( + title = context.getString(R.string.settings_folk_mount_mode_nomount), + content = context.getString(R.string.settings_folk_mount_nomount_confirm), + confirm = context.getString(R.string.settings_folk_mount_nomount_confirm_ok), + ) + if (result is ConfirmResult.Confirmed) viewModel.setFolkMountMode(mode) + } + } else { + viewModel.setFolkMountMode(mode) + } + } return SettingsScreenActions( onSetCheckUpdate = viewModel::setCheckUpdate, onSetCheckModuleUpdate = viewModel::setCheckModuleUpdate, @@ -73,7 +97,7 @@ fun rememberSettingsActions( onSetSELinuxMode = viewModel::setSELinuxMode, onSetSulogEnabled = viewModel::setSulogEnabled, onSetAvcSpoofEnabled = viewModel::setAvcSpoofEnabled, - onSetFolkMountMode = viewModel::setFolkMountMode, + onSetFolkMountMode = onSetFolkMountMode, onOpenNomount = { navigator.push(Route.Nomount) }, onSetFolkZygiskEnabled = viewModel::setFolkZygiskEnabled, onSetAdbRootEnabled = viewModel::setAdbRootEnabled, diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 8253d13c6..7a6fc9d26 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -524,6 +524,11 @@ 内核拒绝了本次变更,请检查驱动状态后重试。 例外应用 %1$d 个应用看到原厂文件系统 + 所有应用都能看到注入的文件;在下面勾选应用可隐藏 + 默认可见性 + 内建 Folk Mount 会按 Profile 的「卸载模块」把注入文件隐藏起来(默认对全部非 root 应用生效)。NoMount 初始没有任何例外,所以在勾选之前这些应用都能看到注入的文件。 + NoMount 初始会让所有应用都看到注入的文件;内建 Folk Mount 则会按 Profile 的「卸载模块」隐藏(默认覆盖全部非 root 应用)。\n\n切换在重启后生效。要保留旧行为,请在「NoMount → 例外应用」里勾选对应应用。 + 切换,稍后重启 跟随各应用 Profile 的「卸载模块」设置 对隔离进程隐藏 隔离进程(WebView、SDK 沙箱等)同样只能看到原厂文件系统。 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 070178d7d..1d6ca497c 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -336,6 +336,11 @@ The kernel refused the change. Check the driver state and try again. Excluded apps %1$d apps see the stock filesystem + Every app can see the injected files; pick apps below to hide them + Default visibility + The built-in Folk Mount hides injected files from apps whose profile says "unmount modules" (every non-root app by default). NoMount starts with no exclusions, so those apps see the files until you tick them here. + NoMount shows the injected files to every app at first; the built-in Folk Mount hides them from apps whose profile says "unmount modules" (every non-root app by default).\n\nSwitching takes effect after a reboot. To keep the old behaviour, tick the apps in NoMount → Excluded apps. + Switch and reboot later Follows each app profile\'s \"unmount modules\" setting Hide from isolated processes Isolated processes (WebView, SDK sandboxes) also see the stock filesystem. From bbcaae8faa9e25a3f0ccec133e815ac17a02c607 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:13:06 +0800 Subject: [PATCH 17/24] docs: Correct the NoMount visibility description Both READMEs said visibility follows each app profile's "unmount modules" setting. It does not: ksud applies a fixed exception list it stores itself, and the app profiles are copied into that list on demand from the NoMount page. Say what actually happens, including that a later profile change needs another copy. --- README.md | 2 +- README_CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f11427aa4..1be75be7b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Folk-Mount is the built-in module mount engine. It can run in auto, built-in, or NoMount is the built-in VFS redirection backend: instead of mounting anything it takes over path resolution and directory iteration in a kernel module, so `/proc/mounts` stays clean. The module ships inside ksud (embedded per KMI, built by this repository); the backend needs neither a metamodule nor the `nm` binary. -The manager exposes the driver state, the number of active rules, visibility exclusions and a "mounting off" mode; the backend can be set to automatic, built-in Folk Mount, metamodule only, NoMount or off. Choosing NoMount falls back to the built-in engine when the driver is unavailable and says so on the settings page. Visibility follows each app profile's "unmount modules" setting. +The manager exposes the driver state, the number of active rules, visibility exclusions and a "mounting off" mode; the backend can be set to automatic, built-in Folk Mount, metamodule only, NoMount or off. Choosing NoMount falls back to the built-in engine when the driver is unavailable and says so on the settings page. Visibility is an exception list kept by ksud: every app sees the injected files until it is added, and the NoMount page copies each app profile's "unmount modules" verdict into that list on demand, so later profile changes need another copy. ## Security diff --git a/README_CN.md b/README_CN.md index 4e625734e..ef4ce357e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -63,7 +63,7 @@ Folk-Mount 是内置的模块挂载引擎,可在自动、内置或 metamodule NoMount 是内建的 VFS 路径重定向后端:不产生任何挂载,而是由内核模块直接接管路径解析与目录遍历,因此不会污染 `/proc/mounts`。内核模块随 ksud 一起分发(按 KMI 内嵌,由本仓库自行编译),后端本身不依赖模块或 `nm` 二进制。 -管理器提供驱动状态、注入规则数、可见性例外与「关闭挂载」;挂载模式可选 `自动 / 内建 Folk Mount / 仅 metamodule / NoMount / 关闭`。选择 NoMount 时若驱动不可用会自动回退到内建引擎,并在设置页说明原因;可见性跟随各应用 Profile 的「卸载模块」设置。 +管理器提供驱动状态、注入规则数、可见性例外与「关闭挂载」;挂载模式可选 `自动 / 内建 Folk Mount / 仅 metamodule / NoMount / 关闭`。选择 NoMount 时若驱动不可用会自动回退到内建引擎,并在设置页说明原因。可见性是一份由 ksud 保存的例外名单:名单内的应用看到原厂文件系统,其余应用都能看到注入的文件;NoMount 页可以按需把各应用 Profile 的「卸载模块」判定复制进这份名单,改动 Profile 后需要再复制一次。 ## 安全提示 From dba71b69cd921fe0562784287f106bf5f37f9a81 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:13:06 +0800 Subject: [PATCH 18/24] manager: Stop claiming the NoMount exclusions follow profiles The exclusion row caption asserted the list tracks app profiles on its own; only the "Follow app profiles" action copies them, and nothing re-syncs afterwards. The caption now describes that copy, and the action summary says the profiles are not tracked. --- manager/app/src/main/res/values-zh-rCN/strings.xml | 4 ++-- manager/app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 7a6fc9d26..5c6101b19 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -529,7 +529,7 @@ 内建 Folk Mount 会按 Profile 的「卸载模块」把注入文件隐藏起来(默认对全部非 root 应用生效)。NoMount 初始没有任何例外,所以在勾选之前这些应用都能看到注入的文件。 NoMount 初始会让所有应用都看到注入的文件;内建 Folk Mount 则会按 Profile 的「卸载模块」隐藏(默认覆盖全部非 root 应用)。\n\n切换在重启后生效。要保留旧行为,请在「NoMount → 例外应用」里勾选对应应用。 切换,稍后重启 - 跟随各应用 Profile 的「卸载模块」设置 + 按需从应用 Profile 复制 对隔离进程隐藏 隔离进程(WebView、SDK 沙箱等)同样只能看到原厂文件系统。 正在读取状态… @@ -549,7 +549,7 @@ %1$s · uid %2$d 保存 跟随应用 Profile - 在列表范围内选中那些 Profile 里"卸载模块"的应用。默认非 root Profile 对所有应用生效,所以通常会选中其中绝大多数。 + 在列表范围内选中那些 Profile 里「卸载模块」的应用。默认非 root Profile 对所有应用生效,所以通常会选中其中绝大多数。之后不会自动跟随 Profile 变化,改动后需要再复制一次。 清空全部 清除全部规则与例外,直到下次重启。 注入由其它模块接管 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 1d6ca497c..20c59547c 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -341,7 +341,7 @@ The built-in Folk Mount hides injected files from apps whose profile says "unmount modules" (every non-root app by default). NoMount starts with no exclusions, so those apps see the files until you tick them here. NoMount shows the injected files to every app at first; the built-in Folk Mount hides them from apps whose profile says "unmount modules" (every non-root app by default).\n\nSwitching takes effect after a reboot. To keep the old behaviour, tick the apps in NoMount → Excluded apps. Switch and reboot later - Follows each app profile\'s \"unmount modules\" setting + Copied from app profiles on demand Hide from isolated processes Isolated processes (WebView, SDK sandboxes) also see the stock filesystem. Reading status… @@ -361,7 +361,7 @@ %1$s · uid %2$d Save Follow app profiles - Select the listed apps whose profile hides modules from them. The default profile hides them from every non-root app, so this usually selects almost all of them. + Select the listed apps whose profile hides modules from them. The default profile hides them from every non-root app, so this usually selects almost all of them. Profiles are not tracked afterwards: change one and copy again. Clear everything Drops every rule and every exclusion until the next reboot. Another module owns NoMount From f8d1e7ea615d729a90245c346468532f79e15326 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:16:57 +0800 Subject: [PATCH 19/24] nomount: Give build-lkm.sh a shebang ShellCheck fails the workflow with SC2148 because the file is run as "sh build-lkm.sh" and carries no shebang, so the tool cannot pick a target shell. --- userspace/nomount/build-lkm.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/userspace/nomount/build-lkm.sh b/userspace/nomount/build-lkm.sh index 37a12d656..d20942dca 100644 --- a/userspace/nomount/build-lkm.sh +++ b/userspace/nomount/build-lkm.sh @@ -1,3 +1,4 @@ +#!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # # Builds the vendored NoMount module inside an Android DDK container and drops From 8eeb221f3d5c182a54b72706534dd08b1994012a Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:16:57 +0800 Subject: [PATCH 20/24] build: Silence the ShellCheck findings in build.sh The ShellCheck job has been red on master as well, for two reasons. The SC1090 directive sat on a line holding three commands, so it covered "set -a" instead of the source. The SC2030/SC2031 notes come from the deliberate ( export ANDROID_NDK_HOME=... ) subshells, which is the intended scoping. Give each one the directive it needs; no behaviour change. --- build.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 7a33b8a4d..3267acc8c 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# ANDROID_NDK_HOME is exported inside a subshell on purpose: every build stage +# gets it for its own cargo invocation and the parent shell keeps none of it, so +# ShellCheck's subshell-scope notes (SC2030/SC2031) do not apply here. +# shellcheck disable=SC2030,SC2031 # # FolkSU build driver - a local mirror of the GitHub Actions pipeline. # @@ -55,8 +59,12 @@ have() { command -v "$1" >/dev/null 2>&1; } read_keystore_cfg() { [[ -f "$KEYSTORE_PROPS" ]] || die "missing $KEYSTORE_PROPS (copy sign.example.properties and fill it in)" + set -a + # The keystore file is a user-provided path, so its content cannot be + # analysed up front. # shellcheck disable=SC1090 - set -a; source "$KEYSTORE_PROPS"; set +a + source "$KEYSTORE_PROPS" + set +a : "${KEYSTORE_FILE:?KEYSTORE_FILE not set in $KEYSTORE_PROPS}" : "${KEYSTORE_PASSWORD:?KEYSTORE_PASSWORD not set in $KEYSTORE_PROPS}" : "${KEY_ALIAS:?KEY_ALIAS not set in $KEYSTORE_PROPS}" From 6c921df0b4910723ac2b1b53bb5643c250234f9b Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:18:48 +0800 Subject: [PATCH 21/24] manager: Fix the lock screen night text and PIN keyboard The full-screen prompt painted its text straight on a background, so the headline kept Material's default black content colour and vanished on a dark surface. Wrapping the screen in a Surface makes its content follow onSurface. The PIN field keeps focus after the keyboard is dismissed, so tapping a cell only re-requested focus and the pad read as read-only. Ask the IME back on tap, and again whenever the pad is re-enabled after a rejected attempt. --- .../security/AppAuthenticationScreen.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/security/AppAuthenticationScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/security/AppAuthenticationScreen.kt index eefe17283..e8fc97b2e 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/security/AppAuthenticationScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/security/AppAuthenticationScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -119,13 +120,11 @@ fun AppAuthenticationScreen( ) { val motion = rememberMotionEnabled() val busy = state == AuthenticationVisualState.BUSY - Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .safeDrawingPadding(), + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainer, ) { - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { if (busy) { LinearWavyProgressIndicator( modifier = Modifier @@ -302,7 +301,12 @@ fun PinPad( onComplete: () -> Unit, ) { val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val keyboard = LocalSoftwareKeyboardController.current + val summonKeyboard = { + runCatching { focus.requestFocus() } + keyboard?.show() + } + LaunchedEffect(enabled) { if (enabled) summonKeyboard() } BasicTextField( value = value, onValueChange = { input -> if (input.length <= length && input.all { it.isDigit() }) onValueChange(input) }, @@ -327,7 +331,7 @@ fun PinPad( focused = value.length == index && enabled, isError = isError, modifier = Modifier.weight(1f), - onClick = { runCatching { focus.requestFocus() } }, + onClick = { if (enabled) summonKeyboard() }, ) } } From 2aa52cabf080cd655ae55fc95a8debcc699fd915 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:22:34 +0800 Subject: [PATCH 22/24] fix(actions): Build nomount.ko from the checkout path The job's checkout is mounted at $GITHUB_WORKSPACE, while /github/workspace only holds the artifacts the kernelsu.ko step creates there. Asking sh for the script under /github/workspace exited 127 on every KMI, and matrix fail-fast then cancelled the remaining jobs of the run. --- .github/workflows/ddk-lkm.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ddk-lkm.yml b/.github/workflows/ddk-lkm.yml index c86bb7536..d61b9cdb6 100644 --- a/.github/workflows/ddk-lkm.yml +++ b/.github/workflows/ddk-lkm.yml @@ -95,8 +95,10 @@ jobs: - name: Build nomount.ko run: | echo "=== Building NoMount for KMI: ${{ inputs.kmi }} ===" + # The checkout lives at $GITHUB_WORKSPACE; /github/workspace is only the + # scratch directory the kernelsu.ko step creates for its artifacts. NOMOUNT_OUT="/github/workspace/out/aarch64/${{ inputs.kmi }}_nomount.ko" \ - sh /github/workspace/userspace/nomount/build-lkm.sh + sh "$GITHUB_WORKSPACE/userspace/nomount/build-lkm.sh" echo "Size: $(du -h "/github/workspace/out/aarch64/${{ inputs.kmi }}_nomount.ko" | cut -f1)" - name: Upload kernelsu.ko artifact From b908303cb02019c05a96d590fc961133807140f1 Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:35:27 +0800 Subject: [PATCH 23/24] manager: Fix the lint error in the NoMount confirm dialog The dialog took its strings from LocalContext, which Android lint rejects (LocalContextGetResourceValueCall: the value is not configuration-aware) and which aborts :app:lintDebug for every build. Read them through LocalResources, as the rest of the app does. --- .../weishu/kernelsu/ui/screen/settings/SettingsScreen.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt index 859e599e1..3a378ebbf 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/settings/SettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.activity.compose.LocalActivity import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.Dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -62,6 +63,7 @@ fun rememberSettingsActions( viewModel: SettingsViewModel, ): SettingsScreenActions { val context = LocalContext.current + val resources = LocalResources.current val activity = LocalActivity.current val scope = rememberCoroutineScope() val confirm = rememberConfirmDialog() @@ -71,9 +73,9 @@ fun rememberSettingsActions( if (mode == FolkMountMode.NOMOUNT) { scope.launch { val result = confirm.awaitConfirm( - title = context.getString(R.string.settings_folk_mount_mode_nomount), - content = context.getString(R.string.settings_folk_mount_nomount_confirm), - confirm = context.getString(R.string.settings_folk_mount_nomount_confirm_ok), + title = resources.getString(R.string.settings_folk_mount_mode_nomount), + content = resources.getString(R.string.settings_folk_mount_nomount_confirm), + confirm = resources.getString(R.string.settings_folk_mount_nomount_confirm_ok), ) if (result is ConfirmResult.Confirmed) viewModel.setFolkMountMode(mode) } From b00df7b8d727facaf07134a07c3bc0558641e2da Mon Sep 17 00:00:00 2001 From: matsuzaka-yuki Date: Thu, 15 Oct 2026 20:35:27 +0800 Subject: [PATCH 24/24] manager: Fix the lint error the plugin store notice reports The same rule fires on the notice shown after a plugin install, and it fails the Build Manager job on master as well, so the release pipeline cannot get past :app:lintDebug. Same fix: query the notice through LocalResources. --- .../weishu/kernelsu/ui/screen/plugin/PluginStoreMaterial.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/plugin/PluginStoreMaterial.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/plugin/PluginStoreMaterial.kt index 26a774e78..bfa3d226d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/plugin/PluginStoreMaterial.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/plugin/PluginStoreMaterial.kt @@ -46,8 +46,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -69,7 +69,7 @@ fun PluginStoreMaterial( uiState: PluginStoreUiState, actions: PluginStoreActions, ) { - val context = LocalContext.current + val resources = LocalResources.current val haptic = LocalHapticFeedback.current val snackbarHostState = remember { SnackbarHostState() } val listState = rememberLazyListState() @@ -86,7 +86,7 @@ fun PluginStoreMaterial( LaunchedEffect(uiState.notice, uiState.noticeArg) { val notice = uiState.notice ?: return@LaunchedEffect val arg = uiState.noticeArg - val message = if (arg == null) context.getString(notice) else context.getString(notice, arg) + val message = if (arg == null) resources.getString(notice) else resources.getString(notice, arg) snackbarHostState.showSnackbar(message) actions.onNoticeShown() }