add module config, migrate managedFeatures (#2965)

Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
This commit is contained in:
Ylarod
2025-11-20 21:50:34 +08:00
committed by ShirkNeko
parent 3d4e0e48b4
commit e3ef521de5
11 changed files with 881 additions and 46 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.material.icons.rounded.DeveloperMode
import androidx.compose.material.icons.rounded.EnhancedEncryption import androidx.compose.material.icons.rounded.EnhancedEncryption
import androidx.compose.material.icons.rounded.Fence import androidx.compose.material.icons.rounded.Fence
import androidx.compose.material.icons.rounded.FolderDelete import androidx.compose.material.icons.rounded.FolderDelete
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.RemoveCircle import androidx.compose.material.icons.rounded.RemoveCircle
import androidx.compose.material.icons.rounded.RemoveModerator import androidx.compose.material.icons.rounded.RemoveModerator
import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material.icons.rounded.RestartAlt
@@ -33,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -66,6 +68,7 @@ import com.sukisu.ultra.ui.component.SendLogDialog
import com.sukisu.ultra.ui.component.UninstallDialog import com.sukisu.ultra.ui.component.UninstallDialog
import com.sukisu.ultra.ui.component.rememberLoadingDialog import com.sukisu.ultra.ui.component.rememberLoadingDialog
import com.sukisu.ultra.ui.util.execKsud import com.sukisu.ultra.ui.util.execKsud
import com.sukisu.ultra.ui.util.getFeatureStatus
import com.sukisu.ultra.ui.util.rememberKpmAvailable import com.sukisu.ultra.ui.util.rememberKpmAvailable
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
@@ -317,9 +320,17 @@ fun SettingPager(
} }
) )
} }
val enhancedStatus by produceState(initialValue = "") {
value = getFeatureStatus("enhanced_security")
}
val enhancedSummary = when (enhancedStatus) {
"unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary)
"managed" -> stringResource(id = R.string.feature_status_managed_summary)
else -> stringResource(id = R.string.settings_enable_enhanced_security_summary)
}
SuperDropdown( SuperDropdown(
title = stringResource(id = R.string.settings_enable_enhanced_security), title = stringResource(id = R.string.settings_enable_enhanced_security),
summary = stringResource(id = R.string.settings_enable_enhanced_security_summary), summary = enhancedSummary,
items = modeItems, items = modeItems,
leftAction = { leftAction = {
Icon( Icon(
@@ -329,6 +340,7 @@ fun SettingPager(
tint = colorScheme.onBackground tint = colorScheme.onBackground
) )
}, },
enabled = enhancedStatus == "supported",
selectedIndex = enhancedSecurityMode, selectedIndex = enhancedSecurityMode,
onSelectedIndexChange = { index -> onSelectedIndexChange = { index ->
when (index) { when (index) {
@@ -367,9 +379,17 @@ fun SettingPager(
} }
) )
} }
val suStatus by produceState(initialValue = "") {
value = getFeatureStatus("su_compat")
}
val suSummary = when (suStatus) {
"unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary)
"managed" -> stringResource(id = R.string.feature_status_managed_summary)
else -> stringResource(id = R.string.settings_disable_su_summary)
}
SuperDropdown( SuperDropdown(
title = stringResource(id = R.string.settings_disable_su), title = stringResource(id = R.string.settings_disable_su),
summary = stringResource(id = R.string.settings_disable_su_summary), summary = suSummary,
items = modeItems, items = modeItems,
leftAction = { leftAction = {
Icon( Icon(
@@ -379,6 +399,7 @@ fun SettingPager(
tint = colorScheme.onBackground tint = colorScheme.onBackground
) )
}, },
enabled = suStatus == "supported",
selectedIndex = suCompatMode, selectedIndex = suCompatMode,
onSelectedIndexChange = { index -> onSelectedIndexChange = { index ->
when (index) { when (index) {
@@ -417,9 +438,17 @@ fun SettingPager(
} }
) )
} }
val umountStatus by produceState(initialValue = "") {
value = getFeatureStatus("kernel_umount")
}
val umountSummary = when (umountStatus) {
"unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary)
"managed" -> stringResource(id = R.string.feature_status_managed_summary)
else -> stringResource(id = R.string.settings_disable_kernel_umount_summary)
}
SuperDropdown( SuperDropdown(
title = stringResource(id = R.string.settings_disable_kernel_umount), title = stringResource(id = R.string.settings_disable_kernel_umount),
summary = stringResource(id = R.string.settings_disable_kernel_umount_summary), summary = umountSummary,
items = modeItems, items = modeItems,
leftAction = { leftAction = {
Icon( Icon(
@@ -429,6 +458,7 @@ fun SettingPager(
tint = colorScheme.onBackground tint = colorScheme.onBackground
) )
}, },
enabled = umountStatus == "supported",
selectedIndex = kernelUmountMode, selectedIndex = kernelUmountMode,
onSelectedIndexChange = { index -> onSelectedIndexChange = { index ->
when (index) { when (index) {
@@ -458,7 +488,7 @@ fun SettingPager(
} }
) )
var SuLogMode by rememberSaveable { var suLogMode by rememberSaveable {
mutableIntStateOf( mutableIntStateOf(
run { run {
val currentEnabled = Natives.isSuLogEnabled() val currentEnabled = Natives.isSuLogEnabled()
@@ -467,9 +497,17 @@ fun SettingPager(
} }
) )
} }
val suLogStatus by produceState(initialValue = "") {
value = getFeatureStatus("sulog")
}
val suLogSummary = when (suLogStatus) {
"unsupported" -> stringResource(id = R.string.feature_status_unsupported_summary)
"managed" -> stringResource(id = R.string.feature_status_managed_summary)
else -> stringResource(id = R.string.settings_disable_sulog_summary)
}
SuperDropdown( SuperDropdown(
title = stringResource(id = R.string.settings_disable_sulog), title = stringResource(id = R.string.settings_disable_sulog),
summary = stringResource(id = R.string.settings_disable_sulog_summary), summary = suLogSummary,
items = modeItems, items = modeItems,
leftAction = { leftAction = {
Icon( Icon(
@@ -479,14 +517,15 @@ fun SettingPager(
tint = colorScheme.onBackground tint = colorScheme.onBackground
) )
}, },
selectedIndex = SuLogMode, enabled = suLogStatus == "supported",
selectedIndex = suLogMode,
onSelectedIndexChange = { index -> onSelectedIndexChange = { index ->
when (index) { when (index) {
// Default: enable and save to persist // Default: enable and save to persist
0 -> if (Natives.setSuLogEnabled(true)) { 0 -> if (Natives.setSuLogEnabled(true)) {
execKsud("feature save", true) execKsud("feature save", true)
prefs.edit { putInt("sulog_mode", 0) } prefs.edit { putInt("sulog_mode", 0) }
SuLogMode = 0 suLogMode = 0
isSuLogEnabled = true isSuLogEnabled = true
} }
@@ -495,7 +534,7 @@ fun SettingPager(
execKsud("feature save", true) execKsud("feature save", true)
if (Natives.setSuLogEnabled(false)) { if (Natives.setSuLogEnabled(false)) {
prefs.edit { putInt("sulog_mode", 0) } prefs.edit { putInt("sulog_mode", 0) }
SuLogMode = 1 suLogMode = 1
isSuLogEnabled = false isSuLogEnabled = false
} }
} }
@@ -504,7 +543,7 @@ fun SettingPager(
2 -> if (Natives.setSuLogEnabled(false)) { 2 -> if (Natives.setSuLogEnabled(false)) {
execKsud("feature save", true) execKsud("feature save", true)
prefs.edit { putInt("sulog_mode", 2) } prefs.edit { putInt("sulog_mode", 2) }
SuLogMode = 2 suLogMode = 2
isSuLogEnabled = false isSuLogEnabled = false
} }
} }

View File

@@ -108,6 +108,13 @@ fun execKsud(args: String, newShell: Boolean = false): Boolean {
} }
} }
suspend fun getFeatureStatus(feature: String): String = withContext(Dispatchers.IO) {
val shell = getRootShell()
val out = shell.newJob()
.add("${getKsuDaemonPath()} feature check $feature").to(ArrayList<String>(), null).exec().out
out.firstOrNull()?.trim().orEmpty()
}
fun install() { fun install() {
val start = SystemClock.elapsedRealtime() val start = SystemClock.elapsedRealtime()
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath
@@ -118,8 +125,8 @@ fun install() {
fun listModules(): String { fun listModules(): String {
val shell = getRootShell() val shell = getRootShell()
val out = val out = shell.newJob()
shell.newJob().add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out .add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out
return out.joinToString("\n").ifBlank { "[]" } return out.joinToString("\n").ifBlank { "[]" }
} }

View File

@@ -144,6 +144,8 @@
<string name="settings_disable_kernel_umount_summary">关闭 KernelSU 控制的内核级 umount 行为。</string> <string name="settings_disable_kernel_umount_summary">关闭 KernelSU 控制的内核级 umount 行为。</string>
<string name="settings_enable_enhanced_security">增强安全性</string> <string name="settings_enable_enhanced_security">增强安全性</string>
<string name="settings_enable_enhanced_security_summary">使用更严格的安全策略。</string> <string name="settings_enable_enhanced_security_summary">使用更严格的安全策略。</string>
<string name="feature_status_unsupported_summary">内核不支持此功能。</string>
<string name="feature_status_managed_summary">此功能由模块管理。</string>
<string name="settings_mode_default">默认</string> <string name="settings_mode_default">默认</string>
<string name="settings_mode_temp_enable">临时启用</string> <string name="settings_mode_temp_enable">临时启用</string>
<string name="settings_mode_always_enable">始终启用</string> <string name="settings_mode_always_enable">始终启用</string>

View File

@@ -148,6 +148,8 @@
<string name="settings_disable_kernel_umount_summary">Disable kernel-level umount behavior controlled by KernelSU.</string> <string name="settings_disable_kernel_umount_summary">Disable kernel-level umount behavior controlled by KernelSU.</string>
<string name="settings_enable_enhanced_security">Enable enhanced security</string> <string name="settings_enable_enhanced_security">Enable enhanced security</string>
<string name="settings_enable_enhanced_security_summary">Enable stricter security policies.</string> <string name="settings_enable_enhanced_security_summary">Enable stricter security policies.</string>
<string name="feature_status_unsupported_summary">Kernel does not support this feature.</string>
<string name="feature_status_managed_summary">This feature is managed by a module.</string>
<string name="settings_mode_default">Default</string> <string name="settings_mode_default">Default</string>
<string name="settings_mode_temp_enable">Temporarily enable</string> <string name="settings_mode_temp_enable">Temporarily enable</string>
<string name="settings_mode_always_enable">Permanently enable</string> <string name="settings_mode_always_enable">Permanently enable</string>

View File

@@ -302,6 +302,51 @@ enum Module {
/// list all modules /// list all modules
List, List,
/// manage module configuration
Config {
#[command(subcommand)]
command: ModuleConfigCmd,
},
}
#[derive(clap::Subcommand, Debug)]
enum ModuleConfigCmd {
/// Get a config value
Get {
/// config key
key: String,
},
/// Set a config value
Set {
/// config key
key: String,
/// config value
value: String,
/// use temporary config (cleared on reboot)
#[arg(short, long)]
temp: bool,
},
/// List all config entries
List,
/// Delete a config entry
Delete {
/// config key
key: String,
/// delete from temporary config
#[arg(short, long)]
temp: bool,
},
/// Clear all config entries
Clear {
/// clear temporary config
#[arg(short, long)]
temp: bool,
},
} }
#[derive(clap::Subcommand, Debug)] #[derive(clap::Subcommand, Debug)]
@@ -510,6 +555,66 @@ pub fn run() -> Result<()> {
Module::Disable { id } => module::disable_module(&id), Module::Disable { id } => module::disable_module(&id),
Module::Action { id } => module::run_action(&id), Module::Action { id } => module::run_action(&id),
Module::List => module::list_modules(), Module::List => module::list_modules(),
Module::Config { command } => {
// Get module ID from environment variable
let module_id = std::env::var("KSU_MODULE").map_err(|_| {
anyhow::anyhow!("This command must be run in the context of a module")
})?;
use crate::module_config;
match command {
ModuleConfigCmd::Get { key } => {
// Use merge_configs to respect priority (temp overrides persist)
let config = module_config::merge_configs(&module_id)?;
match config.get(&key) {
Some(value) => {
println!("{}", value);
Ok(())
}
None => anyhow::bail!("Key '{}' not found", key),
}
}
ModuleConfigCmd::Set { key, value, temp } => {
// Validate input at CLI layer for better user experience
module_config::validate_config_key(&key)?;
module_config::validate_config_value(&value)?;
let config_type = if temp {
module_config::ConfigType::Temp
} else {
module_config::ConfigType::Persist
};
module_config::set_config_value(&module_id, &key, &value, config_type)
}
ModuleConfigCmd::List => {
let config = module_config::merge_configs(&module_id)?;
if config.is_empty() {
println!("No config entries found");
} else {
for (key, value) in config {
println!("{}={}", key, value);
}
}
Ok(())
}
ModuleConfigCmd::Delete { key, temp } => {
let config_type = if temp {
module_config::ConfigType::Temp
} else {
module_config::ConfigType::Persist
};
module_config::delete_config_value(&module_id, &key, config_type)
}
ModuleConfigCmd::Clear { temp } => {
let config_type = if temp {
module_config::ConfigType::Temp
} else {
module_config::ConfigType::Persist
};
module_config::clear_config(&module_id, config_type)
}
}
}
} }
} }
Commands::Install { magiskboot } => utils::install(magiskboot), Commands::Install { magiskboot } => utils::install(magiskboot),

View File

@@ -26,6 +26,11 @@ pub const DISABLE_FILE_NAME: &str = "disable";
pub const UPDATE_FILE_NAME: &str = "update"; pub const UPDATE_FILE_NAME: &str = "update";
pub const REMOVE_FILE_NAME: &str = "remove"; pub const REMOVE_FILE_NAME: &str = "remove";
// Module config system
pub const MODULE_CONFIG_DIR: &str = concatcp!(WORKING_DIR, "module_configs/");
pub const PERSIST_CONFIG_NAME: &str = "persist.config";
pub const TEMP_CONFIG_NAME: &str = "tmp.config";
// Metamodule support // Metamodule support
pub const METAMODULE_MOUNT_SCRIPT: &str = "metamount.sh"; pub const METAMODULE_MOUNT_SCRIPT: &str = "metamount.sh";
pub const METAMODULE_METAINSTALL_SCRIPT: &str = "metainstall.sh"; pub const METAMODULE_METAINSTALL_SCRIPT: &str = "metainstall.sh";

View File

@@ -206,6 +206,39 @@ pub fn get_feature(id: String) -> Result<()> {
pub fn set_feature(id: String, value: u64) -> Result<()> { pub fn set_feature(id: String, value: u64) -> Result<()> {
let feature_id = parse_feature_id(&id)?; let feature_id = parse_feature_id(&id)?;
// Check if this feature is managed by any module
if let Ok(managed_features_map) = crate::module::get_managed_features() {
// Find which modules manage this feature
let managing_modules: Vec<&String> = managed_features_map
.iter()
.filter(|(_, features)| features.iter().any(|f| f == feature_id.name()))
.map(|(module_id, _)| module_id)
.collect();
if !managing_modules.is_empty() {
// Feature is managed, check if caller is an authorized module
let caller_module = std::env::var("KSU_MODULE").unwrap_or_default();
if caller_module.is_empty() || !managing_modules.contains(&&caller_module) {
bail!(
"Feature '{}' is managed by module(s): {}. Direct modification is not allowed.",
feature_id.name(),
managing_modules
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
log::info!(
"Module '{}' is setting managed feature '{}'",
caller_module,
feature_id.name()
);
}
}
crate::ksucalls::set_feature(feature_id as u32, value) crate::ksucalls::set_feature(feature_id as u32, value)
.with_context(|| format!("Failed to set feature {} to {}", id, value))?; .with_context(|| format!("Failed to set feature {} to {}", id, value))?;
@@ -358,7 +391,7 @@ pub fn init_features() -> Result<()> {
let mut features = load_binary_config()?; let mut features = load_binary_config()?;
// Get managed features from active modules // Get managed features from active modules and skip them during init
if let Ok(managed_features_map) = crate::module::get_managed_features() { if let Ok(managed_features_map) = crate::module::get_managed_features() {
if !managed_features_map.is_empty() { if !managed_features_map.is_empty() {
log::info!( log::info!(
@@ -366,7 +399,7 @@ pub fn init_features() -> Result<()> {
managed_features_map.len() managed_features_map.len()
); );
// Force override managed features to 0 // Build a set of all managed feature IDs to skip
for (module_id, feature_list) in managed_features_map.iter() { for (module_id, feature_list) in managed_features_map.iter() {
log::info!( log::info!(
"Module '{}' manages {} feature(s)", "Module '{}' manages {} feature(s)",
@@ -376,12 +409,20 @@ pub fn init_features() -> Result<()> {
for feature_name in feature_list { for feature_name in feature_list {
if let Ok(feature_id) = parse_feature_id(feature_name) { if let Ok(feature_id) = parse_feature_id(feature_name) {
let feature_id_u32 = feature_id as u32; let feature_id_u32 = feature_id as u32;
log::info!( // Remove managed features from config, let modules control them
" - Force overriding managed feature '{}' to 0 (by module: {})", if features.remove(&feature_id_u32).is_some() {
feature_name, log::info!(
module_id " - Skipping managed feature '{}' (controlled by module: {})",
); feature_name,
features.insert(feature_id_u32, 0); module_id
);
} else {
log::info!(
" - Feature '{}' is managed by module '{}', skipping",
feature_name,
module_id
);
}
} else { } else {
log::warn!( log::warn!(
" - Unknown managed feature '{}' from module '{}', ignoring", " - Unknown managed feature '{}' from module '{}', ignoring",
@@ -405,9 +446,9 @@ pub fn init_features() -> Result<()> {
apply_config(&features)?; apply_config(&features)?;
// Save the final configuration (including managed features forced to 0) // Save the configuration (excluding managed features)
save_binary_config(&features)?; save_binary_config(&features)?;
log::info!("Saved final feature configuration to file"); log::info!("Saved feature configuration to file");
Ok(()) Ok(())
} }

View File

@@ -15,6 +15,11 @@ pub fn on_post_data_fs() -> Result<()> {
utils::umask(0); utils::umask(0);
// Clear all temporary module configs early
if let Err(e) = crate::module_config::clear_all_temp_configs() {
warn!("clear temp configs failed: {e}");
}
#[cfg(unix)] #[cfg(unix)]
let _ = catch_bootlog("logcat", vec!["logcat"]); let _ = catch_bootlog("logcat", vec!["logcat"]);
#[cfg(unix)] #[cfg(unix)]

View File

@@ -11,6 +11,7 @@ mod kpm;
mod ksucalls; mod ksucalls;
mod metamodule; mod metamodule;
mod module; mod module;
mod module_config;
mod profile; mod profile;
mod restorecon; mod restorecon;
mod sepolicy; mod sepolicy;

View File

@@ -10,7 +10,7 @@ use anyhow::{Context, Result, anyhow, bail, ensure};
use const_format::concatcp; use const_format::concatcp;
use is_executable::is_executable; use is_executable::is_executable;
use java_properties::PropertiesIter; use java_properties::PropertiesIter;
use log::{info, warn}; use log::{debug, info, warn};
use std::fs::{copy, rename}; use std::fs::{copy, rename};
use std::{ use std::{
@@ -39,6 +39,63 @@ const INSTALL_MODULE_SCRIPT: &str = concatcp!(
"\n" "\n"
); );
/// Validate module_id format and security
/// Module ID must match: ^[a-zA-Z][a-zA-Z0-9._-]+$
/// - Must start with a letter (a-zA-Z)
/// - Followed by one or more alphanumeric, dot, underscore, or hyphen characters
/// - Minimum length: 2 characters
pub fn validate_module_id(module_id: &str) -> Result<()> {
if module_id.is_empty() {
bail!("Module ID cannot be empty");
}
if module_id.len() < 2 {
bail!("Module ID too short: must be at least 2 characters");
}
if module_id.len() > 64 {
bail!(
"Module ID too long: {} characters (max: 64)",
module_id.len()
);
}
// Check first character: must be a letter
let first_char = module_id.chars().next().unwrap();
if !first_char.is_ascii_alphabetic() {
bail!(
"Module ID must start with a letter (a-zA-Z), got: '{}'",
first_char
);
}
// Check remaining characters: alphanumeric, dot, underscore, or hyphen
for (i, ch) in module_id.chars().enumerate() {
if i == 0 {
continue; // Already checked
}
if !ch.is_ascii_alphanumeric() && ch != '.' && ch != '_' && ch != '-' {
bail!(
"Module ID contains invalid character '{}' at position {}. Only letters, digits, '.', '_', and '-' are allowed",
ch,
i
);
}
}
// Additional security checks
if module_id.contains("..") {
bail!("Module ID cannot contain '..' sequence");
}
if module_id == "." || module_id == ".." {
bail!("Module ID cannot be '.' or '..'");
}
Ok(())
}
/// Get common environment variables for script execution /// Get common environment variables for script execution
pub(crate) fn get_common_script_envs() -> Vec<(&'static str, String)> { pub(crate) fn get_common_script_envs() -> Vec<(&'static str, String)> {
vec![ vec![
@@ -147,6 +204,41 @@ pub fn load_sepolicy_rule() -> Result<()> {
pub fn exec_script<T: AsRef<Path>>(path: T, wait: bool) -> Result<()> { pub fn exec_script<T: AsRef<Path>>(path: T, wait: bool) -> Result<()> {
info!("exec {}", path.as_ref().display()); info!("exec {}", path.as_ref().display());
// Extract module_id from path if it matches /data/adb/modules/{id}/...
let module_id = path
.as_ref()
.strip_prefix(defs::MODULE_DIR)
.ok()
.and_then(|p| p.components().next())
.and_then(|c| c.as_os_str().to_str())
.map(|s| s.to_string());
// Validate and log module_id extraction
let validated_module_id = module_id
.as_ref()
.and_then(|id| match validate_module_id(id) {
Ok(_) => {
debug!("Module ID extracted from script path: '{}'", id);
Some(id.as_str())
}
Err(e) => {
warn!(
"Invalid module ID '{}' extracted from script path '{}': {}",
id,
path.as_ref().display(),
e
);
None
}
});
if module_id.is_none() {
debug!(
"Failed to extract module_id from script path '{}'. Script will run without KSU_MODULE environment variable.",
path.as_ref().display()
);
}
let mut command = &mut Command::new(assets::BUSYBOX_PATH); let mut command = &mut Command::new(assets::BUSYBOX_PATH);
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -165,6 +257,11 @@ pub fn exec_script<T: AsRef<Path>>(path: T, wait: bool) -> Result<()> {
.arg(path.as_ref()) .arg(path.as_ref())
.envs(get_common_script_envs()); .envs(get_common_script_envs());
// Set KSU_MODULE environment variable if module_id was validated successfully
if let Some(id) = validated_module_id {
command = command.env("KSU_MODULE", id);
}
let result = if wait { let result = if wait {
command.status().map(|_| ()) command.status().map(|_| ())
} else { } else {
@@ -276,6 +373,12 @@ pub fn prune_modules() -> Result<()> {
warn!("Failed to exec uninstaller: {e}"); warn!("Failed to exec uninstaller: {e}");
} }
// Clear module configs before removing module directory
if let Err(e) = crate::module_config::clear_module_configs(module_id) {
warn!("Failed to clear configs for {}: {}", module_id, e);
}
// Finally remove the module directory
if let Err(e) = remove_dir_all(module) { if let Err(e) = remove_dir_all(module) {
warn!("Failed to remove {}: {}", module.display(), e); warn!("Failed to remove {}: {}", module.display(), e);
} }
@@ -363,6 +466,10 @@ fn _install_module(zip: &str) -> Result<()> {
}; };
let module_id = module_id.trim(); let module_id = module_id.trim();
// Validate module_id format
validate_module_id(module_id)
.with_context(|| format!("Invalid module ID in module.prop: '{}'", module_id))?;
// Check if this module is a metamodule // Check if this module is a metamodule
let is_metamodule = metamodule::is_metamodule(&module_prop); let is_metamodule = metamodule::is_metamodule(&module_prop);
@@ -482,6 +589,8 @@ pub fn install_module(zip: &str) -> Result<()> {
} }
pub fn undo_uninstall_module(id: &str) -> Result<()> { pub fn undo_uninstall_module(id: &str) -> Result<()> {
validate_module_id(id)?;
let module_path = Path::new(defs::MODULE_DIR).join(id); let module_path = Path::new(defs::MODULE_DIR).join(id);
ensure!(module_path.exists(), "Module {} not found", id); ensure!(module_path.exists(), "Module {} not found", id);
@@ -497,6 +606,8 @@ pub fn undo_uninstall_module(id: &str) -> Result<()> {
} }
pub fn uninstall_module(id: &str) -> Result<()> { pub fn uninstall_module(id: &str) -> Result<()> {
validate_module_id(id)?;
let module_path = Path::new(defs::MODULE_DIR).join(id); let module_path = Path::new(defs::MODULE_DIR).join(id);
ensure!(module_path.exists(), "Module {} not found", id); ensure!(module_path.exists(), "Module {} not found", id);
@@ -510,11 +621,15 @@ pub fn uninstall_module(id: &str) -> Result<()> {
} }
pub fn run_action(id: &str) -> Result<()> { pub fn run_action(id: &str) -> Result<()> {
validate_module_id(id)?;
let action_script_path = format!("/data/adb/modules/{id}/action.sh"); let action_script_path = format!("/data/adb/modules/{id}/action.sh");
exec_script(&action_script_path, true) exec_script(&action_script_path, true)
} }
pub fn enable_module(id: &str) -> Result<()> { pub fn enable_module(id: &str) -> Result<()> {
validate_module_id(id)?;
let module_path = Path::new(defs::MODULE_DIR).join(id); let module_path = Path::new(defs::MODULE_DIR).join(id);
ensure!(module_path.exists(), "Module {} not found", id); ensure!(module_path.exists(), "Module {} not found", id);
@@ -587,6 +702,15 @@ pub fn read_module_prop(module_path: &Path) -> Result<HashMap<String, String>> {
} }
fn _list_modules(path: &str) -> Vec<HashMap<String, String>> { fn _list_modules(path: &str) -> Vec<HashMap<String, String>> {
// Load all module configs once to minimize I/O overhead
let all_configs = match crate::module_config::get_all_module_configs() {
Ok(configs) => configs,
Err(e) => {
warn!("Failed to load module configs: {}", e);
HashMap::new()
}
};
// first check enabled modules // first check enabled modules
let dir = std::fs::read_dir(path); let dir = std::fs::read_dir(path);
let Ok(dir) = dir else { let Ok(dir) = dir else {
@@ -640,6 +764,32 @@ fn _list_modules(path: &str) -> Vec<HashMap<String, String>> {
module_prop_map.insert("action".to_owned(), action.to_string()); module_prop_map.insert("action".to_owned(), action.to_string());
module_prop_map.insert("mount".to_owned(), need_mount.to_string()); module_prop_map.insert("mount".to_owned(), need_mount.to_string());
// Apply module config overrides and extract managed features
if let Some(module_id) = module_prop_map.get("id")
&& let Some(config) = all_configs.get(module_id.as_str())
{
// Apply override.description
if let Some(desc) = config.get("override.description") {
module_prop_map.insert("description".to_owned(), desc.clone());
}
// Extract managed features from manage.* config entries
let managed_features: Vec<String> = config
.iter()
.filter_map(|(k, v)| {
if k.starts_with("manage.") && crate::module_config::parse_bool_config(v) {
k.strip_prefix("manage.").map(|f| f.to_string())
} else {
None
}
})
.collect();
if !managed_features.is_empty() {
module_prop_map.insert("managedFeatures".to_owned(), managed_features.join(","));
}
}
modules.push(module_prop_map); modules.push(module_prop_map);
} }
@@ -653,45 +803,49 @@ pub fn list_modules() -> Result<()> {
} }
/// Get all managed features from active modules /// Get all managed features from active modules
/// Modules can specify managedFeatures in their module.prop /// Modules declare managed features via config system (manage.<feature>=true)
/// Format: managedFeatures=feature1,feature2,feature3
/// Returns: HashMap<ModuleId, Vec<ManagedFeature>> /// Returns: HashMap<ModuleId, Vec<ManagedFeature>>
pub fn get_managed_features() -> Result<HashMap<String, Vec<String>>> { pub fn get_managed_features() -> Result<HashMap<String, Vec<String>>> {
let mut managed_features_map: HashMap<String, Vec<String>> = HashMap::new(); let mut managed_features_map: HashMap<String, Vec<String>> = HashMap::new();
foreach_active_module(|module_path| { foreach_active_module(|module_path| {
let prop_map = match read_module_prop(module_path) { // Get module ID
Ok(prop) => prop, let module_id = match module_path.file_name().and_then(|n| n.to_str()) {
Err(e) => { Some(id) => id,
None => {
warn!( warn!(
"Failed to read module.prop for {}: {}", "Failed to get module id from path: {}",
module_path.display(), module_path.display()
e
); );
return Ok(()); return Ok(());
} }
}; };
if let Some(features_str) = prop_map.get("managedFeatures") { // Read module config
let module_id = prop_map let config = match crate::module_config::merge_configs(module_id) {
.get("id") Ok(c) => c,
.map(|s| s.to_string()) Err(e) => {
.unwrap_or_else(|| "unknown".to_string()); warn!("Failed to merge configs for module '{}': {}", module_id, e);
return Ok(()); // Skip this module
}
};
info!("Module {} manages features: {}", module_id, features_str); // Extract manage.* config entries
let mut feature_list = Vec::new();
let mut feature_list = Vec::new(); for (key, value) in config.iter() {
for feature in features_str.split(',') { if key.starts_with("manage.") {
let feature = feature.trim(); // Parse feature name
if !feature.is_empty() { if let Some(feature_name) = key.strip_prefix("manage.")
info!(" - Adding managed feature: {}", feature); && crate::module_config::parse_bool_config(value)
feature_list.push(feature.to_string()); {
info!("Module {} manages feature: {}", module_id, feature_name);
feature_list.push(feature_name.to_string());
} }
} }
}
if !feature_list.is_empty() { if !feature_list.is_empty() {
managed_features_map.insert(module_id, feature_list); managed_features_map.insert(module_id.to_string(), feature_list);
}
} }
Ok(()) Ok(())

View File

@@ -0,0 +1,474 @@
use anyhow::{Context, Result, bail};
use log::{debug, warn};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::defs;
use crate::utils::ensure_dir_exists;
const MODULE_CONFIG_MAGIC: u32 = 0x4b53554d; // "KSUM"
const MODULE_CONFIG_VERSION: u32 = 1;
// Validation limits
pub const MAX_CONFIG_KEY_LEN: usize = 256;
pub const MAX_CONFIG_VALUE_LEN: usize = 256;
pub const MAX_CONFIG_COUNT: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigType {
Persist,
Temp,
}
impl ConfigType {
fn filename(&self) -> &'static str {
match self {
ConfigType::Persist => defs::PERSIST_CONFIG_NAME,
ConfigType::Temp => defs::TEMP_CONFIG_NAME,
}
}
}
/// Parse a boolean config value
/// Accepts "true", "1" (case-insensitive) as true, everything else as false
pub fn parse_bool_config(value: &str) -> bool {
let trimmed = value.trim();
trimmed.eq_ignore_ascii_case("true") || trimmed == "1"
}
/// Validate config key
/// Rejects keys with control characters, newlines, or excessive length
pub fn validate_config_key(key: &str) -> Result<()> {
if key.is_empty() {
bail!("Config key cannot be empty");
}
if key.len() > MAX_CONFIG_KEY_LEN {
bail!(
"Config key too long: {} bytes (max: {})",
key.len(),
MAX_CONFIG_KEY_LEN
);
}
// Check for control characters and newlines
for ch in key.chars() {
if ch.is_control() {
bail!(
"Config key contains control character: {:?} (U+{:04X})",
ch,
ch as u32
);
}
}
// Reject keys with path separators to prevent path traversal
if key.contains('/') || key.contains('\\') {
bail!("Config key cannot contain path separators");
}
Ok(())
}
/// Validate config value
/// Rejects values with control characters (except tab), newlines, or excessive length
pub fn validate_config_value(value: &str) -> Result<()> {
if value.len() > MAX_CONFIG_VALUE_LEN {
bail!(
"Config value too long: {} bytes (max: {})",
value.len(),
MAX_CONFIG_VALUE_LEN
);
}
// Check for control characters (allow tab but reject newlines and others)
for ch in value.chars() {
if ch.is_control() && ch != '\t' {
bail!(
"Config value contains invalid control character: {:?} (U+{:04X})",
ch,
ch as u32
);
}
}
Ok(())
}
/// Validate config count
fn validate_config_count(config: &HashMap<String, String>) -> Result<()> {
if config.len() > MAX_CONFIG_COUNT {
bail!(
"Too many config entries: {} (max: {})",
config.len(),
MAX_CONFIG_COUNT
);
}
Ok(())
}
/// Get the config directory path for a module
fn get_config_dir(module_id: &str) -> PathBuf {
Path::new(defs::MODULE_CONFIG_DIR).join(module_id)
}
/// Get the config file path for a module
fn get_config_path(module_id: &str, config_type: ConfigType) -> PathBuf {
get_config_dir(module_id).join(config_type.filename())
}
/// Ensure the config directory exists
fn ensure_config_dir(module_id: &str) -> Result<PathBuf> {
let dir = get_config_dir(module_id);
ensure_dir_exists(&dir)?;
Ok(dir)
}
/// Load config from binary file
pub fn load_config(module_id: &str, config_type: ConfigType) -> Result<HashMap<String, String>> {
crate::module::validate_module_id(module_id)?;
let config_path = get_config_path(module_id, config_type);
if !config_path.exists() {
debug!("Config file not found: {:?}", config_path);
return Ok(HashMap::new());
}
let mut file = File::open(&config_path)
.with_context(|| format!("Failed to open config file: {:?}", config_path))?;
// Read magic
let mut magic_buf = [0u8; 4];
file.read_exact(&mut magic_buf)
.with_context(|| "Failed to read magic")?;
let magic = u32::from_le_bytes(magic_buf);
if magic != MODULE_CONFIG_MAGIC {
bail!(
"Invalid config magic: expected 0x{:08x}, got 0x{:08x}",
MODULE_CONFIG_MAGIC,
magic
);
}
// Read version
let mut version_buf = [0u8; 4];
file.read_exact(&mut version_buf)
.with_context(|| "Failed to read version")?;
let version = u32::from_le_bytes(version_buf);
if version != MODULE_CONFIG_VERSION {
bail!(
"Unsupported config version: expected {}, got {}",
MODULE_CONFIG_VERSION,
version
);
}
// Read count
let mut count_buf = [0u8; 4];
file.read_exact(&mut count_buf)
.with_context(|| "Failed to read count")?;
let count = u32::from_le_bytes(count_buf);
// Read entries
let mut config = HashMap::new();
for i in 0..count {
// Read key length
let mut key_len_buf = [0u8; 4];
file.read_exact(&mut key_len_buf)
.with_context(|| format!("Failed to read key length for entry {}", i))?;
let key_len = u32::from_le_bytes(key_len_buf) as usize;
// Read key data
let mut key_buf = vec![0u8; key_len];
file.read_exact(&mut key_buf)
.with_context(|| format!("Failed to read key data for entry {}", i))?;
let key = String::from_utf8(key_buf)
.with_context(|| format!("Invalid UTF-8 in key for entry {}", i))?;
// Read value length
let mut value_len_buf = [0u8; 4];
file.read_exact(&mut value_len_buf)
.with_context(|| format!("Failed to read value length for entry {}", i))?;
let value_len = u32::from_le_bytes(value_len_buf) as usize;
// Read value data
let mut value_buf = vec![0u8; value_len];
file.read_exact(&mut value_buf)
.with_context(|| format!("Failed to read value data for entry {}", i))?;
let value = String::from_utf8(value_buf)
.with_context(|| format!("Invalid UTF-8 in value for entry {}", i))?;
config.insert(key, value);
}
debug!("Loaded {} entries from {:?}", config.len(), config_path);
Ok(config)
}
/// Save config to binary file
pub fn save_config(
module_id: &str,
config_type: ConfigType,
config: &HashMap<String, String>,
) -> Result<()> {
crate::module::validate_module_id(module_id)?;
// Validate config count
validate_config_count(config)?;
// Validate all keys and values
for (key, value) in config {
validate_config_key(key).with_context(|| format!("Invalid config key: '{}'", key))?;
validate_config_value(value)
.with_context(|| format!("Invalid config value for key '{}'", key))?;
}
ensure_config_dir(module_id)?;
let config_path = get_config_path(module_id, config_type);
let temp_path = config_path.with_extension("tmp");
// Write to temporary file first
let mut file = File::create(&temp_path)
.with_context(|| format!("Failed to create temp config file: {:?}", temp_path))?;
// Write magic
file.write_all(&MODULE_CONFIG_MAGIC.to_le_bytes())
.with_context(|| "Failed to write magic")?;
// Write version
file.write_all(&MODULE_CONFIG_VERSION.to_le_bytes())
.with_context(|| "Failed to write version")?;
// Write count
let count = config.len() as u32;
file.write_all(&count.to_le_bytes())
.with_context(|| "Failed to write count")?;
// Write entries
for (key, value) in config {
// Write key length
let key_bytes = key.as_bytes();
let key_len = key_bytes.len() as u32;
file.write_all(&key_len.to_le_bytes())
.with_context(|| format!("Failed to write key length for '{}'", key))?;
// Write key data
file.write_all(key_bytes)
.with_context(|| format!("Failed to write key data for '{}'", key))?;
// Write value length
let value_bytes = value.as_bytes();
let value_len = value_bytes.len() as u32;
file.write_all(&value_len.to_le_bytes())
.with_context(|| format!("Failed to write value length for '{}'", key))?;
// Write value data
file.write_all(value_bytes)
.with_context(|| format!("Failed to write value data for '{}'", key))?;
}
file.sync_all()
.with_context(|| "Failed to sync config file")?;
// Atomic rename
fs::rename(&temp_path, &config_path).with_context(|| {
format!(
"Failed to rename config file: {:?} -> {:?}",
temp_path, config_path
)
})?;
debug!("Saved {} entries to {:?}", config.len(), config_path);
Ok(())
}
/// Get a single config value
#[allow(dead_code)]
pub fn get_config_value(
module_id: &str,
key: &str,
config_type: ConfigType,
) -> Result<Option<String>> {
let config = load_config(module_id, config_type)?;
Ok(config.get(key).cloned())
}
/// Set a single config value
pub fn set_config_value(
module_id: &str,
key: &str,
value: &str,
config_type: ConfigType,
) -> Result<()> {
// Validate input early for better error messages
validate_config_key(key)?;
validate_config_value(value)?;
let mut config = load_config(module_id, config_type)?;
config.insert(key.to_string(), value.to_string());
// Note: save_config will also validate, but this provides earlier feedback
save_config(module_id, config_type, &config)?;
Ok(())
}
/// Delete a single config value
pub fn delete_config_value(module_id: &str, key: &str, config_type: ConfigType) -> Result<()> {
let mut config = load_config(module_id, config_type)?;
if config.remove(key).is_none() {
bail!("Key '{}' not found in config", key);
}
save_config(module_id, config_type, &config)?;
Ok(())
}
/// Clear all config values
pub fn clear_config(module_id: &str, config_type: ConfigType) -> Result<()> {
let config_path = get_config_path(module_id, config_type);
if config_path.exists() {
fs::remove_file(&config_path)
.with_context(|| format!("Failed to remove config file: {:?}", config_path))?;
debug!("Cleared config: {:?}", config_path);
}
Ok(())
}
/// Merge persist and temp configs (temp takes priority)
pub fn merge_configs(module_id: &str) -> Result<HashMap<String, String>> {
crate::module::validate_module_id(module_id)?;
let mut merged = match load_config(module_id, ConfigType::Persist) {
Ok(config) => config,
Err(e) => {
warn!(
"Failed to load persist config for module '{}': {}",
module_id, e
);
HashMap::new()
}
};
let temp = match load_config(module_id, ConfigType::Temp) {
Ok(config) => config,
Err(e) => {
warn!(
"Failed to load temp config for module '{}': {}",
module_id, e
);
HashMap::new()
}
};
// Temp config overrides persist config
for (key, value) in temp {
merged.insert(key, value);
}
Ok(merged)
}
/// Get all module configs (for iteration)
/// Loads all configs in a single pass to minimize I/O overhead
pub fn get_all_module_configs() -> Result<HashMap<String, HashMap<String, String>>> {
let config_root = Path::new(defs::MODULE_CONFIG_DIR);
if !config_root.exists() {
return Ok(HashMap::new());
}
let mut all_configs = HashMap::new();
for entry in fs::read_dir(config_root)
.with_context(|| format!("Failed to read config directory: {:?}", config_root))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(module_id) = path.file_name().and_then(|n| n.to_str()) {
match merge_configs(module_id) {
Ok(config) => {
if !config.is_empty() {
all_configs.insert(module_id.to_string(), config);
}
}
Err(e) => {
warn!("Failed to load config for module '{}': {}", module_id, e);
// Continue processing other modules
}
}
}
}
Ok(all_configs)
}
/// Clear all temporary configs (called during post-fs-data)
pub fn clear_all_temp_configs() -> Result<()> {
let config_root = Path::new(defs::MODULE_CONFIG_DIR);
if !config_root.exists() {
debug!("Config directory does not exist, nothing to clear");
return Ok(());
}
let mut cleared_count = 0;
for entry in fs::read_dir(config_root)
.with_context(|| format!("Failed to read config directory: {:?}", config_root))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let temp_config = path.join(defs::TEMP_CONFIG_NAME);
if temp_config.exists() {
match fs::remove_file(&temp_config) {
Ok(_) => {
debug!("Cleared temp config: {:?}", temp_config);
cleared_count += 1;
}
Err(e) => {
warn!("Failed to clear temp config {:?}: {}", temp_config, e);
}
}
}
}
if cleared_count > 0 {
debug!("Cleared {} temp config file(s)", cleared_count);
}
Ok(())
}
/// Clear all configs for a module (called during uninstall)
pub fn clear_module_configs(module_id: &str) -> Result<()> {
crate::module::validate_module_id(module_id)?;
let config_dir = get_config_dir(module_id);
if config_dir.exists() {
fs::remove_dir_all(&config_dir)
.with_context(|| format!("Failed to remove config directory: {:?}", config_dir))?;
debug!("Cleared all configs for module: {}", module_id);
}
Ok(())
}