Files
clash-verge-rev/src-tauri/src/core/validate.rs
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00

471 lines
17 KiB
Rust

use anyhow::Result;
use scopeguard::defer;
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;
use crate::core::handle;
use crate::singleton;
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<parking_lot::Mutex<Option<u64>>> = 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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ValidationErrorKind {
FileMissing,
FileRead,
YamlSyntax,
YamlMapping,
ScriptSyntax,
ScriptMissingMain,
CoreRejected,
ProcessTerminated,
Timeout,
}
impl ValidationErrorKind {
pub fn from_message(message: &str) -> Self {
let lower = message.to_ascii_lowercase();
if lower.contains("file not found") {
Self::FileMissing
} else if lower.contains("failed to read") || lower.contains("无法读取") {
Self::FileRead
} else if lower.contains("script must contain a main function") {
Self::ScriptMissingMain
} else if lower.contains("script syntax error") {
Self::ScriptSyntax
} else if lower.contains("mapping values are not allowed")
|| lower.contains("failed to transform to yaml mapping")
|| lower.contains("failed to apply merge")
{
Self::YamlMapping
} else if lower.contains("yaml syntax error") || lower.contains("did not find expected key") {
Self::YamlSyntax
} else if lower.contains("timeout") || lower.contains("超时") {
Self::Timeout
} else if lower.contains("terminated") || lower.contains("被终止") {
Self::ProcessTerminated
} else {
Self::CoreRejected
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ValidationSkipReason {
Exiting,
Debounced,
}
impl fmt::Display for ValidationSkipReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Exiting => write!(f, "application is exiting"),
Self::Debounced => write!(f, "debounced"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ValidationOutcome {
Valid,
Invalid {
kind: ValidationErrorKind,
#[specta(type = std::string::String)]
message: String,
},
Skipped {
reason: ValidationSkipReason,
},
Busy,
}
impl ValidationOutcome {
pub fn invalid(kind: ValidationErrorKind, message: impl Into<String>) -> Self {
Self::Invalid {
kind,
message: message.into(),
}
}
pub fn invalid_from_message(message: impl Into<String>) -> Self {
let message = message.into();
Self::invalid(ValidationErrorKind::from_message(&message), message)
}
pub const fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}
}
impl fmt::Display for ValidationOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Valid => write!(f, "configuration is valid"),
Self::Invalid { message, .. } => write!(f, "{message}"),
Self::Skipped { reason } => write!(f, "Configuration validation skipped: {reason}"),
Self::Busy => write!(f, "Configuration validation is already running"),
}
}
}
impl CoreConfigValidator {
pub const fn new() -> Self {
Self {
is_processing: AtomicBool::new(false),
}
}
pub fn try_start(&self) -> bool {
!self.is_processing.swap(true, Ordering::AcqRel)
}
pub fn finish(&self) {
self.is_processing.store(false, Ordering::Release)
}
}
impl CoreConfigValidator {
async fn is_script_file(path: &str) -> Result<bool> {
if has_ext(path, "yaml") || has_ext(path, "yml") {
return Ok(false);
} else if has_ext(path, "js") {
return Ok(true);
}
let content = match fs::read_to_string(path).await {
Ok(content) => content,
Err(err) => {
logging!(warn, Type::Validate, "无法读取文件以检测类型: {}, 错误: {}", path, err);
return Err(anyhow::anyhow!("Failed to read file to detect type: {}", err));
}
};
let has_yaml_features = content.contains(": ")
|| content.contains("#")
|| content.contains("---")
|| content.lines().any(|line| line.trim().starts_with("- "));
let has_js_features = content.contains("function ")
|| content.contains("const ")
|| content.contains("let ")
|| content.contains("var ")
|| content.contains("//")
|| content.contains("/*")
|| content.contains("*/")
|| content.contains("export ")
|| content.contains("import ");
if has_yaml_features && !has_js_features {
return Ok(false);
} else if has_js_features && !has_yaml_features {
return Ok(true);
} else if has_yaml_features && has_js_features {
if content.contains("function main")
|| content.contains("module.exports")
|| content.contains("export default")
{
return Ok(true);
}
let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count();
if yaml_pattern_count > 2 {
return Ok(false);
}
}
// Unknown files take the safer YAML validation path.
logging!(debug, Type::Validate, "无法确定文件类型,默认当作YAML处理: {}", path);
Ok(false)
}
async fn validate_file_syntax_outcome(config_path: &str) -> Result<ValidationOutcome> {
logging!(info, Type::Validate, "开始检查文件: {}", config_path);
let content = match fs::read_to_string(config_path).await {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read file: {err}").into();
logging!(error, Type::Validate, "Failed to read file {config_path}: {err}");
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
logging!(info, Type::Validate, "进行YAML语法检查");
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content) {
Ok(_) => {
logging!(info, Type::Validate, "YAML语法检查通过");
Ok(ValidationOutcome::Valid)
}
Err(err) => {
let error_msg: String = format!("YAML syntax error: {err}").into();
logging!(error, Type::Validate, "YAML syntax error in {config_path}: {err}");
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
}
async fn validate_script_file_outcome(path: &str) -> Result<ValidationOutcome> {
let content = match fs::read_to_string(path).await {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read script file: {err}").into();
logging!(warn, Type::Validate, "Failed to read script file {path}: {err}");
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
logging!(debug, Type::Validate, "验证脚本文件: {}", path);
let has_main =
content.contains("function main") || content.contains("const main") || content.contains("let main");
// 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){}});",
));
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(()) => {
logging!(debug, Type::Validate, "脚本语法验证通过: {}", path);
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));
}
Ok(ValidationOutcome::Valid)
}
Err(err) => {
let error_msg: String = format!("Script syntax error: {err}").into();
logging!(warn, Type::Validate, "Script syntax error in {path}: {err}");
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
}
pub async fn validate_config_file_outcome(
config_path: &str,
is_merge_file: Option<bool>,
) -> Result<ValidationOutcome> {
if handle::Handle::global().is_exiting() {
logging!(info, Type::Core, "应用正在退出,跳过验证");
return Ok(ValidationOutcome::Skipped {
reason: ValidationSkipReason::Exiting,
});
}
if !std::path::Path::new(config_path).exists() {
let error_msg: String = format!("File not found: {config_path}").into();
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
if is_merge_file.unwrap_or(false) {
logging!(info, Type::Validate, "检测到Merge文件,仅进行语法检查: {}", config_path);
return Self::validate_file_syntax_outcome(config_path).await;
}
let is_script = match Self::is_script_file(config_path).await {
Ok(result) => result,
Err(err) => {
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
return Self::validate_config_internal_outcome(config_path).await;
}
};
if is_script {
logging!(
info,
Type::Validate,
"检测到脚本文件,使用JavaScript验证: {}",
config_path
);
return Self::validate_script_file_outcome(config_path).await;
}
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
Self::validate_config_internal_outcome(config_path).await
}
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
if handle::Handle::global().is_exiting() {
logging!(info, Type::Validate, "应用正在退出,跳过验证");
return Ok(ValidationOutcome::Skipped {
reason: ValidationSkipReason::Exiting,
});
}
logging!(info, Type::Validate, "开始验证配置文件: {}", config_path);
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
logging!(info, Type::Validate, "使用内核: {}", clash_core);
let app_handle = handle::Handle::app_handle();
let app_dir = dirs::app_home_dir()?;
let app_dir_str = dirs::path_to_str(&app_dir)?;
logging!(info, Type::Validate, "验证目录: {}", app_dir_str);
let command = app_handle.shell().sidecar(clash_core.as_str()).map_err(|error| {
anyhow::anyhow!("failed to build validation command for core {clash_core:?}: {error:#}")
})?;
let command = command.args(["-t", "-d", app_dir_str, "-f", config_path]);
let output = command.output().await.map_err(|error| {
anyhow::anyhow!(
"failed to run validation core {clash_core:?} for config {config_path:?} in data directory {}: {error:#}",
app_dir.display()
)
})?;
let status = &output.status;
let stderr = &output.stderr;
let stdout = &output.stdout;
let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"];
let has_error = !status.success() || contains_any_keyword(stderr, &error_keywords);
logging!(info, Type::Validate, "-------- 验证结果 --------");
if !stderr.is_empty() {
logging!(info, Type::Validate, "stderr输出:\n{:?}", stderr);
}
if has_error {
logging!(info, Type::Validate, "发现错误,开始处理错误信息");
let error_msg: String = if !stdout.is_empty() {
str::from_utf8(stdout).unwrap_or_default().into()
} else if !stderr.is_empty() {
str::from_utf8(stderr).unwrap_or_default().into()
} else if let Some(code) = status.code() {
format!("验证进程异常退出,退出码: {code}").into()
} else {
"验证进程被终止,请检查是否有安全软件阻止了内核运行,或电脑 CPU/运行环境是否支持该内核程序。".into()
};
logging!(info, Type::Validate, "-------- 验证结束 --------");
let outcome = if status.code().is_none() {
ValidationOutcome::invalid(ValidationErrorKind::ProcessTerminated, error_msg)
} else {
ValidationOutcome::invalid_from_message(error_msg)
};
Ok(outcome)
} else {
logging!(info, Type::Validate, "验证成功");
logging!(info, Type::Validate, "-------- 验证结束 --------");
Ok(ValidationOutcome::Valid)
}
}
/// 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<ValidationOutcome> {
if !self.try_start() {
logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求");
return Ok(ValidationOutcome::Busy);
}
defer! {
self.finish();
}
logging!(info, Type::Validate, "生成临时配置文件用于验证");
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)
}
}
fn has_ext<P: AsRef<std::path::Path>>(path: P, ext: &str) -> bool {
path.as_ref()
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(ext))
.unwrap_or(false)
}
fn contains_any_keyword<'a>(buf: &'a [u8], keywords: &'a [&str]) -> bool {
for &kw in keywords {
let needle = kw.as_bytes();
if needle.is_empty() {
continue;
}
let mut i = 0;
while i + needle.len() <= buf.len() {
if &buf[i..i + needle.len()] == needle {
return true;
}
i += 1;
}
}
false
}
singleton!(CoreConfigValidator, CORECONFIGVALIDATOR);