Files
clash-verge-rev/src-tauri/src/enhance/mod.rs
T
Tunglies b7c4f7d39b refactor: api cleanup (#7812)
* refactor(config): unify YAML persistence

* refactor(config): remove redundant config APIs

* refactor(config): remove unused config APIs

* refactor(config): narrow profile APIs

* fix(config): preserve profiles on invalid reorder

* refactor(startup): narrow resolve helpers

* refactor(config): unify mixed port defaults

* refactor(network): simplify response APIs

* refactor(config): make profile builders infallible

* refactor(config): make profile activation infallible

* refactor(api): simplify internal runtime APIs

* refactor: consolidate internal config and window APIs
2026-08-25 07:19:17 +00:00

1639 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod chain;
pub mod field;
mod merge;
mod script;
pub mod seq;
mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
tun::use_tun,
};
use crate::utils::dirs;
use crate::{
config::{Config, IProfiles, IVerge, PrfItem},
constants,
utils::tmpl,
};
use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use tokio::fs;
type ResultLog = Vec<(String, String)>;
#[derive(Debug)]
struct ConfigValues {
clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool,
enable_builtin: bool,
socks_enabled: bool,
http_enabled: bool,
enable_dns_settings: bool,
enable_external_controller: bool,
#[cfg(not(target_os = "windows"))]
redir_enabled: bool,
#[cfg(target_os = "linux")]
tproxy_enabled: bool,
}
#[derive(Debug)]
struct ProfileItems {
config: Mapping,
merge_item: ChainItem,
script_item: ChainItem,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: String,
}
impl Default for ProfileItems {
fn default() -> Self {
Self {
config: Default::default(),
profile_name: Default::default(),
merge_item: ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},
script_item: ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
rules_item: ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},
proxies_item: ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},
groups_item: ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},
global_merge: ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},
global_script: ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
}
}
}
async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce() -> ChainItem) -> ChainItem {
if let Some(item) = item {
<Option<ChainItem>>::from_async(item).await.unwrap_or_else(default_item)
} else {
default_item()
}
}
async fn get_config_values() -> ConfigValues {
let clash = Config::clash().await;
let clash_arc = clash.latest_arc();
let clash_config = clash_arc.0.clone();
drop(clash_arc);
drop(clash);
let verge = Config::verge().await;
let verge_arc = verge.latest_arc();
let IVerge {
ref enable_tun_mode,
ref enable_builtin_enhanced,
ref verge_socks_enabled,
ref verge_http_enabled,
ref enable_dns_settings,
ref enable_external_controller,
..
} = **verge_arc;
let enable_external_controller = enable_external_controller.unwrap_or(false);
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false) && !Config::tun_suppressed_for_session(),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
enable_dns_settings.unwrap_or(false),
);
#[cfg(not(target_os = "windows"))]
let redir_enabled = verge_arc.verge_redir_enabled.unwrap_or(false);
#[cfg(target_os = "linux")]
let tproxy_enabled = verge_arc.verge_tproxy_enabled.unwrap_or(false);
drop(verge_arc);
drop(verge);
ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
}
}
#[allow(clippy::cognitive_complexity)]
async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let current_profile_uid = match profiles.current.clone() {
Some(uid) => uid,
None => return Ok(ProfileItems::default()),
};
let current = profiles
.current_mapping()
.await
.with_context(|| format!("failed to read current profile \"{current_profile_uid}\""))?;
let current_item = match profiles.get_item(&current_profile_uid) {
Ok(item) => item,
Err(err) => {
return Err(err).with_context(|| format!("failed to get current profile \"{current_profile_uid}\""));
}
};
let merge_uid = current_item.current_merge().cloned().unwrap_or_else(|| "Merge".into());
let script_uid = current_item
.current_script()
.cloned()
.unwrap_or_else(|| "Script".into());
let rules_uid = current_item.current_rules().cloned().unwrap_or_else(|| "Rules".into());
let proxies_uid = current_item
.current_proxies()
.cloned()
.unwrap_or_else(|| "Proxies".into());
let groups_uid = current_item
.current_groups()
.cloned()
.unwrap_or_else(|| "Groups".into());
let name = current_item.name.clone().unwrap_or_default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
);
Ok(ProfileItems {
config: current,
merge_item,
script_item,
rules_item,
proxies_item,
groups_item,
global_merge,
global_script,
profile_name: name,
})
}
async fn process_global_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
config = use_merge(&merge, config);
}
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())),
}
result_map.insert(global_script.uid, logs);
}
(config, exists_keys, result_map)
}
fn process_seq_items(
mut config: Mapping,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
) -> Mapping {
if let ChainType::Rules(rules) = rules_item.data {
config = use_seq(rules, config, "rules");
}
if let ChainType::Proxies(proxies) = proxies_item.data {
config = use_seq(proxies, config, "proxies");
}
if let ChainType::Groups(groups) = groups_item.data {
config = use_seq(groups, config, "proxy-groups");
}
config
}
fn extend_changed_keys(exists_keys: &mut Vec<String>, 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] = &[
"external-controller",
#[cfg(unix)]
"external-controller-unix",
#[cfg(windows)]
"external-controller-pipe",
"external-controller-cors",
"secret",
"mixed-port",
"socks-port",
"port",
#[cfg(not(target_os = "windows"))]
"redir-port",
#[cfg(target_os = "linux")]
"tproxy-port",
"tun",
"mode",
"allow-lan",
"log-level",
"ipv6",
"unified-delay",
];
/// The fields the app owns, held across the stages that apply the user's manual overrides.
///
/// Capturing and restoring are one value rather than two calls because the order is a
/// correctness requirement, not a style: capture must happen after the app has finished
/// deriving these fields and before any override runs, and restore must happen after every
/// override. As four separate calls that contract lived only in comments.
struct AuthoritativeFields {
control_plane: Mapping,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
dns_ipv6: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, enable_dns_settings: bool) -> Self {
Self {
control_plane: snapshot_control_plane(config),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
}
}
fn enforce(self, config: Mapping) -> Mapping {
let config = enforce_control_plane(config, self.control_plane);
enforce_dns_ipv6(config, self.dns_ipv6)
}
}
/// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。
fn snapshot_control_plane(config: &Mapping) -> Mapping {
let mut snapshot = Mapping::new();
for &key in CONTROL_PLANE_KEYS {
let key = Value::from(key);
if let Some(value) = config.get(&key) {
snapshot.insert(key, value.clone());
}
}
snapshot
}
/// 手动覆盖后恢复控制面快照;快照缺失的控制面键从最终配置删除。
fn enforce_control_plane(mut config: Mapping, snapshot: Mapping) -> Mapping {
for &key in CONTROL_PLANE_KEYS {
let key = Value::from(key);
if !snapshot.contains_key(&key) {
config.remove(&key);
}
}
config.extend(snapshot);
config
}
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
if let Some(dns_ipv6) = dns_ipv6
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
{
dns.insert(Value::from("ipv6"), dns_ipv6);
}
config
}
fn is_loopback_bind_address(addr: &str) -> bool {
let addr = addr.trim();
let addr = addr
.strip_prefix('[')
.and_then(|addr| addr.strip_suffix(']'))
.unwrap_or(addr);
addr.eq_ignore_ascii_case("localhost")
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|| is_ipv4_shorthand_loopback(addr)
}
fn is_ipv4_shorthand_loopback(addr: &str) -> bool {
let parts = addr.split('.').map(str::parse::<u32>).collect::<Result<Vec<_>, _>>();
let Ok(parts) = parts else {
return false;
};
match parts.as_slice() {
[first, rest] => *first == 127 && *rest <= 0x00ff_ffff,
[first, second, rest] => *first == 127 && *second <= 0xff && *rest <= 0xffff,
[first, second, third, fourth] => *first == 127 && *second <= 0xff && *third <= 0xff && *fourth <= 0xff,
_ => false,
}
}
fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
if allow_lan
&& config
.get("bind-address")
.and_then(Value::as_str)
.is_some_and(is_loopback_bind_address)
{
config.insert(Value::from("bind-address"), Value::from("*"));
}
config
}
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
config = use_merge(&merge, config);
}
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())),
}
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merge the Application Merge Config over the profile's own configuration.
///
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
/// out of the global config from inside its loop, which made a stage that looks pure depend
/// on process state and left it untestable.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
socks_enabled: bool,
http_enabled: bool,
enable_external_controller: bool,
#[cfg(not(target_os = "windows"))] redir_enabled: bool,
#[cfg(target_os = "linux")] tproxy_enabled: bool,
) -> Mapping {
for (key, value) in clash_config.into_iter() {
if key.as_str() == Some("tun") {
let mut tun = config.get_mut("tun").map_or_else(Mapping::new, |val| {
val.as_mapping().cloned().unwrap_or_else(Mapping::new)
});
let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new);
for (key, value) in patch_tun.into_iter() {
tun.insert(key, value);
}
config.insert("tun".into(), tun.into());
} else {
if key.as_str() == Some("socks-port") && !socks_enabled {
config.remove("socks-port");
continue;
}
if key.as_str() == Some("port") && !http_enabled {
config.remove("port");
continue;
}
#[cfg(target_os = "windows")]
{
if key.as_str() == Some("redir-port") {
continue;
}
}
#[cfg(not(target_os = "windows"))]
{
if key.as_str() == Some("redir-port") && !redir_enabled {
config.remove("redir-port");
continue;
}
}
#[cfg(target_os = "linux")]
{
if key.as_str() == Some("tproxy-port") && !tproxy_enabled {
config.remove("tproxy-port");
continue;
}
}
#[cfg(not(target_os = "linux"))]
{
if key.as_str() == Some("tproxy-port") {
config.remove("tproxy-port");
continue;
}
}
if key.as_str() == Some("external-controller") {
if enable_external_controller {
config.insert(key, value);
} else {
config.insert(key, "".into());
}
} else {
config.insert(key, value);
}
}
}
config
}
async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, 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}`");
}
}
}
}
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
let proxy_names = config
.get("proxies")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| match item {
Value::Mapping(map) => map
.get("name")
.and_then(Value::as_str)
.map(|name| name.to_owned().into()),
Value::String(name) => Some(name.to_owned().into()),
_ => None,
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let group_names = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
item.as_mapping()
.and_then(|map| map.get("name"))
.and_then(Value::as_str)
.map(std::convert::Into::into)
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let provider_names = config
.get("proxy-providers")
.and_then(Value::as_mapping)
.map(|map| {
map.keys()
.filter_map(Value::as_str)
.map(std::convert::Into::into)
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let mut allowed_names = proxy_names;
allowed_names.extend(group_names);
allowed_names.extend(provider_names.iter().cloned());
allowed_names.extend(BUILTIN_POLICIES.iter().map(|p| (*p).into()));
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
for group in groups {
if let Some(group_map) = group.as_mapping_mut() {
let mut has_valid_provider = false;
if let Some(Value::Sequence(uses)) = group_map.get_mut("use") {
uses.retain(|provider| match provider {
Value::String(name) => {
let exists = provider_names.contains(name.as_str());
has_valid_provider = has_valid_provider || exists;
exists
}
_ => false,
});
}
if let Some(Value::Sequence(proxies)) = group_map.get_mut("proxies") {
proxies.retain(|proxy| match proxy {
Value::String(name) => allowed_names.contains(name.as_str()) || has_valid_provider,
_ => true,
});
}
}
}
}
config
}
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
let ipv6_enabled = dns.get("ipv6").and_then(|v| v.as_bool()).unwrap_or(false);
let is_fake_ip = dns
.get("enhanced-mode")
.and_then(|v| v.as_str())
.map(|m| m == "fake-ip")
.unwrap_or(true);
// Hand-edited YAML may leave the key present but empty.
let range6_missing = dns
.get("fake-ip-range6")
.and_then(|v| v.as_str())
.map(|s| s.trim().is_empty())
.unwrap_or(true);
if ipv6_enabled && is_fake_ip && range6_missing {
dns.insert(Value::from("fake-ip-range6"), Value::from("fdfe:dcba:9876::1/64"));
}
}
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
{
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
logging!(info, Type::Core, "apply hosts configuration");
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
let mut dns_mapping = dns_mapping.clone();
ensure_fake_ip_range6(&mut dns_mapping);
config.insert("dns".into(), dns_mapping.into());
logging!(info, Type::Core, "apply dns_config.yaml (dns section)");
}
} else {
let mut dns_config = dns_config;
ensure_fake_ip_range6(&mut dns_config);
config.insert("dns".into(), dns_config.into());
logging!(info, Type::Core, "apply dns_config.yaml");
}
}
}
config
}
/// Returns the enhanced profile, its original keys, and script logs.
pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>)> {
let cfg_vals = get_config_values().await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
} = cfg_vals;
let profile = collect_profile_items(profiles).await?;
let config = profile.config;
let merge_item = profile.merge_item;
let script_item = profile.script_item;
let rules_item = profile.rules_item;
let proxies_item = profile.proxies_item;
let groups_item = profile.groups_item;
let global_merge = profile.global_merge;
let global_script = profile.global_script;
let profile_name = profile.profile_name;
let result_map = HashMap::new();
let config = process_seq_items(config, rules_item, proxies_item, groups_item);
let exists_keys = use_keys(&config).collect::<Vec<_>>();
let config = merge_default_config(
config,
clash_config,
socks_enabled,
http_enabled,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
);
let config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, enable_dns_settings);
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
result_map,
global_merge,
global_script,
&profile_name,
)
.await;
let (config, exists_keys, result_map) =
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
let config = authoritative.enforce(config);
let config = ensure_lan_bind_address(config);
let config = cleanup_proxy_groups(config);
let config = use_sort(config);
let mut exists_keys_set = HashSet::new();
exists_keys_set.extend(exists_keys);
Ok((config, exists_keys_set, result_map))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod fake_ip_tests {
use super::{Mapping, Value, ensure_fake_ip_range6};
fn dns(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
const RANGE6: &str = "fdfe:dcba:9876::1/64";
#[test]
fn an_ipv6_fake_ip_setup_missing_its_range_gets_one() {
let mut config = dns(&[("ipv6", Value::from(true)), ("enhanced-mode", Value::from("fake-ip"))]);
ensure_fake_ip_range6(&mut config);
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
}
#[test]
fn fake_ip_is_the_assumed_mode_when_unstated() {
let mut config = dns(&[("ipv6", Value::from(true))]);
ensure_fake_ip_range6(&mut config);
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
}
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// The reason this is not just a `contains_key` check: YAML edited by hand often
// leaves the key present with nothing after the colon.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("fake-ip")),
("fake-ip-range6", Value::from(blank)),
]);
ensure_fake_ip_range6(&mut config);
assert_eq!(
config.get(Value::from("fake-ip-range6")),
Some(&Value::from(RANGE6)),
"blank range {blank:?} should be filled in"
);
}
}
#[test]
fn an_existing_range_is_never_overwritten() {
let mut config = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("fake-ip")),
("fake-ip-range6", Value::from("fc00::1/64")),
]);
ensure_fake_ip_range6(&mut config);
assert_eq!(
config.get(Value::from("fake-ip-range6")),
Some(&Value::from("fc00::1/64"))
);
}
#[test]
fn nothing_is_added_without_ipv6_or_outside_fake_ip() {
let mut without_ipv6 = dns(&[("enhanced-mode", Value::from("fake-ip"))]);
ensure_fake_ip_range6(&mut without_ipv6);
assert!(!without_ipv6.contains_key(Value::from("fake-ip-range6")));
let mut redir_host = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("redir-host")),
]);
ensure_fake_ip_range6(&mut redir_host);
assert!(!redir_host.contains_key(Value::from("fake-ip-range6")));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod use_tun_tests {
use super::{Mapping, Value, use_tun};
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
fn tun_of(config: &Mapping) -> &Mapping {
config
.get(Value::from("tun"))
.and_then(Value::as_mapping)
.expect("tun should be a mapping")
}
fn dns_of(config: &Mapping) -> Option<&Mapping> {
config.get(Value::from("dns")).and_then(Value::as_mapping)
}
#[tokio::test]
async fn the_switch_is_written_either_way() {
assert_eq!(
tun_of(&use_tun(Mapping::new(), true)).get(Value::from("enable")),
Some(&Value::from(true))
);
assert_eq!(
tun_of(&use_tun(Mapping::new(), false)).get(Value::from("enable")),
Some(&Value::from(false))
);
}
#[tokio::test]
async fn existing_tun_keys_survive_being_switched_on() {
let config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
let result = use_tun(config, true);
assert_eq!(tun_of(&result).get(Value::from("stack")), Some(&Value::from("gvisor")));
}
#[tokio::test]
async fn enabling_tun_sets_up_fake_ip_dns() {
let result = use_tun(Mapping::new(), true);
let dns = dns_of(&result).expect("enabling tun should write dns settings");
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(true)));
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("fake-ip")));
assert_eq!(
dns.get(Value::from("fake-ip-range")),
Some(&Value::from("198.18.0.1/16"))
);
}
#[tokio::test]
async fn a_deliberate_redir_host_setup_is_left_alone() {
// Only fake-ip DNS is ours to configure; someone who chose redir-host meant it.
let config = mapping(&[(
"dns",
Value::Mapping(mapping(&[("enhanced-mode", Value::from("redir-host"))])),
)]);
let result = use_tun(config, true);
let dns = dns_of(&result).expect("dns should still be present");
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("redir-host")));
assert!(!dns.contains_key(Value::from("fake-ip-range")));
}
#[tokio::test]
async fn the_ipv6_fake_ip_range_follows_the_top_level_switch() {
let with_ipv6 = use_tun(mapping(&[("ipv6", Value::from(true))]), true);
let dns = dns_of(&with_ipv6).expect("dns should be written");
assert_eq!(dns.get(Value::from("ipv6")), Some(&Value::from(true)));
assert_eq!(
dns.get(Value::from("fake-ip-range6")),
Some(&Value::from("fdfe:dcba:9876::1/64"))
);
let without_ipv6 = use_tun(Mapping::new(), true);
let dns = dns_of(&without_ipv6).expect("dns should be written");
assert!(!dns.contains_key(Value::from("fake-ip-range6")));
}
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
// resolver is restored, which is not part of the configuration.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
let dns = dns_of(&result).expect("dns should be untouched");
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(false)));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod use_sort_tests {
use super::{Mapping, Value, use_sort};
#[test]
fn every_key_survives_sorting() {
let mut config = Mapping::new();
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
config.insert(Value::from(key), Value::from(key));
}
let expected = config.len();
let sorted = use_sort(config);
assert_eq!(sorted.len(), expected, "sorting must not drop or invent keys");
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
assert_eq!(sorted.get(Value::from(key)), Some(&Value::from(key)));
}
}
#[test]
fn the_bulky_list_fields_are_written_last() {
// These are the fields that make a config file unreadable from the top, so they are
// pushed below the settings a human actually scans for.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
let sorted = use_sort(config);
let order: Vec<_> = sorted.keys().filter_map(Value::as_str).collect();
let mode = order.iter().position(|key| *key == "mode");
let rules = order.iter().position(|key| *key == "rules");
assert!(mode < rules, "expected mode before rules, got {order:?}");
}
#[test]
fn sorting_is_stable_when_applied_twice() {
let mut config = Mapping::new();
for key in ["proxies", "mode", "unknown-key", "rules"] {
config.insert(Value::from(key), Value::from(1));
}
let once = use_sort(config);
let twice = use_sort(once.clone());
assert_eq!(once.keys().collect::<Vec<_>>(), twice.keys().collect::<Vec<_>>());
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod merge_default_config_tests {
use super::{Mapping, Value, merge_default_config};
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
/// Call with every listener enabled, so a test only says what it is turning off.
fn merge(config: Mapping, clash_config: Mapping, external_controller: bool) -> Mapping {
merge_default_config(
config,
clash_config,
true,
true,
external_controller,
#[cfg(not(target_os = "windows"))]
true,
#[cfg(target_os = "linux")]
true,
)
}
fn merge_without_optional_listeners(config: Mapping, clash_config: Mapping) -> Mapping {
merge_default_config(
config,
clash_config,
false,
false,
true,
#[cfg(not(target_os = "windows"))]
false,
#[cfg(target_os = "linux")]
false,
)
}
#[test]
fn the_merge_config_overwrites_the_profile() {
let config = mapping(&[("mode", Value::from("rule"))]);
let clash_config = mapping(&[("mode", Value::from("global"))]);
let merged = merge(config, clash_config, true);
assert_eq!(merged.get(Value::from("mode")), Some(&Value::from("global")));
}
#[test]
fn keys_the_merge_config_says_nothing_about_survive() {
let config = mapping(&[("profile-only", Value::from(1))]);
let merged = merge(config, Mapping::new(), true);
assert_eq!(merged.get(Value::from("profile-only")), Some(&Value::from(1)));
}
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not just skipped: the profile may have asked for the port itself, and a
// listener the user switched off must not be opened by the profile's own value.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
let merged = merge_without_optional_listeners(config, clash_config);
assert!(!merged.contains_key(Value::from("socks-port")));
assert!(!merged.contains_key(Value::from("port")));
}
#[test]
fn an_enabled_listener_takes_the_merge_config_value() {
let config = mapping(&[("socks-port", Value::from(7891))]);
let clash_config = mapping(&[("socks-port", Value::from(1080))]);
let merged = merge(config, clash_config, true);
assert_eq!(merged.get(Value::from("socks-port")), Some(&Value::from(1080)));
}
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
// listen anyway, which is the opposite of what switching it off means.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
assert_eq!(
merged.get(Value::from("external-controller")),
Some(&Value::from("")),
"the key must remain, holding an empty value"
);
}
#[test]
fn an_enabled_external_controller_keeps_its_address() {
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, true);
assert_eq!(
merged.get(Value::from("external-controller")),
Some(&Value::from("127.0.0.1:9090"))
);
}
#[test]
fn tun_settings_are_merged_key_by_key_rather_than_replaced() {
// The profile's other tun keys have to survive a merge config that sets only one.
let config = mapping(&[(
"tun",
Value::Mapping(mapping(&[
("device", Value::from("utun9")),
("stack", Value::from("system")),
])),
)]);
let clash_config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
let merged = merge(config, clash_config, true);
let tun = merged
.get(Value::from("tun"))
.and_then(Value::as_mapping)
.expect("tun should still be a mapping");
assert_eq!(tun.get(Value::from("device")), Some(&Value::from("utun9")));
assert_eq!(tun.get(Value::from("stack")), Some(&Value::from("gvisor")));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod authoritative_field_tests {
use super::{AuthoritativeFields, Mapping, Value};
fn config_with(pairs: &[(&str, Value)]) -> Mapping {
let mut config = Mapping::new();
for (key, value) in pairs {
config.insert(Value::from(*key), value.clone());
}
config
}
fn dns_with_ipv6(enabled: bool) -> Value {
let mut dns = Mapping::new();
dns.insert(Value::from("ipv6"), Value::from(enabled));
dns.insert(Value::from("enable"), Value::from(true));
Value::Mapping(dns)
}
#[test]
fn an_override_cannot_change_a_field_the_app_owns() {
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
let authoritative = AuthoritativeFields::capture(&derived, false);
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
let result = authoritative.enforce(overridden);
assert_eq!(result.get(Value::from("mode")), Some(&Value::from("rule")));
assert_eq!(result.get(Value::from("secret")), Some(&Value::from("ours")));
}
#[test]
fn an_override_cannot_introduce_a_field_the_app_left_out() {
// The app decided not to expose the external controller; a profile must not re-add it.
let derived = Mapping::new();
let authoritative = AuthoritativeFields::capture(&derived, false);
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
let result = authoritative.enforce(overridden);
assert!(!result.contains_key(Value::from("external-controller")));
}
#[test]
fn fields_the_app_does_not_own_survive_an_override() {
let derived = config_with(&[("mode", Value::from("rule"))]);
let authoritative = AuthoritativeFields::capture(&derived, false);
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
let result = authoritative.enforce(overridden);
assert_eq!(result.get(Value::from("profile-key")), Some(&Value::from(1)));
}
#[test]
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let owned = AuthoritativeFields::capture(&derived, true);
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
Some(&Value::from(true)),
"with the DNS page on, the app's value wins"
);
let unowned = AuthoritativeFields::capture(&derived, false);
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
Some(&Value::from(false)),
"with the DNS page off, an override may set it"
);
}
#[test]
fn restoring_dns_ipv6_never_invents_a_dns_block() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let authoritative = AuthoritativeFields::capture(&derived, true);
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
let result = authoritative.enforce(Mapping::new());
assert!(!result.contains_key(Value::from("dns")));
}
}
#[allow(clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::{
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
process_profile_items, use_keys,
};
use std::collections::HashMap;
fn mapping(yaml: &str) -> serde_yaml_ng::Mapping {
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
}
#[tokio::test]
async fn manual_overrides_follow_expected_priority() {
let mut config = mapping(
r"{global-merge-wins: other, global-script-wins: other, profile-merge-wins: other,
profile-script-wins: other, nested: {winner: other}, dns: {enable: true}, tun: {enable: true}}",
);
let exists_keys = use_keys(&config).collect();
config.insert("application-only".into(), true.into());
let global_merge = ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(mapping(
r"{global-merge-wins: global-merge, global-script-wins: global-merge,
profile-merge-wins: global-merge, profile-script-wins: global-merge,
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
)),
};
let global_script = ChainItem::to_script(
"Script",
r#"function main(config) {
config["global-script-wins"] = "global-script";
config["profile-merge-wins"] = "global-script";
config["profile-script-wins"] = "global-script";
config.nested.winner = "global-script";
return config;
}"#,
);
let profile_merge = ChainItem {
uid: "profile-merge".into(),
data: ChainType::Merge(mapping(
r"{profile-merge-wins: profile-merge, profile-script-wins: profile-merge,
nested: {winner: profile-merge}}",
)),
};
let profile_script = ChainItem::to_script(
"profile-script",
r#"function main(config) {
config["profile-script-wins"] = "profile-script";
config.nested.winner = "profile-script";
return config;
}"#,
);
let profile_name = "test-profile".into();
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
HashMap::new(),
global_merge,
global_script,
&profile_name,
)
.await;
let (config, exists_keys, _) = process_profile_items(
config,
exists_keys,
result_map,
profile_merge,
profile_script,
&profile_name,
)
.await;
let string_value = |key| config.get(key).and_then(serde_yaml_ng::Value::as_str);
assert_eq!(string_value("global-merge-wins"), Some("global-merge"));
assert_eq!(string_value("global-script-wins"), Some("global-script"));
assert_eq!(string_value("profile-merge-wins"), Some("profile-merge"));
assert_eq!(string_value("profile-script-wins"), Some("profile-script"));
assert_eq!(
config
.get("nested")
.and_then(|value| value.get("winner"))
.and_then(serde_yaml_ng::Value::as_str),
Some("profile-script")
);
assert!(!exists_keys.contains(&"application-only".into()));
}
#[test]
fn control_plane_survives_manual_overrides() {
let app_config = mapping(
r#"{external-controller: "",
external-controller-cors: {allow-origins: ["app-only"]},
mixed-port: 7890, socks-port: 7891, secret: "app-secret", mode: rule, allow-lan: false,
log-level: info, ipv6: false, unified-delay: true,
dns: {proxy-server-nameserver: ["1.1.1.1"]}}"#,
);
let snapshot = super::snapshot_control_plane(&app_config);
let hijacked = mapping(
r#"{external-controller: "0.0.0.0:9090",
external-controller-cors: {allow-origins: ["*"]},
mixed-port: 1080, socks-port: 1080, secret: "hijacked", mode: global, allow-lan: true,
log-level: debug, ipv6: true, unified-delay: false,
dns: {proxy-server-nameserver: ["8.8.8.8"]}}"#,
);
let result = super::enforce_control_plane(hijacked, snapshot);
let as_str = |key| result.get(key).and_then(serde_yaml_ng::Value::as_str);
assert_eq!(as_str("external-controller"), Some(""));
assert_eq!(
result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64),
Some(7890)
);
assert_eq!(
result.get("socks-port").and_then(serde_yaml_ng::Value::as_u64),
Some(7891)
);
assert_eq!(
result
.get("external-controller-cors")
.and_then(|value| value.get("allow-origins"))
.and_then(serde_yaml_ng::Value::as_sequence)
.and_then(|seq| seq.first())
.and_then(serde_yaml_ng::Value::as_str),
Some("app-only")
);
assert_eq!(as_str("secret"), Some("app-secret"));
assert_eq!(as_str("mode"), Some("rule"));
assert_eq!(
result.get("allow-lan").and_then(serde_yaml_ng::Value::as_bool),
Some(false)
);
assert_eq!(as_str("log-level"), Some("info"));
assert_eq!(result.get("ipv6").and_then(serde_yaml_ng::Value::as_bool), Some(false));
assert_eq!(
result.get("unified-delay").and_then(serde_yaml_ng::Value::as_bool),
Some(true)
);
// DNS 数据面不属于顶层控制面。
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("proxy-server-nameserver"))
.and_then(serde_yaml_ng::Value::as_sequence)
.and_then(|seq| seq.first())
.and_then(serde_yaml_ng::Value::as_str),
Some("8.8.8.8")
);
}
#[test]
fn lan_bind_address_loopback_is_widened() {
for bind_address in [
"localhost",
"127.0.0.1",
"127.0.0.2",
"127.1",
"::1",
"[::1]",
"0:0:0:0:0:0:0:1",
] {
let result = ensure_lan_bind_address(mapping(&format!(
r#"{{allow-lan: true, bind-address: "{bind_address}"}}"#
)));
assert_eq!(
result.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("*"),
"bind-address {bind_address} should be widened"
);
}
}
#[test]
fn lan_bind_address_preserves_custom_or_disabled() {
let custom = ensure_lan_bind_address(mapping(r#"{allow-lan: true, bind-address: "192.168.1.2"}"#));
assert_eq!(
custom.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("192.168.1.2")
);
let disabled = ensure_lan_bind_address(mapping(r#"{allow-lan: false, bind-address: "127.0.0.1"}"#));
assert_eq!(
disabled.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("127.0.0.1")
);
}
#[test]
fn control_plane_removes_reenabled_disabled_port() {
let app_config = mapping(r"{mixed-port: 7890, mode: rule}");
let snapshot = super::snapshot_control_plane(&app_config);
let hijacked = mapping(r"{mixed-port: 7890, mode: rule, socks-port: 1080}");
let result = super::enforce_control_plane(hijacked, snapshot);
assert!(!result.contains_key("socks-port"));
assert_eq!(
result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64),
Some(7890)
);
}
#[test]
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("ipv6"))
.and_then(serde_yaml_ng::Value::as_bool),
Some(false)
);
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("proxy-server-nameserver"))
.and_then(serde_yaml_ng::Value::as_sequence)
.and_then(|seq| seq.first())
.and_then(serde_yaml_ng::Value::as_str),
Some("8.8.8.8")
);
}
#[test]
fn snapshot_control_plane_skips_absent_keys() {
let app_config = mapping(r"{mode: rule, mixed-port: 7890}");
let snapshot = super::snapshot_control_plane(&app_config);
assert!(snapshot.contains_key("mode"));
assert!(snapshot.contains_key("mixed-port"));
assert!(!snapshot.contains_key("secret"));
assert!(!snapshot.contains_key("allow-lan"));
}
#[test]
fn remove_missing_proxies_from_groups() {
let config_str = r#"
proxies:
- name: "alive-node"
type: ss
proxy-groups:
- name: "manual"
type: select
proxies:
- "alive-node"
- "missing-node"
- "DIRECT"
- name: "nested"
type: select
proxies:
- "manual"
- "ghost"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let manual_proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("manual proxies should be a sequence");
assert_eq!(manual_proxies.len(), 2);
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("alive-node")));
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
let nested_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("nested"))
.and_then(|group| group.as_mapping())
.expect("nested group should exist");
let nested_proxies = nested_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("nested proxies should be a sequence");
assert_eq!(nested_proxies.len(), 1);
assert_eq!(nested_proxies[0].as_str(), Some("manual"));
}
#[test]
fn keep_provider_backed_groups_intact() {
let config_str = r#"
proxy-providers:
providerA:
type: http
url: https://example.com
path: ./providerA.yaml
proxies: []
proxy-groups:
- name: "manual"
type: select
use:
- "providerA"
- "ghostProvider"
proxies:
- "dynamic-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].as_str(), Some("providerA"));
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 2);
assert!(proxies.iter().any(|p| p.as_str() == Some("dynamic-node")));
assert!(proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
}
#[test]
fn prune_invalid_provider_and_proxies_without_provider() {
let config_str = r#"
proxy-groups:
- name: "manual"
type: select
use:
- "ghost-provider"
proxies:
- "ghost-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 0);
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 1);
assert_eq!(proxies[0].as_str(), Some("DIRECT"));
}
}