From a7981195c5b5776d02c0df15bf55537c5347f15a Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 9 Nov 2026 19:12:42 +0800 Subject: [PATCH] refactor(profile): simplify REALITY short-id quoting Quote short-ids with a plain text pass and keep the result only when nothing but numeric short-ids changed. Drop the editor-save and seq-file paths. --- src-tauri/src/core/validate.rs | 33 +--- src-tauri/src/enhance/chain.rs | 53 +----- src-tauri/src/utils/help.rs | 317 ++++++++------------------------- 3 files changed, 73 insertions(+), 330 deletions(-) diff --git a/src-tauri/src/core/validate.rs b/src-tauri/src/core/validate.rs index 667b42d6f..cfa8d7a6c 100644 --- a/src-tauri/src/core/validate.rs +++ b/src-tauri/src/core/validate.rs @@ -1,13 +1,10 @@ use anyhow::Result; -use nanoid::nanoid; use scopeguard::defer; use serde::Serialize; use smartstring::alias::String; use std::{ - borrow::Cow, fmt, hash::{DefaultHasher, Hash as _, Hasher as _}, - path::Path, sync::LazyLock, sync::atomic::{AtomicBool, Ordering}, }; @@ -332,7 +329,7 @@ impl CoreConfigValidator { Ok(result) => result, Err(err) => { logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err); - return Self::validate_profile_yaml_outcome(config_path).await; + return Self::validate_config_internal_outcome(config_path).await; } }; @@ -347,33 +344,7 @@ impl CoreConfigValidator { } logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path); - Self::validate_profile_yaml_outcome(config_path).await - } - - /// The user file is saved as written, so the core checks a copy carrying the tolerated short-ids. - async fn validate_profile_yaml_outcome(config_path: &str) -> Result { - let Some(tolerant) = Self::short_id_tolerant_text(config_path).await else { - return Self::validate_config_internal_outcome(config_path).await; - }; - let Ok(app_dir) = dirs::app_home_dir() else { - return Self::validate_config_internal_outcome(config_path).await; - }; - let check_path = app_dir.join(format!("short-id-check-{}.yaml", nanoid!())); - defer! { - let _ = std::fs::remove_file(&check_path); - } - if help::save_yaml_str(&check_path, &tolerant).await.is_err() { - return Self::validate_config_internal_outcome(config_path).await; - } - Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await - } - - async fn short_id_tolerant_text(config_path: &str) -> Option { - let yaml = fs::read_to_string(config_path).await.ok()?; - match help::short_id_tolerant(&yaml, Path::new(config_path)) { - Ok((Cow::Owned(tolerant), _)) => Some(tolerant), - _ => None, - } + Self::validate_config_internal_outcome(config_path).await } async fn validate_config_internal_outcome(config_path: &str) -> Result { diff --git a/src-tauri/src/enhance/chain.rs b/src-tauri/src/enhance/chain.rs index 2d194a2a5..df25c712f 100644 --- a/src-tauri/src/enhance/chain.rs +++ b/src-tauri/src/enhance/chain.rs @@ -3,10 +3,8 @@ use crate::{ config::PrfItem, utils::{dirs, help}, }; -use clash_verge_logging::{Type, logging}; use serde_yaml_ng::Mapping; use smartstring::alias::String; -use std::{borrow::Cow, path::Path}; use tokio::fs; #[derive(Debug, Clone)] @@ -50,8 +48,7 @@ impl AsyncChainItemFrom for Option { data: ChainType::Merge(help::read_mapping(&path).await.ok()?), }), "rules" | "proxies" | "groups" => { - let yaml = fs::read_to_string(&path).await.ok()?; - let seq_map = parse_seq_map(&yaml, &path)?; + let seq_map = help::read_yaml::(&path).await.ok()?; let data = match itype { "rules" => ChainType::Rules(seq_map), "proxies" => ChainType::Proxies(seq_map), @@ -63,51 +60,3 @@ impl AsyncChainItemFrom for Option { } } } - -/// Typed parsing reads `delete: [1234]` as strings where a `Value` round trip would reject it. -fn parse_seq_map(yaml: &str, path: &Path) -> Option { - let original = serde_yaml_ng::from_str::(yaml).ok()?; - let Ok((Cow::Owned(tolerant), _)) = help::short_id_tolerant(yaml, path) else { - return Some(original); - }; - match serde_yaml_ng::from_str::(&tolerant) { - Ok(seq_map) => Some(seq_map), - Err(_) => { - logging!( - warn, - Type::Config, - "kept numeric short-id in {}: quoted text does not load", - path.display() - ); - Some(original) - } - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, reason = "tests assert by panicking")] -mod tests { - use super::parse_seq_map; - use serde_yaml_ng::Value; - use std::path::Path; - - #[test] - fn seq_short_id_keeps_typed_parse() { - for (input, delete) in [ - ("prepend: []\nappend: []\ndelete: [1234]\n", "1234"), - ("prepend: []\nappend: []\ndelete: [true]\n", "true"), - ] { - let seq_map = parse_seq_map(input, Path::new("b8.yaml")).expect("seq file should load"); - assert_eq!(seq_map.delete, [delete]); - } - - let input = "prepend:\n - name: r\n reality-opts:\n short-id: 1234e123\nappend: []\ndelete: []\n"; - let seq_map = parse_seq_map(input, Path::new("seq.yaml")).expect("seq file should load"); - let short_id = seq_map - .prepend - .first() - .and_then(|proxy| proxy.get("reality-opts")) - .and_then(|opts| opts.get("short-id")); - assert_eq!(short_id, Some(&Value::String("1234e123".into()))); - } -} diff --git a/src-tauri/src/utils/help.rs b/src-tauri/src/utils/help.rs index 7b87981e7..6ad2feaa3 100644 --- a/src-tauri/src/utils/help.rs +++ b/src-tauri/src/utils/help.rs @@ -7,8 +7,6 @@ use scopeguard::{ScopeGuard, guard}; use serde::{Serialize, de::DeserializeOwned}; use serde_yaml_ng::{Mapping, Value}; use std::{ - borrow::Cow, - ops::Range, path::{Path, PathBuf}, str::FromStr, }; @@ -47,8 +45,8 @@ pub async fn read_mapping(path: &Path) -> Result { } fn parse_mapping(yaml_str: String, path: PathBuf) -> Result { - match short_id_tolerant(&yaml_str, &path) { - Ok((_, mut val)) => { + match parse_short_id_text(&yaml_str, &path) { + Ok(mut val) => { val.apply_merge() .with_context(|| format!("failed to apply merge \"{}\"", path.display()))?; @@ -68,150 +66,74 @@ fn parse_mapping(yaml_str: String, path: PathBuf) -> Result { } } -const SHORT_ID_KEY: &str = "short-id:"; - -/// A float like `1234e123` loses its hex text, so quote short-ids before parsing and keep that only if nothing else changed. -pub(crate) fn short_id_tolerant<'a>(yaml: &'a str, path: &Path) -> serde_yaml_ng::Result<(Cow<'a, str>, Value)> { - let original = serde_yaml_ng::from_str::(yaml)?; - if !has_numeric_short_id(&original) { - return Ok((Cow::Borrowed(yaml), original)); +/// A float like `1234e123` loses its hex text, so retry with short-ids quoted and keep that only if nothing else changed. +fn parse_short_id_text(yaml: &str, path: &Path) -> serde_yaml_ng::Result { + let value = serde_yaml_ng::from_str::(yaml)?; + if !has_numeric_short_id(&value) { + return Ok(value); } - let quoted = quote_plain_short_ids(yaml); - if matches!(quoted, Cow::Borrowed(_)) { - return Ok((quoted, original)); - } - - match serde_yaml_ng::from_str::("ed) { - Ok(tolerant) if same_except_short_id_text(&original, &tolerant) => Ok((quoted, tolerant)), + match serde_yaml_ng::from_str::("e_short_ids(yaml)) { + Ok(quoted) if same_except_short_id(&value, "ed) => Ok(quoted), _ => { - logging!( - warn, - Type::Config, - "kept numeric short-id in {}: quoting it would change other values", - path.display() - ); - Ok((Cow::Borrowed(yaml), original)) + logging!(warn, Type::Config, "kept numeric short-id in {}", path.display()); + Ok(value) } } } +fn quote_short_ids(yaml: &str) -> String { + let mut quoted = String::with_capacity(yaml.len()); + let mut rest = yaml; + while let Some((head, tail)) = rest.split_once("short-id:") { + let (gap, value) = tail.split_at(tail.len() - tail.trim_start_matches([' ', '\t']).len()); + let (id, after) = value.split_at(value.bytes().take_while(u8::is_ascii_hexdigit).count()); + quoted.push_str(head); + quoted.push_str("short-id:"); + quoted.push_str(gap); + if !id.is_empty() && (after.is_empty() || after.starts_with([' ', '\t', '\r', '\n', ',', '}'])) { + quoted.push('"'); + quoted.push_str(id); + quoted.push('"'); + } else { + quoted.push_str(id); + } + rest = after; + } + quoted.push_str(rest); + quoted +} + fn has_numeric_short_id(value: &Value) -> bool { match value { Value::Mapping(map) => map .iter() - .any(|(key, value)| (is_short_id_key(key) && value.is_number()) || has_numeric_short_id(value)), + .any(|(key, value)| (is_short_id(key) && matches!(value, Value::Number(_))) || has_numeric_short_id(value)), Value::Sequence(seq) => seq.iter().any(has_numeric_short_id), - Value::Tagged(tagged) => has_numeric_short_id(&tagged.value), _ => false, } } -fn same_except_short_id_text(original: &Value, tolerant: &Value) -> bool { - match (original, tolerant) { - (Value::Mapping(original), Value::Mapping(tolerant)) => { - original.len() == tolerant.len() - && original - .iter() - .zip(tolerant.iter()) - .all(|((key, old), (new_key, new))| { - key == new_key && (same_except_short_id_text(old, new) || is_short_id_text(key, old, new)) - }) +fn same_except_short_id(old: &Value, new: &Value) -> bool { + match (old, new) { + (Value::Mapping(old), Value::Mapping(new)) => { + old.len() == new.len() + && old.iter().zip(new).all(|((key, old), (new_key, new))| { + key == new_key + && (same_except_short_id(old, new) + || (is_short_id(key) && matches!((old, new), (Value::Number(_), Value::String(_))))) + }) } - (Value::Sequence(original), Value::Sequence(tolerant)) => { - original.len() == tolerant.len() - && original - .iter() - .zip(tolerant.iter()) - .all(|(old, new)| same_except_short_id_text(old, new)) + (Value::Sequence(old), Value::Sequence(new)) => { + old.len() == new.len() && old.iter().zip(new).all(|(old, new)| same_except_short_id(old, new)) } - (Value::Tagged(original), Value::Tagged(tolerant)) => { - original.tag == tolerant.tag && same_except_short_id_text(&original.value, &tolerant.value) - } - _ => original == tolerant, + _ => old == new, } } -fn is_short_id_text(key: &Value, old: &Value, new: &Value) -> bool { - is_short_id_key(key) - && old.is_number() - && matches!(new, Value::String(text) if serde_yaml_ng::from_str::(text).ok().as_ref() == Some(old)) -} - -fn is_short_id_key(key: &Value) -> bool { +fn is_short_id(key: &Value) -> bool { key.as_str() == Some("short-id") } -fn quote_plain_short_ids(yaml: &str) -> Cow<'_, str> { - let mut quoted = String::new(); - let mut copied = 0; - for (key, _) in yaml.match_indices(SHORT_ID_KEY) { - let Some(value) = plain_short_id_at(yaml, key) else { - continue; - }; - let (Some(head), Some(id)) = (yaml.get(copied..value.start), yaml.get(value.clone())) else { - continue; - }; - quoted.push_str(head); - quoted.push('"'); - quoted.push_str(id); - quoted.push('"'); - copied = value.end; - } - - if copied == 0 { - return Cow::Borrowed(yaml); - } - quoted.push_str(yaml.get(copied..).unwrap_or_default()); - Cow::Owned(quoted) -} - -fn plain_short_id_at(yaml: &str, key: usize) -> Option> { - let bytes = yaml.as_bytes(); - let before = bytes.get(..key)?; - let flow = matches!( - before.iter().rev().find(|byte| !byte.is_ascii_whitespace()), - Some(b'{' | b',') - ); - let line_start = before - .iter() - .rev() - .take_while(|&&byte| byte != b'\n') - .all(|&byte| is_blank(byte)); - if !flow && !line_start { - return None; - } - - let start = key + SHORT_ID_KEY.len(); - let gap = bytes.get(start..)?.iter().take_while(|&&byte| is_blank(byte)).count(); - if gap == 0 { - return None; - } - let start = start + gap; - let len = bytes - .get(start..)? - .iter() - .take_while(|byte| byte.is_ascii_hexdigit()) - .count(); - if len == 0 || !ends_plain_short_id(bytes.get(start + len..)?, flow) { - return None; - } - Some(start..start + len) -} - -fn ends_plain_short_id(rest: &[u8], flow: bool) -> bool { - let blanks = rest.iter().take_while(|&&byte| is_blank(byte)).count(); - match rest.get(blanks..) { - Some([] | [b'\n', ..] | [b'\r', b'\n', ..]) => true, - Some([b'#', ..]) => blanks > 0, - Some([b',' | b'}', ..]) => flow, - _ => false, - } -} - -const fn is_blank(byte: u8) -> bool { - matches!(byte, b' ' | b'\t') -} - pub async fn save_yaml(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> { let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?; @@ -469,136 +391,37 @@ pub fn snapshot_path(original_path: &Path) -> Result { #[cfg(test)] #[allow(clippy::expect_used, reason = "tests assert by panicking")] mod tests { - use super::{has_numeric_short_id, parse_mapping, quote_plain_short_ids, short_id_tolerant}; + use super::{parse_mapping, parse_short_id_text}; use serde_yaml_ng::Value; - use std::{ - borrow::Cow, - path::{Path, PathBuf}, - }; + use std::path::{Path, PathBuf}; #[test] - fn quotes_plain_short_ids() { - let cases = [ - ("short-id: 1234e123", "short-id: \"1234e123\""), - ("short-id: 0e5", "short-id: \"0e5\""), - ("short-id: 12345678", "short-id: \"12345678\""), - ("short-id: 6ba85179", "short-id: \"6ba85179\""), - ("short-id: \"1234e123\"", "short-id: \"1234e123\""), - ("short-id: '1234e123'", "short-id: '1234e123'"), - ("short-id: \"\"", "short-id: \"\""), - ("short-id:\npublic-key: k", "short-id:\npublic-key: k"), - ("short-id: null", "short-id: null"), - ("short-id: ~", "short-id: ~"), - ("short-id: 1234e123 # note", "short-id: \"1234e123\" # note"), - ("# short-id: 1234e123", "# short-id: 1234e123"), - ( - "reality-opts: {public-key: k, short-id: 1234e123}", - "reality-opts: {public-key: k, short-id: \"1234e123\"}", - ), - ( - "reality-opts: {short-id: 1234e123, public-key: k}", - "reality-opts: {short-id: \"1234e123\", public-key: k}", - ), - ( - "{\n public-key: k,\n short-id: 1234e123\n}", - "{\n public-key: k,\n short-id: \"1234e123\"\n}", - ), - ("short-id: &sid 1234e123", "short-id: &sid 1234e123"), - ("short-id: !!str 1234e123", "short-id: !!str 1234e123"), - ("short-id: abc,def", "short-id: abc,def"), - ("short-id: 1234e123\r\n", "short-id: \"1234e123\"\r\n"), - ("my-short-id: 1234e123", "my-short-id: 1234e123"), - ("xshort-id: 1", "xshort-id: 1"), - ("short-id:1234e123", "short-id:1234e123"), - ("short-id: 1.5e3", "short-id: 1.5e3"), - ]; - - for (input, expected) in cases { - let output = quote_plain_short_ids(input); - assert_eq!(output, expected, "input: {input:?}"); - assert_eq!( - matches!(output, Cow::Borrowed(_)), - input == expected, - "input: {input:?}" - ); - assert_eq!(quote_plain_short_ids(&output), expected, "rerun: {input:?}"); - } - } - - #[test] - fn plain_short_id_survives_profile_parse() { + fn plain_short_id_stays_text() { let profile = "\ -proxies: - - name: r - type: vless - reality-opts: - public-key: SFv3pZQdZ1fpnNg9yI2RwXKqAOz4mZHRbT2Wq4kz8Ws - short-id: 1234e123 -"; - let mapping = parse_mapping(profile.into(), PathBuf::from("profile.yaml")).expect("profile should parse"); - let short_id = mapping - .get("proxies") - .and_then(|proxies| proxies.get(0)) - .and_then(|proxy| proxy.get("reality-opts")) - .and_then(|opts| opts.get("short-id")); - assert_eq!(short_id, Some(&Value::String("1234e123".into()))); - - let output = serde_yaml_ng::to_string(&mapping).expect("mapping should serialize"); - assert!(output.contains("short-id: '1234e123'"), "{output}"); - } - - #[test] - fn short_id_rewrite_falls_back_to_original_parse() { - // (case, input, has a numeric short-id today) - let cases = [ - ("B2", "proxies:\n - name: \"node, short-id: 1234e123}\"\n", false), - ( - "B3", - "proxies:\n - name: \"node, short-id: 1234e123}\"\n - name: r\n reality-opts:\n short-id: 1234e123\n", - true, - ), - ( - "B4", - "payload: |\n short-id: 1234e123\nreality-opts:\n short-id: 1234e123\n", - true, - ), - ("B5", "reality-opts:\n short-id: 1234e123\n more\n", false), - ("B6", "reality-opts: {public-key: k, # c\n short-id: 1234e123}\n", true), - ]; - - for (case, input, numeric) in cases { - let today = serde_yaml_ng::from_str::(input).expect("input should parse"); - assert_eq!(has_numeric_short_id(&today), numeric, "{case}"); - let (text, value) = short_id_tolerant(input, Path::new("b.yaml")).expect("input should parse"); - assert!(matches!(text, Cow::Borrowed(_)), "{case}"); - assert_eq!(value, today, "{case}"); - } - } - - #[test] - fn short_id_rewrite_keeps_every_numeric_short_id() { - let input = "\ proxies: - name: a reality-opts: short-id: 1234e123 - - {name: b, reality-opts: {short-id: 0e5}} + - {name: b, reality-opts: {public-key: k, short-id: 0e5}} "; - let (text, value) = short_id_tolerant(input, Path::new("b7.yaml")).expect("input should parse"); - assert!(matches!(text, Cow::Owned(_))); - let short_ids: Vec<_> = value - .get("proxies") - .and_then(Value::as_sequence) - .expect("proxies should be a sequence") - .iter() - .map(|proxy| proxy.get("reality-opts").and_then(|opts| opts.get("short-id")).cloned()) - .collect(); - assert_eq!( - short_ids, - [ - Some(Value::String("1234e123".into())), - Some(Value::String("0e5".into())) - ] - ); + let mapping = parse_mapping(profile.into(), PathBuf::from("profile.yaml")).expect("profile should parse"); + let output = serde_yaml_ng::to_string(&mapping).expect("mapping should serialize"); + assert!(output.contains("short-id: '1234e123'"), "{output}"); + assert!(output.contains("short-id: '0e5'"), "{output}"); + } + + #[test] + fn short_id_quoting_changing_other_values_is_dropped() { + let profile = "\ +proxies: + - name: a + reality-opts: + short-id: 1234e123 +note: | + short-id: 1234e123 +"; + let today = serde_yaml_ng::from_str::(profile).expect("profile should parse"); + let value = parse_short_id_text(profile, Path::new("profile.yaml")).expect("profile should parse"); + assert_eq!(value, today); } }