fix(config): isolate conflicting provider cache paths

Fixes #7989
This commit is contained in:
wonfen
2026-09-21 23:20:48 +08:00
parent d374ab4350
commit 3b958acb22
4 changed files with 179 additions and 3 deletions
+1
View File
@@ -3,6 +3,7 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
+2
View File
@@ -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() {
+1 -1
View File
@@ -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;
+175 -2
View File
@@ -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<String, Vec<(&'static str, Value)>>,
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::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = 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>) -> 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<RuntimeParts> {
let config_root = std::fs::canonicalize(config_root)?;
let mut assets = Vec::new();
@@ -255,7 +344,91 @@ fn normalized_destination(relative: &Path) -> Result<String> {
#[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::<serde_yaml_ng::Mapping>(&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<()> {