* Revert "chore(ksud): bump ksud's deps (#585)"
* Because it may cause compilation errors.
This reverts commit c8020b2066.
* chore(ksud): remove unused Result
Signed-off-by: Tools-app <localhost.hutao@gmail.com>
* chore(ksud): enable clippy::all, clippy::pedantic && make clippy happy
https://rust-lang.github.io/rust-clippy/master/index.html#map_unwrap_or
https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args
https://rust-lang.github.io/rust-clippy/master/index.html#used_underscore_items
https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls
https://rust-lang.github.io/rust-clippy/master/index.html#redundant_pub_crate
...
and use some #![allow(...)] or #[allow(...)]
Signed-off-by: Tools-app <localhost.hutao@gmail.com>
---------
Signed-off-by: Tools-app <localhost.hutao@gmail.com>
479 lines
14 KiB
Rust
479 lines
14 KiB
Rust
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;
|
|
|
|
#[allow(clippy::unreadable_literal)]
|
|
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 {
|
|
const fn filename(self) -> &'static str {
|
|
match self {
|
|
Self::Persist => defs::PERSIST_CONFIG_NAME,
|
|
Self::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.display());
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let mut file = File::open(&config_path)
|
|
.with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
|
|
|
|
// 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 {MODULE_CONFIG_VERSION}, got {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.display()
|
|
);
|
|
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.display()))?;
|
|
|
|
// 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.display(),
|
|
config_path.display()
|
|
)
|
|
})?;
|
|
|
|
debug!(
|
|
"Saved {} entries to {}",
|
|
config.len(),
|
|
config_path.display()
|
|
);
|
|
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 '{key}' not found in config");
|
|
}
|
|
|
|
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.display()))?;
|
|
debug!("Cleared config: {}", config_path.display());
|
|
}
|
|
|
|
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.display()))?
|
|
{
|
|
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.display()))?
|
|
{
|
|
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.display());
|
|
cleared_count += 1;
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to clear temp config {}: {e}", temp_config.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if cleared_count > 0 {
|
|
debug!("Cleared {cleared_count} temp config file(s)");
|
|
}
|
|
|
|
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.display()
|
|
)
|
|
})?;
|
|
debug!("Cleared all configs for module: {module_id}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|