diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 23574692e..4abc64ad2 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -147,12 +147,16 @@ impl Config { } logging!(debug, Type::Config, "生成运行时配置成功"); - let config_result = Self::generate_file(ConfigType::Run).await; + // Run file first: startup rewrites it via use_default_config when validation fails. + let config_result = match Self::runtime_config_yaml().await { + Ok(yaml) => Self::write_runtime_file(&yaml).await.map(|_| yaml), + Err(error) => Err(error), + }; - if config_result.is_ok() { + if let Ok(yaml) = &config_result { logging!(debug, Type::Config, "开始验证配置"); - match CoreConfigValidator::global().validate_config_outcome().await { + match CoreConfigValidator::global().validate_config_outcome_with(yaml).await { Ok(outcome) if outcome.is_valid() => { logging!(debug, Type::Config, "配置验证成功"); Ok(None) @@ -188,22 +192,31 @@ impl Config { } } - pub async fn generate_file(typ: ConfigType) -> Result { - let path = match typ { - ConfigType::Run => dirs::app_home_dir()?.join(files::RUNTIME_CONFIG), - ConfigType::Check => dirs::app_home_dir()?.join(files::CHECK_CONFIG), - }; + pub async fn generate_file() -> Result { + let yaml = Self::runtime_config_yaml().await?; + Self::write_runtime_file(&yaml).await + } + pub(crate) async fn runtime_config_yaml() -> Result { let runtime = Self::runtime().await; - let runtime_lastest = runtime.latest_arc(); - let runtime_data = runtime.data_arc(); - let config = runtime_lastest - .config - .as_ref() - .or_else(|| runtime_data.config.as_ref()) - .ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?; + AsyncHandler::spawn_blocking(move || { + let runtime_lastest = runtime.latest_arc(); + let runtime_data = runtime.data_arc(); + let config = runtime_lastest + .config + .as_ref() + .or_else(|| runtime_data.config.as_ref()) + .ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?; + let yaml_str = serde_yaml_ng::to_string(config)?; + Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str)) + }) + .await + .map_err(|join| anyhow!("runtime serialization task failed: {join}"))? + } - help::save_yaml(&path, config, Some("# Generated by Clash Verge")).await?; + pub(crate) async fn write_runtime_file(yaml: &str) -> Result { + let path = dirs::app_home_dir()?.join(files::RUNTIME_CONFIG); + help::save_yaml_str(&path, yaml).await?; Ok(path) } @@ -346,9 +359,3 @@ fn collect_names(config: &Mapping, list_key: &str, out: &mut HashSet) { } } } - -#[derive(Debug)] -pub(crate) enum ConfigType { - Run, - Check, -} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 27d2e6d83..add685f62 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -11,7 +11,7 @@ pub mod runtime; pub(crate) mod snapshot; mod verge; -pub(crate) use self::config::{Config, ConfigType}; +pub(crate) use self::config::Config; pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*}; pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { diff --git a/src-tauri/src/config/port.rs b/src-tauri/src/config/port.rs index 5219690b1..50cdcb78d 100644 --- a/src-tauri/src/config/port.rs +++ b/src-tauri/src/config/port.rs @@ -1,4 +1,4 @@ -use super::{Config, ConfigType, IClashTemp, IVerge, MixedPort}; +use super::{Config, IClashTemp, IVerge, MixedPort}; use crate::{ constants::timing, core::{ @@ -129,15 +129,18 @@ impl Config { .await .context("failed to materialize runtime configuration with fallback port")?; + let yaml = Self::runtime_config_yaml() + .await + .context("failed to validate runtime configuration with fallback port")?; let validation = CoreConfigValidator::global() - .validate_config_outcome() + .validate_config_outcome_with(&yaml) .await .context("failed to validate runtime configuration with fallback port")?; if !validation.is_valid() { bail!("runtime configuration with fallback port is invalid: {validation}"); } - Self::generate_file(ConfigType::Run) + Self::generate_file() .await .context("failed to write Runtime Configuration")?; Ok(()) diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index a775c56d7..d6324e553 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -1,7 +1,7 @@ use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode}; use crate::core::service::StageRequest; use crate::{ - config::{Config, ConfigType, IProfiles, runtime::IRuntime}, + config::{Config, IProfiles, runtime::IRuntime}, constants::timing, core::{ handle, @@ -299,12 +299,16 @@ impl CoreManager { /// Validates and applies the caller's transaction, committing only on success. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { - let outcome = CoreConfigValidator::global().validate_config_outcome().await?; + // One serialization feeds check and run files; the core never applies unvalidated bytes. + let yaml = Config::runtime_config_yaml().await?; + let outcome = CoreConfigValidator::global() + .validate_config_outcome_with(&yaml) + .await?; if !outcome.is_valid() { return Ok(outcome); } - let run_path = Config::generate_file(ConfigType::Run).await?; + let run_path = Config::write_runtime_file(&yaml).await?; self.apply_config(run_path).await?; transaction.commit(); Ok(ValidationOutcome::Valid) diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index f39e872ec..be2d7a73a 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -104,7 +104,7 @@ impl CoreManager { handle::Handle::app_handle() .mihomo() .update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?; - let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; + let config_file = Config::generate_file().await?; let app_handle = handle::Handle::app_handle(); let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); let config_dir = dirs::app_home_dir()?; @@ -255,7 +255,7 @@ impl CoreManager { pub(super) async fn start_core_by_service(&self) -> Result<()> { self.core_starting(); let service_ipc = dirs::ipc_path()?; - let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; + let config_file = Config::generate_file().await?; handle::Handle::app_handle() .mihomo() .update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?; diff --git a/src-tauri/src/core/runtime_bundle.rs b/src-tauri/src/core/runtime_bundle.rs index d74276f7d..ede83e520 100644 --- a/src-tauri/src/core/runtime_bundle.rs +++ b/src-tauri/src/core/runtime_bundle.rs @@ -5,7 +5,7 @@ use serde_yaml_ng::Value; use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; -const GEO_ASSETS: &[&str] = &[ +pub(crate) const GEO_ASSETS: &[&str] = &[ "Country.mmdb", "geoip.dat", "geosite.dat", diff --git a/src-tauri/src/core/validate.rs b/src-tauri/src/core/validate.rs index 99e9e455d..586f84e96 100644 --- a/src-tauri/src/core/validate.rs +++ b/src-tauri/src/core/validate.rs @@ -4,17 +4,49 @@ use serde::Serialize; use smartstring::alias::String; use std::{ fmt, + hash::{DefaultHasher, Hash as _, Hasher as _}, + sync::LazyLock, sync::atomic::{AtomicBool, Ordering}, }; use tauri_plugin_shell::ShellExt as _; use tokio::fs; -use crate::config::{Config, ConfigType}; +use crate::config::Config; use crate::core::handle; use crate::singleton; -use crate::utils::dirs; +use crate::utils::{dirs, help}; use clash_verge_logging::{Type, logging}; +const SYNTAX_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +static LAST_VALIDATED: LazyLock>> = LazyLock::new(|| parking_lot::Mutex::new(None)); + +async fn validation_fingerprint(yaml: &str) -> u64 { + let core = Config::verge().await.latest_arc().get_valid_clash_core(); + let mut hasher = DefaultHasher::new(); + yaml.hash(&mut hasher); + core.as_str().hash(&mut hasher); + let binary = std::env::current_exe() + .into_iter() + .map(|exe| exe.with_file_name(format!("{}{}", core.as_str(), std::env::consts::EXE_SUFFIX))); + // The test run reads geo databases from the app dir; a geo update must revalidate. + let geo = dirs::app_home_dir().into_iter().flat_map(|dir| { + super::runtime_bundle::GEO_ASSETS + .iter() + .map(move |asset| dir.join(asset)) + }); + for path in binary.chain(geo) { + match std::fs::metadata(&path) { + Ok(meta) => { + meta.len().hash(&mut hasher); + meta.modified().ok().hash(&mut hasher); + } + Err(_) => 0u8.hash(&mut hasher), + } + } + hasher.finish() +} + pub struct CoreConfigValidator { is_processing: AtomicBool, } @@ -223,28 +255,33 @@ impl CoreConfigValidator { }; logging!(debug, Type::Validate, "验证脚本文件: {}", path); + let has_main = + content.contains("function main") || content.contains("const main") || content.contains("let main"); - use boa_engine::{Context, Source}; + // Boa parsing is pure CPU; keep it off the async worker, with a timeout. + let syntax = crate::process::AsyncHandler::spawn_blocking(move || { + use boa_engine::{Context, Source}; - let mut context = Context::default(); - let _ = context.eval(Source::from_bytes( - "var console = Object.freeze({ - log(...data){}, - info(...data){}, - error(...data){}, - debug(...data){}, - });", - )); - let result = context.eval(Source::from_bytes(&content)); + let mut context = Context::default(); + let _ = context.eval(Source::from_bytes( + "var console = Object.freeze({log(...data){},info(...data){},error(...data){},debug(...data){}});", + )); + context + .eval(Source::from_bytes(&content)) + .map(|_| ()) + .map_err(|err| err.to_string()) + }); + let result = match tokio::time::timeout(SYNTAX_CHECK_TIMEOUT, syntax).await { + Ok(Ok(evaluated)) => evaluated, + Ok(Err(join_err)) => Err(format!("syntax check task failed: {join_err}")), + Err(_) => Err("syntax check timed out".to_owned()), + }; match result { - Ok(_) => { + Ok(()) => { logging!(debug, Type::Validate, "脚本语法验证通过: {}", path); - if !content.contains("function main") - && !content.contains("const main") - && !content.contains("let main") - { + if !has_main { let error_msg = "Script must contain a main function"; logging!(warn, Type::Validate, "脚本缺少main函数: {}", path); return Ok(ValidationOutcome::invalid_from_message(error_msg)); @@ -371,7 +408,8 @@ impl CoreConfigValidator { } } - pub async fn validate_config_outcome(&self) -> Result { + /// Skips the subprocess when these bytes were already accepted by the same core build. + pub async fn validate_config_outcome_with(&self, yaml: &str) -> Result { if !self.try_start() { logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求"); return Ok(ValidationOutcome::Busy); @@ -381,9 +419,20 @@ impl CoreConfigValidator { } logging!(info, Type::Validate, "生成临时配置文件用于验证"); - let config_path = Config::generate_file(ConfigType::Check).await?; - let config_path = dirs::path_to_str(&config_path)?; - Self::validate_config_internal_outcome(config_path).await + let fingerprint = validation_fingerprint(yaml).await; + if *LAST_VALIDATED.lock() == Some(fingerprint) { + logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查"); + return Ok(ValidationOutcome::Valid); + } + + let check_path = dirs::app_home_dir()?.join(crate::constants::files::CHECK_CONFIG); + help::save_yaml_str(&check_path, yaml).await?; + let outcome = Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await?; + if outcome.is_valid() { + *LAST_VALIDATED.lock() = Some(fingerprint); + let _ = fs::remove_file(&check_path).await; + } + Ok(outcome) } } diff --git a/src-tauri/src/enhance/merge.rs b/src-tauri/src/enhance/merge.rs index a348d576e..db524b8aa 100644 --- a/src-tauri/src/enhance/merge.rs +++ b/src-tauri/src/enhance/merge.rs @@ -1,6 +1,6 @@ use clash_verge_logging::{Type, logging}; -use super::use_lowercase; +use super::field::use_lowercase; use serde_yaml_ng::{self, Mapping, Value}; fn deep_merge(a: &mut Value, b: Value) { diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index 2f85e22a3..888597bf2 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -7,7 +7,7 @@ mod tun; use self::{ chain::{AsyncChainItemFrom as _, ChainItem, ChainType}, - field::{use_keys, use_lowercase, use_sort}, + field::{use_keys, use_lowercase_owned, use_sort}, merge::use_merge, script::use_script, seq::{SeqMap, use_seq}, @@ -254,15 +254,9 @@ async fn process_global_items( } if let ChainType::Script(script) = global_script.data { - let mut logs = vec![]; - match use_script(script, config.clone(), profile_name.clone()).await { - Ok((res_config, res_logs)) => { - extend_changed_keys(&mut exists_keys, &config, &res_config); - config = res_config; - logs.extend(res_logs); - } - Err(err) => logs.push(("exception".into(), err.to_string().into())), - } + let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await; + exists_keys.extend(changed_keys); + config = res_config; result_map.insert(global_script.uid, logs); } @@ -290,20 +284,6 @@ fn process_seq_items( config } -fn extend_changed_keys(exists_keys: &mut Vec, config: &Mapping, res_config: &Mapping) { - exists_keys.extend(res_config.iter().filter_map(|(key, value)| { - if config.get(key) == Some(value) { - return None; - } - - key.as_str().map(|key| { - let mut key: String = key.into(); - key.make_ascii_lowercase(); - key - }) - })); -} - /// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。 /// 平台键随 cfg 门控;`dns.ipv6` 单独处理。 const CONTROL_PLANE_KEYS: &[&str] = &[ @@ -450,15 +430,9 @@ async fn process_profile_items( } if let ChainType::Script(script) = script_item.data { - let mut logs = vec![]; - match use_script(script, config.clone(), profile_name.clone()).await { - Ok((res_config, res_logs)) => { - extend_changed_keys(&mut exists_keys, &config, &res_config); - config = res_config; - logs.extend(res_logs); - } - Err(err) => logs.push(("exception".into(), err.to_string().into())), - } + let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await; + exists_keys.extend(changed_keys); + config = res_config; result_map.insert(script_item.uid, logs); } @@ -540,24 +514,23 @@ fn merge_default_config( config } -async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option, enable_builtin: bool) -> Mapping { +fn apply_builtin_scripts(mut config: Mapping, clash_core: Option, enable_builtin: bool) -> Mapping { if enable_builtin { let items: Vec<_> = ChainItem::builtin() .into_iter() .filter(|(s, _)| s.is_support(clash_core.as_ref())) .map(|(_, c)| c) .collect(); - for item in items { - logging!(debug, Type::Core, "run builtin script {}", item.uid); - if let ChainType::Script(script) = item.data { - match use_script(script, config.clone(), String::from("")).await { - Ok((res_config, _)) => { - config = res_config; - } - Err(err) => { - logging!(error, Type::Core, "builtin script error `{err:#}`"); - } - } + if !items.is_empty() { + // The JS path saw a lowercased view; lowercase once, only when a builtin runs. + config = use_lowercase_owned(config); + for item in items { + logging!(debug, Type::Core, "run builtin script {}", item.uid); + config = match item.uid.as_str() { + "verge_hy_alpn" => builtin_hy_alpn(config), + "verge_meta_guard" => builtin_meta_guard(config), + _ => config, + }; } } } @@ -565,6 +538,28 @@ async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option, config } +fn builtin_hy_alpn(mut config: Mapping) -> Mapping { + if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") { + for proxy in proxies.iter_mut() { + let Some(proxy) = proxy.as_mapping_mut() else { continue }; + let is_hysteria = proxy.get("type").and_then(Value::as_str) == Some("hysteria"); + if is_hysteria && let Some(Value::String(alpn)) = proxy.get("alpn").cloned() { + proxy.insert("alpn".into(), Value::Sequence(vec![Value::String(alpn)])); + } + } + } + + config +} + +fn builtin_meta_guard(mut config: Mapping) -> Mapping { + if config.get("mode").and_then(Value::as_str) == Some("script") { + config.insert(Value::from("mode"), Value::from("rule")); + } + + config +} + fn cleanup_proxy_groups(mut config: Mapping) -> Mapping { // built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"]; @@ -760,7 +755,7 @@ pub async fn enhance( tproxy_enabled, ); - let config = apply_builtin_scripts(config, clash_core, enable_builtin).await; + let config = apply_builtin_scripts(config, clash_core, enable_builtin); let config = use_tun(config, enable_tun); let config = apply_dns_settings(config, enable_dns_settings).await; diff --git a/src-tauri/src/enhance/script.rs b/src-tauri/src/enhance/script.rs index 40d151fe0..2aaee1121 100644 --- a/src-tauri/src/enhance/script.rs +++ b/src-tauri/src/enhance/script.rs @@ -1,8 +1,8 @@ -use crate::process::AsyncHandler; +use crate::{process::AsyncHandler, utils::tmpl}; use super::field::{use_lowercase, use_lowercase_owned}; use anyhow::{Error, Result}; -use boa_engine::{Context, JsString, JsValue, Source, native_function::NativeFunction}; +use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute}; use clash_verge_logging::{Type, logging}; use parking_lot::Mutex; use serde_yaml_ng::Mapping; @@ -15,16 +15,58 @@ const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10MB const MAX_LOOP_ITERATIONS: u64 = 10_000_000; const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -pub async fn use_script(script: String, config: Mapping, name: String) -> Result<(Mapping, Vec<(String, String)>)> { - let handle = AsyncHandler::spawn_blocking(move || use_script_sync(script, &config, &name)); - match tokio::time::timeout(SCRIPT_TIMEOUT, handle).await { - Ok(Ok(result)) => result, - Ok(Err(join_err)) => Err(anyhow::anyhow!("script task panicked: {join_err}")), - Err(_elapsed) => Err(anyhow::anyhow!("script execution timed out after {:?}", SCRIPT_TIMEOUT)), +type ScriptResult = (Mapping, Vec, Vec<(String, String)>); +type EvalOutcome = (Result, Vec<(String, String)>); + +/// Never fails: exceptions are logged and the input config is returned unchanged. +pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult { + // The template is identity-plus-lowercasing; skip the Boa round-trip. + if script == tmpl::ITEM_SCRIPT { + return (use_lowercase_owned(config), vec![], vec![]); + } + + let json = match serde_json::to_string(&use_lowercase(&config)) { + Ok(json) if json.len() <= MAX_JSON_SIZE => json, + Ok(_) => { + let message = "Configuration size exceeds maximum allowed size"; + return (config, vec![], vec![("exception".into(), message.into())]); + } + Err(err) => return (config, vec![], vec![("exception".into(), err.to_string().into())]), + }; + + let evaluated = AsyncHandler::spawn_blocking(move || eval_script(&script, &json, &name)); + let (outcome, mut logs) = match tokio::time::timeout(SCRIPT_TIMEOUT, evaluated).await { + Ok(Ok(evaluated)) => evaluated, + Ok(Err(join_err)) => (Err(format!("script task panicked: {join_err}")), vec![]), + Err(_elapsed) => ( + Err(format!("script execution timed out after {SCRIPT_TIMEOUT:?}")), + vec![], + ), + }; + + match outcome { + Ok(result) => { + // Compared against the config as handed in, not the lowercased copy the script saw. + let changed_keys = result + .iter() + .filter(|(key, value)| config.get(key) != Some(value)) + .filter_map(|(key, _)| { + let mut key: String = key.as_str()?.into(); + key.make_ascii_lowercase(); + Some(key) + }) + .collect(); + (result, changed_keys, logs) + } + Err(reason) => { + logs.push(("exception".into(), reason.into())); + (config, vec![], logs) + } } } -fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(Mapping, Vec<(String, String)>)> { +/// Console logs survive every failure path. +fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome { let mut context = Context::default(); context @@ -37,6 +79,8 @@ fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(M let outputs_clone = Arc::clone(&outputs); let total_size_clone = Arc::clone(&total_size); + let bail = |reason: std::string::String| (Err(reason), outputs.lock().to_vec()); + let _ = context.register_global_builtin_callable("__verge_log__".into(), 2, unsafe { NativeFunction::from_closure(move |_: &JsValue, args: &[JsValue], context: &mut Context| { let level = args @@ -87,60 +131,57 @@ fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(M });"#, )); - let config = use_lowercase(config); - let config_str = serde_json::to_string(&config)?; - if config_str.len() > MAX_JSON_SIZE { - anyhow::bail!("Configuration size exceeds maximum allowed size"); + // Bind the JSON instead of embedding it in program source; Boa's parser never chews it. + if let Err(err) = context.register_global_property( + js_string!("__verge_config__"), + JsValue::from(JsString::from(config_json)), + Attribute::all(), + ) { + return bail(format!("failed to bind config for script: {err}")); } // 仅处理 name 参数中的特殊字符 let safe_name = escape_js_string_for_single_quote(name); if safe_name.len() > 1024 { - anyhow::bail!("Name parameter too long"); + return bail("Name parameter too long".to_owned()); } let code = format!( r"try{{ {script}; - JSON.stringify(main({config_str},'{safe_name}')||'') + JSON.stringify(main(JSON.parse(globalThis.__verge_config__),'{safe_name}')||'') }} catch(err) {{ `__error_flag__ ${{err.toString()}}` }}" ); - if let Ok(result) = context.eval(Source::from_bytes(code.as_str())) { - if !result.is_string() { - anyhow::bail!("main function should return object"); - } - let result = result - .to_string(&mut context) - .map_err(|e| anyhow::anyhow!("Failed to convert JS result to string: {}", e))?; - let result = result - .to_std_string() - .map_err(|_| anyhow::anyhow!("Failed to convert JS string to std string"))?; + let result = match context.eval(Source::from_bytes(code.as_str())) { + Ok(result) if result.is_string() => result, + _ => return bail("main function should return object".to_owned()), + }; + let result = match result.to_string(&mut context) { + Ok(result) => result, + Err(e) => return bail(format!("Failed to convert JS result to string: {e}")), + }; + let result = match result.to_std_string() { + Ok(result) => result, + Err(_) => return bail("Failed to convert JS string to std string".to_owned()), + }; - if result.len() > MAX_JSON_SIZE { - anyhow::bail!("Script result exceeds maximum allowed size"); - } + if result.len() > MAX_JSON_SIZE { + return bail("Script result exceeds maximum allowed size".to_owned()); + } - let res: Result = parse_json_safely(&result); - - match res { - Ok(config) => Ok((use_lowercase_owned(config), outputs.lock().to_vec())), - Err(err) => { - outputs - .lock() - .push(("exception".into(), "Script execution failed".into())); - logging!( - error, - Type::Config, - "Script execution error: {err:#}. Script name: {name}" - ); - Ok((config, outputs.lock().to_vec())) - } + match parse_json_safely(&result) { + Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()), + Err(err) => { + logging!( + error, + Type::Config, + "Script execution error: {err:#}. Script name: {name}" + ); + bail("Script execution failed".to_owned()) } - } else { - anyhow::bail!("main function should return object"); } } @@ -181,89 +222,3 @@ fn escape_js_string_for_single_quote(s: &str) -> String { .replace('\r', "\\r") // 添加回车转义 .into() } - -#[test] -#[allow(unused_variables)] -#[allow(clippy::expect_used)] -fn test_script() { - let script = r#" - function main(config) { - if (Array.isArray(config.rules)) { - config.rules = [...config.rules, "add"]; - } - console.log(config); - config.proxies = ["111"]; - return config; - } - "#; - - let config = r" - rules: - - 111 - - 222 - tun: - enable: false - dns: - enable: false - "; - - let config = &serde_yaml_ng::from_str(config).expect("Failed to parse test config YAML"); - let (config, results) = - use_script_sync(script.into(), config, &String::from("")).expect("Script execution should succeed in test"); - - let _ = serde_yaml_ng::to_string(&config).expect("Failed to serialize config to YAML"); - let yaml_config_size = std::mem::size_of_val(&config); - let box_yaml_config_size = std::mem::size_of_val(&Box::new(config)); - assert!(box_yaml_config_size < yaml_config_size); -} - -// 测试特殊字符转义功能 -#[test] -#[allow(clippy::expect_used)] -fn test_escape_unescape() { - let test_string = r#"Hello "World"!\nThis is a test with \u00A9 copyright symbol."#; - let escaped = escape_js_string_for_single_quote(test_string); - println!("Original: {test_string}"); - println!("Escaped: {escaped}"); - - let json_str = r#"{"key":"value","nested":{"key":"value"}}"#; - let parsed = parse_json_safely(json_str).expect("Failed to parse test JSON safely"); - - assert!(parsed.contains_key("key")); - assert!(parsed.contains_key("nested")); - - let quoted_json_str = r#""{"key":"value","nested":{"key":"value"}}""#; - let parsed_quoted = parse_json_safely(quoted_json_str).expect("Failed to parse quoted test JSON safely"); - - assert!(parsed_quoted.contains_key("key")); - assert!(parsed_quoted.contains_key("nested")); -} - -#[test] -fn test_strip_outer_quotes_edge_cases() { - assert_eq!(strip_outer_quotes(""), ""); - assert_eq!(strip_outer_quotes("'"), "'"); - assert_eq!(strip_outer_quotes("\""), "\""); - assert_eq!(strip_outer_quotes("''"), ""); - assert_eq!(strip_outer_quotes("\"\""), ""); - assert_eq!(strip_outer_quotes("'a'"), "a"); -} - -#[test] -fn test_memory_limits() { - // 测试输出限制 - let script = r#" - function main(config) { - for(let i = 0; i < 2000; i++) { - console.log("test"); - } - return config; - } - "#; - - #[allow(clippy::expect_used)] - let config = &serde_yaml_ng::from_str("test: value").expect("Failed to parse test YAML"); - let result = use_script_sync(script.into(), config, &String::from("")); - // 应该失败或被限制 - assert!(result.is_ok()); // 会被限制但不会 panic -} diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 81ab7efb7..7bcb17a80 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -25,7 +25,6 @@ pub async fn patch_clash(patch: &Mapping) -> Result<()> { if patch.get("mode").is_some() { tray::Tray::global().update_menu_and_icon().await; } - Config::runtime().await.edit_draft(|d| d.patch_config(patch)); CoreManager::global().update_config_checked().await?; } handle::Handle::refresh_clash(); diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index 9abb6699b..982ba29af 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -1,6 +1,6 @@ use crate::{ config::{ - Config, ConfigType, MixedPort, + Config, MixedPort, snapshot::{FileSnapshot, capture_config_files, restore_files}, }, core::{ @@ -71,8 +71,11 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result Result Err(error).context("failed to persist candidate Runtime Configuration"), @@ -232,7 +235,7 @@ async fn rollback_proxy_ports(snapshots: &[FileSnapshot], was_running: bool) -> discard_proxy_port_drafts().await; let file_result = restore_files(snapshots).await; if file_result.is_err() { - let _ = Config::generate_file(ConfigType::Run).await; + let _ = Config::generate_file().await; } let lifecycle_result = if was_running { diff --git a/src-tauri/src/utils/help.rs b/src-tauri/src/utils/help.rs index 999b2f257..09a95e775 100644 --- a/src-tauri/src/utils/help.rs +++ b/src-tauri/src/utils/help.rs @@ -1,4 +1,4 @@ -use crate::config::with_encryption; +use crate::{config::with_encryption, process::AsyncHandler}; use anyhow::{Context as _, Result, anyhow, bail}; use clash_verge_logging::{Type, logging}; use nanoid::nanoid; @@ -26,10 +26,20 @@ pub async fn read_mapping(path: &Path) -> Result { bail!("file not found \"{}\"", path.display()); } - let yaml_str = tokio::fs::read_to_string(path) + let bytes = tokio::fs::read(path) .await .with_context(|| format!("failed to read the file \"{}\"", path.display()))?; + let yaml_str = + String::from_utf8(bytes).with_context(|| format!("failed to read the file \"{}\"", path.display()))?; + let label = path.to_path_buf(); + // Parsing a multi-megabyte profile is pure CPU; keep it off the async worker. + AsyncHandler::spawn_blocking(move || parse_mapping(yaml_str, label)) + .await + .map_err(|join| anyhow!("yaml parse task failed: {join}"))? +} + +fn parse_mapping(yaml_str: String, path: PathBuf) -> Result { match serde_yaml_ng::from_str::(&yaml_str) { Ok(mut val) => { val.apply_merge() @@ -59,6 +69,11 @@ pub async fn save_yaml(path: &Path, data: &T, prefix: Optio None => data_str, }; + save_yaml_str(path, &yaml_str).await +} + +/// Atomic replace with pre-serialized content; the payload must not need the encryption scope. +pub async fn save_yaml_str(path: &Path, yaml_str: &str) -> Result<()> { let (temporary, file) = loop { let temporary = path.with_extension(format!("tmp-{}-{}", std::process::id(), nanoid!())); match std::fs::OpenOptions::new()