From 3b958acb22b2ad9fa360245f97d109f245908ea9 Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 21 Sep 2026 23:20:48 +0800 Subject: [PATCH] fix(config): isolate conflicting provider cache paths Fixes #7989 --- Changelog.md | 1 + src-tauri/src/config/config.rs | 2 + src-tauri/src/core/mod.rs | 2 +- src-tauri/src/core/runtime_bundle.rs | 177 ++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/Changelog.md b/Changelog.md index 0061878c1..3058f54f4 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题 - 优化文件错误日志,显示具体文件路径和失败原因 - 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失 - 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题 diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 4abc64ad2..aee641e88 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -6,6 +6,7 @@ use crate::{ CoreManager, handle::{self, Handle}, listener::MIXED_PORT_KEY, + runtime_bundle::resolve_provider_path_conflicts, tray, validate::CoreConfigValidator, }, @@ -228,6 +229,7 @@ impl Config { pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> { let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?; + resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?; sanitize_tunnels_proxy(&mut config); // Apply only to generated core config so the saved choice survives the next launch. if let Some(port) = MixedPort::session_fallback() { diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 2b3021bc1..fdda91372 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod owner_identity; pub mod proxy_control; pub mod proxy_view; pub mod runstate; -mod runtime_bundle; +pub(crate) mod runtime_bundle; pub mod service; pub mod sysopt; pub mod timer; diff --git a/src-tauri/src/core/runtime_bundle.rs b/src-tauri/src/core/runtime_bundle.rs index eed81c838..9987d669c 100644 --- a/src-tauri/src/core/runtime_bundle.rs +++ b/src-tauri/src/core/runtime_bundle.rs @@ -2,7 +2,8 @@ use anyhow::{Context as _, Result, bail}; use clash_verge_logging::{Type, logging}; use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle}; use serde_yaml_ng::{Mapping, Value}; -use std::collections::HashSet; +use sha2::{Digest as _, Sha256}; +use std::collections::{BTreeMap, HashSet}; use std::path::{Component, Path, PathBuf}; pub(crate) const GEO_ASSETS: &[&str] = &[ @@ -54,6 +55,94 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers) } +#[derive(Default)] +struct ProviderPathOwners { + remote: BTreeMap>, + local: bool, +} + +pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> { + let config_root = std::fs::canonicalize(config_root)?; + let mut paths = BTreeMap::::new(); + let mut reserved: HashSet = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect(); + for section in ["proxy-providers", "rule-providers"] { + let Some(providers) = config.get(section).and_then(Value::as_mapping) else { + continue; + }; + for (name, provider) in providers { + let Some(raw_path) = provider.get("path").and_then(Value::as_str) else { + continue; + }; + let is_remote = provider.get("type").and_then(Value::as_str) == Some("http"); + let destination = if is_remote { + provider_destination(&config_root, raw_path) + } else { + local_provider_source(&config_root, raw_path) + .and_then(|source| destination_below_root(&config_root, &source)) + .or_else(|_| provider_destination(&config_root, raw_path)) + }; + let Ok(destination) = destination else { + continue; + }; + reserved.insert(destination.clone()); + let owners = paths.entry(destination).or_default(); + match (is_remote, provider.get("url").and_then(Value::as_str)) { + (true, Some(url)) => owners + .remote + .entry(url.to_owned()) + .or_default() + .push((section, name.clone())), + _ => owners.local = true, + } + } + } + + for (destination, owners) in paths { + if owners.local || owners.remote.len() < 2 { + continue; + } + // Allocate every source in a conflict group, so declaration order cannot select a cache's owner. + for (url, providers) in owners.remote { + let replacement = allocate_provider_destination(&destination, &url, &mut reserved); + for (section, name) in providers { + config[section][&name]["path"] = Value::String(replacement.clone()); + logging!( + warn, + Type::Config, + "provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}" + ); + } + } + } + Ok(()) +} + +fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet) -> String { + let mut digest = Sha256::new(); + digest.update(destination.as_bytes()); + digest.update([0]); + digest.update(url.as_bytes()); + let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect(); + let path = Path::new(destination); + let extension = path + .extension() + .map(|ext| format!(".{}", ext.to_string_lossy())) + .unwrap_or_default(); + let mut suffix = String::new(); + let mut sequence = 0; + loop { + let candidate = path + .with_file_name(format!("cvr-{digest}{suffix}{extension}")) + .to_string_lossy() + .replace('\\', "/"); + if reserved.insert(candidate.clone()) { + return candidate; + } + sequence += 1; + suffix = format!("-{sequence}"); + } +} + fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result { let config_root = std::fs::canonicalize(config_root)?; let mut assets = Vec::new(); @@ -255,7 +344,91 @@ fn normalized_destination(relative: &Path) -> Result { #[cfg(test)] mod tests { - use super::collect_runtime_bundle; + use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts}; + + #[tokio::test] + async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id())); + std::fs::create_dir_all(&root)?; + let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str( + "rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n", + )?; + let original = config.clone(); + let root = std::fs::canonicalize(root)?; + let mut reordered = config.clone(); + reordered["rule-providers"] = serde_yaml_ng::from_str(&format!( + "mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n", + root.join("providers/rule/AppleDev.yaml").display(), + ))?; + + resolve_provider_path_conflicts(&mut config, &root)?; + let config_file = root.join("config.yaml"); + std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?; + let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?; + let paths = &config["rule-providers"]; + assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]); + resolve_provider_path_conflicts(&mut reordered, &root)?; + assert_eq!( + config, reordered, + "path allocation must not depend on declaration order or path spelling" + ); + let resolved = config.clone(); + resolve_provider_path_conflicts(&mut config, &root)?; + assert_eq!( + config, resolved, + "resolving an already generated config must be idempotent" + ); + + assert_eq!(serde_yaml_ng::from_str::(&bundle.yaml)?, config); + let readback = remote_providers_of(&config, &root)?; + assert_eq!(bundle.remote_providers.len(), 2); + for declared in readback { + assert!(bundle.remote_providers.contains(&declared.provider)); + assert_eq!( + config[declared.section][&declared.name]["path"], + declared.provider.destination + ); + assert_eq!( + config[declared.section][&declared.name]["url"], + original[declared.section][&declared.name]["url"] + ); + } + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> { + let root = std::env::temp_dir(); + let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str( + "proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n", + )?; + let mut config = original.clone(); + resolve_provider_path_conflicts(&mut config, &root)?; + let allocated = config["proxy-providers"]["a"]["path"].clone(); + let mut config = original; + config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?; + config["rule-providers"]["reserved"]["path"] = allocated.clone(); + resolve_provider_path_conflicts(&mut config, &root)?; + assert_ne!(config["proxy-providers"]["a"]["path"], allocated); + assert_ne!( + config["proxy-providers"]["a"]["path"], + config["rule-providers"]["b"]["path"] + ); + assert_eq!(config["rule-providers"]["reserved"]["path"], allocated); + Ok(()) + } + + #[test] + fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> { + let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str( + "rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n", + )?; + let original = config.clone(); + resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?; + assert_eq!(config, original); + Ok(()) + } #[tokio::test] async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {