fix(enhance): prevent crash when deleted proxies remain in manual groups (#5522)

* feat(enhance): cleanup stale proxies and policies before final config

* feat(enhance): preserve provider-backed groups when sanitizing proxies

* docs: Changelog.md

* refactor: to_owned

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
Sline
2025-11-19 18:59:20 +08:00
committed by GitHub
parent ba3cd9b006
commit d808e59156
2 changed files with 258 additions and 1 deletions

View File

@@ -9,6 +9,7 @@
- PAC 自动代理脚本内容无法动态调整
- 兼容从旧版服务模式升级
- Monaco 编辑器的行数上限
- 已删除节点在手动分组中导致配置无法加载
<details>
<summary><strong> ✨ 新增功能 </strong></summary>

View File

@@ -17,7 +17,7 @@ use crate::constants;
use crate::utils::dirs;
use crate::{config::Config, utils::tmpl};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use tokio::fs;
@@ -501,6 +501,88 @@ fn apply_builtin_scripts(
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
}
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);
@@ -595,6 +677,8 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
// builtin scripts
let mut config = apply_builtin_scripts(config, clash_core, enable_builtin);
config = cleanup_proxy_groups(config);
config = use_tun(config, enable_tun);
config = use_sort(config);
@@ -607,3 +691,175 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
(config, exists_keys, result_map)
}
#[cfg(test)]
mod tests {
use super::cleanup_proxy_groups;
#[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"));
}
}