add module config, migrate managedFeatures (#2965)
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { "[]" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
474
userspace/ksud/src/module_config.rs
Normal file
474
userspace/ksud/src/module_config.rs
Normal 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user