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 sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, HashSet}; use std::path::{Component, Path, PathBuf}; pub(crate) const GEO_ASSETS: &[&str] = &[ "Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat", "geoip.metadb", "GeoSite.dat", ]; /// Provider identity used by the runtime config and core API. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RemoteProviderRef { pub section: &'static str, pub name: String, pub provider: RemoteProvider, } struct RuntimeParts { config: Value, assets: Vec, remote_providers: Vec, } pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) -> Result { let yaml = tokio::fs::read_to_string(config_file) .await .with_context(|| format!("failed to read runtime config {config_file:?}"))?; let config: Value = serde_yaml_ng::from_str(&yaml).with_context(|| format!("failed to parse runtime config {config_file:?}"))?; let config_root = config_file .parent() .ok_or_else(|| anyhow::anyhow!("runtime config has no parent directory"))?; let parts = collect_runtime_parts(config, config_root)?; Ok(RuntimeBundle { yaml: serde_yaml_ng::to_string(&parts.config).context("failed to serialize service runtime config")?, assets: parts.assets, remote_providers: parts .remote_providers .into_iter() .map(|declared| declared.provider) .collect(), core_path: core_path.to_string_lossy().into_owned(), }) } pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Result> { 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(); let mut destinations = HashSet::new(); let mut remote_providers = Vec::new(); collect_provider_assets( &mut config, "proxy-providers", &config_root, &mut destinations, &mut assets, &mut remote_providers, )?; collect_provider_assets( &mut config, "rule-providers", &config_root, &mut destinations, &mut assets, &mut remote_providers, )?; for filename in GEO_ASSETS { let source = config_root.join(filename); if source.is_file() && destinations.insert((*filename).to_string()) { assets.push(RuntimeAsset { source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(), destination: (*filename).to_string(), }); } } Ok(RuntimeParts { config, assets, remote_providers, }) } /// Rewrites provider paths and records copied assets separately from core-downloaded providers. /// Remote URLs let the service decide whether a core download cache remains reusable. fn collect_provider_assets( config: &mut Value, section: &'static str, config_root: &Path, destinations: &mut HashSet, assets: &mut Vec, remote_providers: &mut Vec, ) -> Result<()> { let Some(providers) = config .as_mapping_mut() .and_then(|mapping| mapping.get_mut(section)) .and_then(Value::as_mapping_mut) else { return Ok(()); }; for (name, provider) in providers.iter_mut() { let Some(provider) = provider.as_mapping_mut() else { continue; }; let Some(raw_path) = provider.get("path").and_then(Value::as_str) else { continue; }; // Match mihomo's `type` classification and leave malformed-but-accepted providers untouched. let is_remote = provider.get("type").and_then(Value::as_str) == Some("http"); let url = provider.get("url").and_then(Value::as_str).map(str::to_owned); let destination = match (is_remote, url) { (true, Some(url)) => { let destination = provider_destination(config_root, raw_path)?; // Reserve remote destinations too; copied and downloaded files must not collide. match remote_providers .iter() .find(|declared| declared.provider.destination == destination) { Some(declared) if declared.provider.url == url => {} Some(_) => { bail!("runtime provider destination {destination:?} is declared for two different sources") } None => { if !destinations.insert(destination.clone()) { bail!("runtime provider destination {destination:?} is claimed more than once"); } remote_providers.push(RemoteProviderRef { section, name: name.as_str().unwrap_or_default().to_owned(), provider: RemoteProvider { destination: destination.clone(), url, }, }); } } destination } (true, None) => { logging!( warn, Type::Config, "remote provider {name:?} declares no url; leaving its path to the core" ); continue; } (false, _) => { let Ok(source) = local_provider_source(config_root, raw_path) else { logging!( warn, Type::Config, "local provider {name:?} is unavailable at {raw_path:?}; leaving its path to the core" ); continue; }; let destination = destination_below_root(config_root, &source)?; if destinations.insert(destination.clone()) { assets.push(RuntimeAsset { source: source.to_string_lossy().into_owned(), destination: destination.clone(), }); } destination } }; provider.insert(Value::String("path".to_owned()), Value::String(destination)); } Ok(()) } fn local_provider_source(config_root: &Path, raw_path: &str) -> Result { let path = Path::new(raw_path); let source = if path.is_absolute() { path.to_path_buf() } else { config_root.join(path) }; let canonical_source = std::fs::canonicalize(&source).with_context(|| format!("local runtime provider is unavailable: {source:?}"))?; canonical_source .strip_prefix(config_root) .map_err(|_| anyhow::anyhow!("local runtime provider is outside the config root: {canonical_source:?}"))?; Ok(canonical_source) } fn provider_destination(config_root: &Path, raw_path: &str) -> Result { let path = Path::new(raw_path); if path .components() .any(|component| matches!(component, Component::ParentDir)) { bail!("runtime provider destination traverses outside the runtime"); } if path.is_absolute() { let normalized = canonicalize_with_missing_tail(path)?; destination_below_root(config_root, &normalized) } else { normalized_destination(path) } } fn canonicalize_with_missing_tail(path: &Path) -> Result { let mut ancestor = path; let mut tail = Vec::new(); while !ancestor.exists() { let name = ancestor .file_name() .ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?; tail.push(name.to_owned()); ancestor = ancestor .parent() .ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?; } let mut normalized = std::fs::canonicalize(ancestor)?; for component in tail.iter().rev() { normalized.push(component); } Ok(normalized) } fn destination_below_root(config_root: &Path, path: &Path) -> Result { let relative = path .strip_prefix(config_root) .map_err(|_| anyhow::anyhow!("runtime provider path is outside the config root: {path:?}"))?; normalized_destination(relative) } fn normalized_destination(relative: &Path) -> Result { let mut destination = PathBuf::new(); for component in relative.components() { match component { Component::Normal(component) => destination.push(component), Component::CurDir => {} _ => bail!("local runtime provider destination traverses outside the runtime"), } } if destination.as_os_str().is_empty() { bail!("local runtime provider destination is empty"); } Ok(destination.to_string_lossy().replace('\\', "/")) } #[cfg(test)] mod tests { 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<()> { let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-{}", std::process::id())); std::fs::create_dir_all(root.join("providers"))?; std::fs::write(root.join("providers/local.yaml"), b"proxies: []\n")?; std::fs::write(root.join("Country.mmdb"), b"geo")?; let config = root.join("config.yaml"); let absolute_local = root.join("providers/local.yaml"); let absolute_remote = root.join("providers/remote.yaml"); std::fs::write( &config, format!( "proxy-providers:\n local:\n type: file\n path: {}\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\nrule-providers: {{}}\n", absolute_local.display(), absolute_remote.display() ), )?; let core = root.join("mihomo"); std::fs::write(&core, b"core")?; let bundle = collect_runtime_bundle(&config, &core).await?; let bundled_config: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?; assert_eq!( bundled_config["proxy-providers"]["local"]["path"].as_str(), Some("providers/local.yaml") ); assert_eq!( bundled_config["proxy-providers"]["remote"]["path"].as_str(), Some("providers/remote.yaml") ); assert!(!bundle.yaml.contains(&root.to_string_lossy().into_owned())); assert!( bundle .assets .iter() .any(|asset| asset.destination == "providers/local.yaml") ); assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb")); assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote"))); // Remote declarations preserve service-side cache reuse. assert_eq!( bundle .remote_providers .iter() .map(|provider| (provider.destination.as_str(), provider.url.as_str())) .collect::>(), [("providers/remote.yaml", "https://example.com/p.yaml")], "the declaration must pair the rewritten destination with the url, not the original path" ); assert!( !bundle .assets .iter() .any(|asset| asset.destination.contains("geoip.dat")) ); std::fs::remove_dir_all(root)?; Ok(()) } #[tokio::test] async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> { // Identical repeats are one service-side file and are safe to fold. let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root)?; let config = root.join("config.yaml"); std::fs::write( &config, "rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n", )?; let core = root.join("mihomo"); std::fs::write(&core, b"core")?; let bundle = collect_runtime_bundle(&config, &core).await?; assert_eq!(bundle.remote_providers.len(), 1, "one file, declared twice"); assert_eq!(bundle.remote_providers[0].destination, "rules/x.yaml"); std::fs::remove_dir_all(root)?; Ok(()) } #[tokio::test] async fn two_sources_for_one_remote_file_are_refused() -> anyhow::Result<()> { let root = std::env::temp_dir().join(format!("clash-verge-bundle-conflict-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root)?; let config = root.join("config.yaml"); std::fs::write( &config, "rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://two.example/x.yaml\n path: ./rules/x.yaml\n", )?; let core = root.join("mihomo"); std::fs::write(&core, b"core")?; let Err(error) = collect_runtime_bundle(&config, &core).await else { anyhow::bail!("two sources cannot own one file"); }; assert!(error.to_string().contains("two different sources"), "{error}"); std::fs::remove_dir_all(root)?; Ok(()) } #[tokio::test] async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> { // Odd providers accepted by mihomo must not block the rest of the bundle. let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root)?; let config = root.join("config.yaml"); std::fs::write( &config, "rule-providers:\n nourl:\n type: http\n path: ./rules/nourl.yaml\n missing:\n type: inline\n url: https://one.example/i.yaml\n path: ./rules/missing.yaml\n good:\n type: http\n url: https://one.example/g.yaml\n path: ./rules/g.yaml\n", )?; let core = root.join("mihomo"); std::fs::write(&core, b"core")?; let bundle = collect_runtime_bundle(&config, &core).await?; assert_eq!( bundle.remote_providers.len(), 1, "only the provider that could be classified is declared" ); assert_eq!(bundle.remote_providers[0].destination, "rules/g.yaml"); let reserialized: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?; assert_eq!( reserialized["rule-providers"]["nourl"]["path"].as_str(), Some("./rules/nourl.yaml"), "an unclassifiable provider keeps the path the core was given" ); std::fs::remove_dir_all(root)?; Ok(()) } #[tokio::test] async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> { let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id())); std::fs::create_dir_all(&root)?; let config = root.join("config.yaml"); let outside = root .parent() .ok_or_else(|| anyhow::anyhow!("test root has no parent"))? .join(format!("outside-{}.yaml", std::process::id())); std::fs::write( &config, format!( "proxy-providers:\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\n", outside.display() ), )?; let core = root.join("mihomo"); std::fs::write(&core, b"core")?; let Err(error) = collect_runtime_bundle(&config, &core).await else { anyhow::bail!("outside provider path must be rejected"); }; assert!(error.to_string().contains("outside the config root")); std::fs::remove_dir_all(root)?; Ok(()) } }