Files
clash-verge-rev/src-tauri/src/enhance/mod.rs
T
wonfen 758300ac34 fix(dns): prioritize configured fields over extensions
Restore non-empty DNS page fields and hosts after Merge/Script processing while preserving extension values for empty fields. Keep provider protection tied to the original profile and retain per-profile confirmations.
2026-09-22 20:11:35 +08:00

1916 lines
65 KiB
Rust

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_owned, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
tun::use_tun,
};
use crate::config::dns::{DnsOverrideState, dns_override_source};
use crate::core::handle::Handle;
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 parking_lot::Mutex;
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,
dns_override_confirmation: Option<String>,
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(profile_uid: &str) -> 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_external_controller,
..
} = **verge_arc;
let enable_external_controller = enable_external_controller.unwrap_or(false);
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
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),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
dns_settings.enabled,
);
#[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,
dns_override_confirmation,
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,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(global_merge.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = global_script.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
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
}
/// App-owned control-plane keys; DNS settings are captured separately.
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",
"mode",
"allow-lan",
"log-level",
"ipv6",
"unified-delay",
];
/// App-owned fields: captured after the app derives them, enforced after every override.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
dns: Mapping,
hosts: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], mut dns_settings: Mapping) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns: take_mapping(&mut dns_settings, "dns"),
hosts: dns_settings.remove("hosts"),
}
}
fn enforce(self, config: Mapping) -> Mapping {
let config = enforce_control_plane(config, self.control_plane);
let mut config = enforce_tun(config, self.tun);
if !self.dns.is_empty() {
let mut dns = take_mapping(&mut config, "dns");
dns.extend(self.dns);
config.insert("dns".into(), dns.into());
}
if let Some(hosts) = self.hosts {
config.insert("hosts".into(), hosts);
}
config
}
/// The owned values as `config` holds them now, for diffing one override.
fn current(&self, config: &Mapping) -> Self {
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
let dns = config.get("dns").and_then(Value::as_mapping);
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, &tun_keys),
dns: self
.dns
.keys()
.filter_map(|key| Some((key.clone(), dns?.get(key)?.clone())))
.collect(),
hosts: self.hosts.as_ref().and_then(|_| config.get("hosts")).cloned(),
}
}
/// Owned keys an override moved off the app value; `enforce` discards those writes.
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
let mut keys = Vec::new();
for &key in CONTROL_PLANE_KEYS {
if before.control_plane.get(key) != after.control_plane.get(key)
&& after.control_plane.get(key) != self.control_plane.get(key)
{
keys.push(key.into());
}
}
for key in self.tun.keys() {
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
}
}
for key in self.dns.keys() {
if before.dns.get(key) != after.dns.get(key) && after.dns.get(key) != self.dns.get(key) {
keys.push(format!("dns.{}", key.as_str().unwrap_or_default()).into());
}
}
if self.hosts.is_some() && before.hosts != after.hosts && after.hosts != self.hosts {
keys.push("hosts".into());
}
keys
}
}
fn discarded_note(key: String) -> (String, String) {
(
"warn".into(),
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
)
}
struct DiscardedKeysNotice {
pending: Option<String>,
last: Vec<String>,
}
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
pending: None,
last: Vec::new(),
});
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
DISCARDED_KEYS_NOTICE.lock().pending.take()
}
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
fn notify_discarded_keys(keys: Vec<String>) {
let mut notice = DISCARDED_KEYS_NOTICE.lock();
if notice.last == keys {
return;
}
let should_notify = !keys.is_empty();
if should_notify {
notice.pending = Some(keys.join(", ").into());
}
notice.last = keys;
drop(notice);
if should_notify {
Handle::notice_message("enhance::discarded_keys", "");
}
}
/// 手动 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
}
/// Only saved GUI fields and the verge switch override profile settings.
fn gui_tun_keys(clash_config: &Mapping) -> Vec<Value> {
let mut keys = vec![Value::from("enable")];
if let Some(Value::Mapping(tun)) = clash_config.get("tun") {
keys.extend(
constants::tun::GUI_KEYS
.iter()
.filter(|key| tun.contains_key(**key))
.map(|key| Value::from(*key)),
);
}
keys
}
fn snapshot_tun(config: &Mapping, gui_tun_keys: &[Value]) -> Mapping {
let mut snapshot = Mapping::new();
if let Some(Value::Mapping(tun)) = config.get("tun") {
for key in gui_tun_keys {
if let Some(value) = tun.get(key) {
snapshot.insert(key.clone(), value.clone());
}
}
}
snapshot
}
fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
if snapshot.is_empty() {
return config;
}
let mut tun = config
.get("tun")
.and_then(Value::as_mapping)
.cloned()
.unwrap_or_default();
tun.extend(snapshot);
config.insert(Value::from("tun"), Value::Mapping(tun));
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,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
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
}
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();
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
}
config
}
fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") {
for proxy in proxies.iter_mut() {
let Some(proxy) = proxy.as_mapping_mut() else { continue };
let is_hysteria = proxy.get("type").and_then(Value::as_str) == Some("hysteria");
if is_hysteria && let Some(Value::String(alpn)) = proxy.get("alpn").cloned() {
proxy.insert("alpn".into(), Value::Sequence(vec![Value::String(alpn)]));
}
}
}
config
}
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
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
}
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
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("2001:2::0/64"));
}
}
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
fn is_set(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(on) => *on,
Value::String(text) => !text.trim().is_empty(),
Value::Sequence(items) => !items.is_empty(),
Value::Mapping(map) => !map.is_empty(),
Value::Number(_) | Value::Tagged(_) => true,
}
}
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
config
.get_mut(key)
.and_then(Value::as_mapping_mut)
.map(std::mem::take)
.unwrap_or_default()
}
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
let mut applied = Mapping::new();
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
&& !hosts.is_empty()
{
applied.insert("hosts".into(), hosts.clone().into());
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
// Legacy layout: no `dns` root.
let dns_override = match dns_config.remove("dns") {
Some(Value::Mapping(dns)) => Some(dns),
Some(_) => None,
None => Some(dns_config),
};
if let Some(mut dns_override) = dns_override {
dns_override.retain(|_, value| is_set(value));
if !dns_override.is_empty() {
applied.insert("dns".into(), dns_override.clone().into());
}
let mut dns = take_mapping(&mut config, "dns");
dns.extend(dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
(config, applied)
}
/// Returns the merged config and the non-empty fields owned by the DNS page.
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, 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::<Mapping>(&dns_yaml)
{
return merge_dns_config(config, dns_config);
}
}
(config, Mapping::new())
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
pub async fn enhance(
profiles: &IProfiles,
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
let profile_uid = profiles.current.as_deref().unwrap_or_default();
let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
dns_override_confirmation,
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 dns_override = DnsOverrideState::new(
profile_uid,
dns_override_source(profile_uid, &profile.config)?,
enable_dns_settings,
dns_override_confirmation,
);
let enable_dns_settings = dns_override.enabled;
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 gui_tun_keys = gui_tun_keys(&clash_config);
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);
let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
result_map,
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
merge_item,
script_item,
&profile_name,
&authoritative,
)
.await;
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
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, dns_override))
}
#[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 = "2001:2::0/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() {
// Hand-edited YAML may leave the key present but empty.
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("2001:2::0/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() {
// TUN off must not rewrite the DNS the profile chose.
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() {
// Bulky list fields go last so the top of the file stays readable.
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 skipped: the profile's own value must not reopen a disabled listener.
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 makes mihomo listen on its default.
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, &[], Mapping::new());
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, &[], Mapping::new());
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, &[], Mapping::new());
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, &[], derived.clone());
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, &[], Mapping::new());
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_settings_reinstates_a_removed_dns_block() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], derived.clone());
let result = authoritative.enforce(Mapping::new());
assert_eq!(result["dns"], derived["dns"]);
}
}
#[allow(clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, 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 dns_settings_override_only_filled_fields_after_merge_and_script() {
let profile = mapping(
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
hosts: {profile.example: 192.0.2.1}",
);
let settings = mapping(
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8},\n\
proxy-server-nameserver: [], fallback: null, listen: ' '}\n\
hosts: {settings.example: 192.0.2.2}",
);
let merge = mapping(
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
hosts: {merge.example: 192.0.2.3}",
);
let script = r#"function main(config) {
config.dns = {
ipv6: false,
nameserver: ["4.4.4.4"],
"nameserver-policy": {"script.example": "4.4.4.4"},
"proxy-server-nameserver": ["4.4.4.4"],
fallback: ["4.4.4.4"],
listen: "127.0.0.1:1053"
};
config.hosts = {"script.example": "192.0.2.4"};
return config;
}"#;
for enabled in [false, true] {
let (config, dns_settings) = if enabled {
super::merge_dns_config(profile.clone(), settings.clone())
} else {
(profile.clone(), Mapping::new())
};
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let merge_item = |uid: &str| ChainItem {
uid: uid.into(),
data: ChainType::Merge(merge.clone()),
};
let profile_name = "test-profile".into();
let (config, keys, logs) = process_global_items(
config,
vec![],
HashMap::new(),
merge_item("global-merge"),
ChainItem::to_script("global-script", script),
&profile_name,
&authoritative,
)
.await;
let (config, _, logs) = process_profile_items(
config,
keys,
logs,
merge_item("profile-merge"),
ChainItem::to_script("profile-script", script),
&profile_name,
&authoritative,
)
.await;
let manual = config.clone();
let result = authoritative.enforce(config);
let source = if enabled { &settings } else { &manual };
assert_eq!(result["dns"]["nameserver-policy"], source["dns"]["nameserver-policy"]);
assert_eq!(result["hosts"], source["hosts"]);
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
for key in ["proxy-server-nameserver", "fallback", "listen"] {
assert_eq!(result["dns"][key], manual["dns"][key]);
}
if enabled {
for uid in ["global-merge", "global-script", "profile-merge", "profile-script"] {
assert!(
logs[uid]
.iter()
.any(|(_, message)| message.contains("dns.nameserver-policy"))
);
}
}
}
}
#[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 authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
HashMap::new(),
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, _) = process_profile_items(
config,
exists_keys,
result_map,
profile_merge,
profile_script,
&profile_name,
&authoritative,
)
.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 empty_dns_settings_leave_inherited_fields_overridable() {
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
let (config, dns_settings) =
super::merge_dns_config(app_config, mapping("dns: {ipv6: false, proxy-server-nameserver: []}"));
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
let result = authoritative.enforce(hijacked);
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("ipv6"))
.and_then(serde_yaml_ng::Value::as_bool),
Some(true)
);
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"));
}
}