From e3ef521de5b68cf2b510a276726a7fc3be1535e6 Mon Sep 17 00:00:00 2001 From: Ylarod Date: Thu, 20 Nov 2025 21:50:34 +0800 Subject: [PATCH] add module config, migrate managedFeatures (#2965) Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com> --- .../com/sukisu/ultra/ui/screen/Settings.kt | 57 ++- .../java/com/sukisu/ultra/ui/util/KsuCli.kt | 11 +- .../src/main/res/values-zh-rCN/strings.xml | 2 + manager/app/src/main/res/values/strings.xml | 2 + userspace/ksud/src/cli.rs | 105 ++++ userspace/ksud/src/defs.rs | 5 + userspace/ksud/src/feature.rs | 61 ++- userspace/ksud/src/init_event.rs | 5 + userspace/ksud/src/main.rs | 1 + userspace/ksud/src/module.rs | 204 +++++++- userspace/ksud/src/module_config.rs | 474 ++++++++++++++++++ 11 files changed, 881 insertions(+), 46 deletions(-) create mode 100644 userspace/ksud/src/module_config.rs diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt index 9ce8822d..8842ac06 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.rounded.DeveloperMode import androidx.compose.material.icons.rounded.EnhancedEncryption import androidx.compose.material.icons.rounded.Fence 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.RemoveModerator 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable 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.rememberLoadingDialog import com.sukisu.ultra.ui.util.execKsud +import com.sukisu.ultra.ui.util.getFeatureStatus import com.sukisu.ultra.ui.util.rememberKpmAvailable import top.yukonga.miuix.kmp.basic.Card 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( title = stringResource(id = R.string.settings_enable_enhanced_security), - summary = stringResource(id = R.string.settings_enable_enhanced_security_summary), + summary = enhancedSummary, items = modeItems, leftAction = { Icon( @@ -329,6 +340,7 @@ fun SettingPager( tint = colorScheme.onBackground ) }, + enabled = enhancedStatus == "supported", selectedIndex = enhancedSecurityMode, onSelectedIndexChange = { 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( title = stringResource(id = R.string.settings_disable_su), - summary = stringResource(id = R.string.settings_disable_su_summary), + summary = suSummary, items = modeItems, leftAction = { Icon( @@ -379,6 +399,7 @@ fun SettingPager( tint = colorScheme.onBackground ) }, + enabled = suStatus == "supported", selectedIndex = suCompatMode, onSelectedIndexChange = { 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( title = stringResource(id = R.string.settings_disable_kernel_umount), - summary = stringResource(id = R.string.settings_disable_kernel_umount_summary), + summary = umountSummary, items = modeItems, leftAction = { Icon( @@ -429,6 +458,7 @@ fun SettingPager( tint = colorScheme.onBackground ) }, + enabled = umountStatus == "supported", selectedIndex = kernelUmountMode, onSelectedIndexChange = { index -> when (index) { @@ -458,7 +488,7 @@ fun SettingPager( } ) - var SuLogMode by rememberSaveable { + var suLogMode by rememberSaveable { mutableIntStateOf( run { 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( title = stringResource(id = R.string.settings_disable_sulog), - summary = stringResource(id = R.string.settings_disable_sulog_summary), + summary = suLogSummary, items = modeItems, leftAction = { Icon( @@ -479,14 +517,15 @@ fun SettingPager( tint = colorScheme.onBackground ) }, - selectedIndex = SuLogMode, + enabled = suLogStatus == "supported", + selectedIndex = suLogMode, onSelectedIndexChange = { index -> when (index) { // Default: enable and save to persist 0 -> if (Natives.setSuLogEnabled(true)) { execKsud("feature save", true) prefs.edit { putInt("sulog_mode", 0) } - SuLogMode = 0 + suLogMode = 0 isSuLogEnabled = true } @@ -495,7 +534,7 @@ fun SettingPager( execKsud("feature save", true) if (Natives.setSuLogEnabled(false)) { prefs.edit { putInt("sulog_mode", 0) } - SuLogMode = 1 + suLogMode = 1 isSuLogEnabled = false } } @@ -504,7 +543,7 @@ fun SettingPager( 2 -> if (Natives.setSuLogEnabled(false)) { execKsud("feature save", true) prefs.edit { putInt("sulog_mode", 2) } - SuLogMode = 2 + suLogMode = 2 isSuLogEnabled = false } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt index c8c97b5c..b549f55f 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt @@ -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(), null).exec().out + out.firstOrNull()?.trim().orEmpty() +} + fun install() { val start = SystemClock.elapsedRealtime() val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath @@ -118,8 +125,8 @@ fun install() { fun listModules(): String { val shell = getRootShell() - val out = - shell.newJob().add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out + val out = shell.newJob() + .add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out return out.joinToString("\n").ifBlank { "[]" } } 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 38ec7231..0b8fe3a0 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -144,6 +144,8 @@ 关闭 KernelSU 控制的内核级 umount 行为。 增强安全性 使用更严格的安全策略。 + 内核不支持此功能。 + 此功能由模块管理。 默认 临时启用 始终启用 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 2831f5a3..793eaedd 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -148,6 +148,8 @@ Disable kernel-level umount behavior controlled by KernelSU. Enable enhanced security Enable stricter security policies. + Kernel does not support this feature. + This feature is managed by a module. Default Temporarily enable Permanently enable diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 4fac0d4c..7afbe99f 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -302,6 +302,51 @@ enum Module { /// list all modules 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)] @@ -510,6 +555,66 @@ pub fn run() -> Result<()> { Module::Disable { id } => module::disable_module(&id), Module::Action { id } => module::run_action(&id), 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), diff --git a/userspace/ksud/src/defs.rs b/userspace/ksud/src/defs.rs index 3ce7eab1..27925421 100644 --- a/userspace/ksud/src/defs.rs +++ b/userspace/ksud/src/defs.rs @@ -26,6 +26,11 @@ pub const DISABLE_FILE_NAME: &str = "disable"; pub const UPDATE_FILE_NAME: &str = "update"; 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 pub const METAMODULE_MOUNT_SCRIPT: &str = "metamount.sh"; pub const METAMODULE_METAINSTALL_SCRIPT: &str = "metainstall.sh"; diff --git a/userspace/ksud/src/feature.rs b/userspace/ksud/src/feature.rs index da449e6f..626828d1 100644 --- a/userspace/ksud/src/feature.rs +++ b/userspace/ksud/src/feature.rs @@ -206,6 +206,39 @@ pub fn get_feature(id: String) -> Result<()> { pub fn set_feature(id: String, value: u64) -> Result<()> { 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::>() + .join(", ") + ); + } + + log::info!( + "Module '{}' is setting managed feature '{}'", + caller_module, + feature_id.name() + ); + } + } + crate::ksucalls::set_feature(feature_id as u32, 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()?; - // 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 !managed_features_map.is_empty() { log::info!( @@ -366,7 +399,7 @@ pub fn init_features() -> Result<()> { 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() { log::info!( "Module '{}' manages {} feature(s)", @@ -376,12 +409,20 @@ pub fn init_features() -> Result<()> { for feature_name in feature_list { if let Ok(feature_id) = parse_feature_id(feature_name) { let feature_id_u32 = feature_id as u32; - log::info!( - " - Force overriding managed feature '{}' to 0 (by module: {})", - feature_name, - module_id - ); - features.insert(feature_id_u32, 0); + // Remove managed features from config, let modules control them + if features.remove(&feature_id_u32).is_some() { + log::info!( + " - Skipping managed feature '{}' (controlled by module: {})", + feature_name, + module_id + ); + } else { + log::info!( + " - Feature '{}' is managed by module '{}', skipping", + feature_name, + module_id + ); + } } else { log::warn!( " - Unknown managed feature '{}' from module '{}', ignoring", @@ -405,9 +446,9 @@ pub fn init_features() -> Result<()> { apply_config(&features)?; - // Save the final configuration (including managed features forced to 0) + // Save the configuration (excluding managed features) save_binary_config(&features)?; - log::info!("Saved final feature configuration to file"); + log::info!("Saved feature configuration to file"); Ok(()) } diff --git a/userspace/ksud/src/init_event.rs b/userspace/ksud/src/init_event.rs index 674826e7..be467c56 100644 --- a/userspace/ksud/src/init_event.rs +++ b/userspace/ksud/src/init_event.rs @@ -15,6 +15,11 @@ pub fn on_post_data_fs() -> Result<()> { 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)] let _ = catch_bootlog("logcat", vec!["logcat"]); #[cfg(unix)] diff --git a/userspace/ksud/src/main.rs b/userspace/ksud/src/main.rs index 173f10e5..945aeaef 100644 --- a/userspace/ksud/src/main.rs +++ b/userspace/ksud/src/main.rs @@ -11,6 +11,7 @@ mod kpm; mod ksucalls; mod metamodule; mod module; +mod module_config; mod profile; mod restorecon; mod sepolicy; diff --git a/userspace/ksud/src/module.rs b/userspace/ksud/src/module.rs index 3f1b1cc2..5202e0d1 100644 --- a/userspace/ksud/src/module.rs +++ b/userspace/ksud/src/module.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result, anyhow, bail, ensure}; use const_format::concatcp; use is_executable::is_executable; use java_properties::PropertiesIter; -use log::{info, warn}; +use log::{debug, info, warn}; use std::fs::{copy, rename}; use std::{ @@ -39,6 +39,63 @@ const INSTALL_MODULE_SCRIPT: &str = concatcp!( "\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 pub(crate) fn get_common_script_envs() -> Vec<(&'static str, String)> { vec![ @@ -147,6 +204,41 @@ pub fn load_sepolicy_rule() -> Result<()> { pub fn exec_script>(path: T, wait: bool) -> Result<()> { 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); #[cfg(unix)] { @@ -165,6 +257,11 @@ pub fn exec_script>(path: T, wait: bool) -> Result<()> { .arg(path.as_ref()) .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 { command.status().map(|_| ()) } else { @@ -276,6 +373,12 @@ pub fn prune_modules() -> Result<()> { 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) { warn!("Failed to remove {}: {}", module.display(), e); } @@ -363,6 +466,10 @@ fn _install_module(zip: &str) -> Result<()> { }; 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 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<()> { + validate_module_id(id)?; + let module_path = Path::new(defs::MODULE_DIR).join(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<()> { + validate_module_id(id)?; + let module_path = Path::new(defs::MODULE_DIR).join(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<()> { + validate_module_id(id)?; + let action_script_path = format!("/data/adb/modules/{id}/action.sh"); exec_script(&action_script_path, true) } pub fn enable_module(id: &str) -> Result<()> { + validate_module_id(id)?; + let module_path = Path::new(defs::MODULE_DIR).join(id); ensure!(module_path.exists(), "Module {} not found", id); @@ -587,6 +702,15 @@ pub fn read_module_prop(module_path: &Path) -> Result> { } fn _list_modules(path: &str) -> Vec> { + // 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 let dir = std::fs::read_dir(path); let Ok(dir) = dir else { @@ -640,6 +764,32 @@ fn _list_modules(path: &str) -> Vec> { module_prop_map.insert("action".to_owned(), action.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 = 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); } @@ -653,45 +803,49 @@ pub fn list_modules() -> Result<()> { } /// Get all managed features from active modules -/// Modules can specify managedFeatures in their module.prop -/// Format: managedFeatures=feature1,feature2,feature3 +/// Modules declare managed features via config system (manage.=true) /// Returns: HashMap> pub fn get_managed_features() -> Result>> { let mut managed_features_map: HashMap> = HashMap::new(); foreach_active_module(|module_path| { - let prop_map = match read_module_prop(module_path) { - Ok(prop) => prop, - Err(e) => { + // Get module ID + let module_id = match module_path.file_name().and_then(|n| n.to_str()) { + Some(id) => id, + None => { warn!( - "Failed to read module.prop for {}: {}", - module_path.display(), - e + "Failed to get module id from path: {}", + module_path.display() ); return Ok(()); } }; - if let Some(features_str) = prop_map.get("managedFeatures") { - let module_id = prop_map - .get("id") - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + // Read module config + let config = match crate::module_config::merge_configs(module_id) { + Ok(c) => c, + Err(e) => { + warn!("Failed to merge configs for module '{}': {}", module_id, e); + return Ok(()); // Skip this module + } + }; - info!("Module {} manages features: {}", module_id, features_str); - - let mut feature_list = Vec::new(); - for feature in features_str.split(',') { - let feature = feature.trim(); - if !feature.is_empty() { - info!(" - Adding managed feature: {}", feature); - feature_list.push(feature.to_string()); + // Extract manage.* config entries + let mut feature_list = Vec::new(); + for (key, value) in config.iter() { + if key.starts_with("manage.") { + // Parse feature name + if let Some(feature_name) = key.strip_prefix("manage.") + && crate::module_config::parse_bool_config(value) + { + info!("Module {} manages feature: {}", module_id, feature_name); + feature_list.push(feature_name.to_string()); } } + } - if !feature_list.is_empty() { - managed_features_map.insert(module_id, feature_list); - } + if !feature_list.is_empty() { + managed_features_map.insert(module_id.to_string(), feature_list); } Ok(()) diff --git a/userspace/ksud/src/module_config.rs b/userspace/ksud/src/module_config.rs new file mode 100644 index 00000000..90518a39 --- /dev/null +++ b/userspace/ksud/src/module_config.rs @@ -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) -> 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 { + 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> { + 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, +) -> 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> { + 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> { + 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>> { + 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(()) +}