From 6790736c30af448c50dd735565def3fae70a3631 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:43:39 +0800 Subject: [PATCH 1/7] feat(cli): align config test with mihomo - accept mihomo-style config sources in test mode, including -config, -f -, and CLASH_* environment inputs - print validation errors and summaries on stdout with mihomo-compatible exit codes - create the mihomo default config for missing file-mode configs and cover the CLI behavior with integration tests --- Cargo.lock | 3 + clash-bin/Cargo.toml | 5 + clash-bin/src/main.rs | 271 ++++++++++++++++++++++---- clash-bin/tests/mihomo_config_test.rs | 258 ++++++++++++++++++++++++ 4 files changed, 498 insertions(+), 39 deletions(-) create mode 100644 clash-bin/tests/mihomo_config_test.rs diff --git a/Cargo.lock b/Cargo.lock index a998e576b..095518242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1432,12 +1432,15 @@ version = "0.10.0" dependencies = [ "anyhow", "aws-lc-rs", + "base64 0.22.1", "clap", "clash-lib", "dhat", "human-panic", "sentry", + "tempfile", "tikv-jemallocator", + "time", ] [[package]] diff --git a/clash-bin/Cargo.toml b/clash-bin/Cargo.toml index 7dc162670..2042c8bbd 100644 --- a/clash-bin/Cargo.toml +++ b/clash-bin/Cargo.toml @@ -38,6 +38,8 @@ ring = ["clash-lib/ring"] [dependencies] clap = { version = "4", features = ["derive"] } anyhow = "1" +base64 = "0.22" +time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } clash-lib = { path = "../clash-lib", default-features = false } @@ -49,3 +51,6 @@ human-panic = "2.0" aws-lc-rs = { version = "1.16", optional = true, default-features = false } + +[dev-dependencies] +tempfile = "3" diff --git a/clash-bin/src/main.rs b/clash-bin/src/main.rs index 866a97e63..ff806e16a 100644 --- a/clash-bin/src/main.rs +++ b/clash-bin/src/main.rs @@ -15,13 +15,19 @@ static GLOBAL: Jemalloc = Jemalloc; extern crate clash_lib as clash; +use anyhow::{Context, anyhow}; +use base64::{Engine, engine::general_purpose::STANDARD}; use clap::Parser; use clash::TokioRuntime; use std::{ - io::Write, + io::{Read, Write}, path::{Path, PathBuf}, process::exit, }; +use time::{OffsetDateTime, macros::format_description}; + +const DEFAULT_CONFIG_FILE: &str = "config.yaml"; +const DEFAULT_CONFIG_CONTENT: &str = "mixed-port: 7890"; #[derive(Parser)] #[clap(author, about, long_about = None)] @@ -35,10 +41,13 @@ struct Cli { visible_short_aliases = ['f'], // -f is used by clash, it is a compatibility option value_parser, value_name = "FILE", - default_value = "config.yaml", help = "Specify configuration file" )] - config: PathBuf, + config: Option, + #[clap(long = "mihomo-file", hide = true, value_parser, value_name = "FILE")] + mihomo_file: Option, + #[clap(long = "mihomo-config", hide = true, value_name = "BASE64")] + mihomo_config: Option, #[clap( short = 't', long, @@ -93,13 +102,7 @@ fn main() -> anyhow::Result<()> { // Those arguments are for compatibility with `mihomo` // Technically, I do not think `mihomo` is a modern/standard POSIX Cli program - let args: Vec = std::env::args() - .map(|arg| match arg.as_str() { - "-ext-ctl-unix" => "--ext-ctl-unix".to_string(), - "-ext-ctl-pipe" => "--ext-ctl-pipe".to_string(), - _ => arg, - }) - .collect(); + let args = preprocess_args(std::env::args()); let cli = Cli::parse_from(args); if cli.version { @@ -111,43 +114,28 @@ fn main() -> anyhow::Result<()> { exit(0) } - let file = cli - .directory - .as_ref() - .unwrap_or(&std::env::current_dir().unwrap()) - .join(cli.config) - .to_string_lossy() - .to_string(); - - if !Path::new(&file).exists() { - let default_config = "port: 7890"; - let mut config_file = match std::fs::File::create(&file) { - Ok(config_file) => config_file, - _ => { - eprintln!("default profile cannot be created: {file}"); - exit(1); - } - }; - - if config_file.write_all(default_config.as_bytes()).is_err() { - eprintln!("default profile cannot be written: {file}"); + let config_input = match ConfigInput::resolve(&cli) { + Ok(config_input) => config_input, + Err(err) => { + print_mihomo_log("fatal", &err.to_string()); exit(1); - }; + } + }; - println!( - "the configuration file cannot be found, the template has been created \ - and used: {file}" - ); + if let Err(err) = config_input.ensure_file() { + print_mihomo_log("fatal", &err.to_string()); + exit(1); } if cli.test_config { - match clash::Config::File(file.clone()).try_parse() { + match config_input.try_parse() { Ok(_) => { - println!("configuration file {file} test is successful"); + print_test_success(&config_input); exit(0); } Err(e) => { - eprintln!("configuration file {file} test failed: {e}"); + print_mihomo_log("error", &e.to_string()); + print_test_failure(&config_input); exit(1); } } @@ -174,7 +162,7 @@ fn main() -> anyhow::Result<()> { ))); } - let mut config = clash::Config::File(file).try_parse()?; + let mut config = config_input.try_parse()?; config.general.controller.external_controller_ipc = cli.controller_ipc; @@ -221,3 +209,208 @@ fn main() -> anyhow::Result<()> { .inspect_err(|err| eprintln!("Failed to start clash: {err}"))?; Ok(()) } + +enum ConfigInput { + File { path: PathBuf, display_path: String }, + Bytes { content: String }, +} + +impl ConfigInput { + fn resolve(cli: &Cli) -> anyhow::Result { + if let Some(config) = cli.mihomo_config.as_deref() { + return decode_config(config); + } + if let Ok(config) = std::env::var("CLASH_CONFIG_STRING") { + return decode_config(&config); + } + + if let Some(file) = cli.mihomo_file.as_ref() { + return resolve_mihomo_file(file); + } + + if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") { + return resolve_mihomo_file(&PathBuf::from(file)); + } + + let current_dir = + std::env::current_dir().context("get current directory")?; + let home_dir = cli + .directory + .clone() + .or_else(|| std::env::var_os("CLASH_HOME_DIR").map(PathBuf::from)); + let directory = match home_dir { + Some(directory) if directory.is_absolute() => directory, + Some(directory) => current_dir.join(directory), + None => current_dir, + }; + let config = cli + .config + .as_ref() + .cloned() + .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_FILE)); + let path = if config.is_absolute() { + config + } else { + directory.join(config) + }; + Ok(Self::from_file_path(path)) + } + + fn from_file_path(path: PathBuf) -> Self { + let path = + absolutize(path).unwrap_or_else(|_| PathBuf::from(DEFAULT_CONFIG_FILE)); + let display_path = path.to_string_lossy().to_string(); + Self::File { path, display_path } + } + + fn ensure_file(&self) -> anyhow::Result<()> { + let Self::File { path, .. } = self else { + return Ok(()); + }; + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "default profile directory cannot be created: {}", + parent.display() + ) + })?; + } + let mut config_file = std::fs::File::create(path).with_context(|| { + format!("default profile cannot be created: {}", path.display()) + })?; + config_file + .write_all(DEFAULT_CONFIG_CONTENT.as_bytes()) + .with_context(|| { + format!("default profile cannot be written: {}", path.display()) + })?; + Ok(()) + } + + fn try_parse(&self) -> clash::Result { + match self { + Self::File { path, display_path } => { + if std::fs::metadata(path).map(|metadata| metadata.len() == 0)? { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("configuration file {display_path} is empty"), + ) + .into()); + } + clash::Config::File(path.to_string_lossy().to_string()).try_parse() + } + Self::Bytes { content } => { + clash::Config::Str(content.clone()).try_parse() + } + } + } +} + +fn preprocess_args(args: impl IntoIterator) -> Vec { + args.into_iter() + .map(|arg| match arg.as_str() { + "-ext-ctl-unix" => "--ext-ctl-unix".to_string(), + "-ext-ctl-pipe" => "--ext-ctl-pipe".to_string(), + "-config" => "--mihomo-config".to_string(), + "-f" => "--mihomo-file".to_string(), + _ if arg.starts_with("-config=") => { + arg.replacen("-config=", "--mihomo-config=", 1) + } + _ if arg.starts_with("-f=") => arg.replacen("-f=", "--mihomo-file=", 1), + _ if arg.starts_with("-f") && arg.len() > 2 => { + format!("--mihomo-file={}", &arg[2..]) + } + _ => arg, + }) + .collect() +} + +fn decode_config(config: &str) -> anyhow::Result { + let content = STANDARD + .decode(config) + .map_err(|err| anyhow!("decode config: {err}"))?; + let content = String::from_utf8_lossy(&content).into_owned(); + Ok(ConfigInput::Bytes { content }) +} + +fn resolve_mihomo_file(file: &Path) -> anyhow::Result { + if file == Path::new("-") { + let mut content = String::new(); + std::io::stdin() + .read_to_string(&mut content) + .context("read configuration from stdin")?; + if !content.is_empty() { + return Ok(ConfigInput::Bytes { content }); + } + return Ok(ConfigInput::from_file_path(PathBuf::from( + DEFAULT_CONFIG_FILE, + ))); + } + Ok(ConfigInput::from_file_path(file.to_path_buf())) +} + +fn absolutize(path: PathBuf) -> anyhow::Result { + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir() + .context("get current directory")? + .join(path)) + } +} + +fn print_test_success(input: &ConfigInput) { + match input { + ConfigInput::File { display_path, .. } => { + println!("configuration file {display_path} test is successful"); + } + ConfigInput::Bytes { .. } => { + println!("configuration file {DEFAULT_CONFIG_FILE} test is successful"); + } + } +} + +fn print_test_failure(input: &ConfigInput) { + match input { + ConfigInput::File { display_path, .. } => { + println!("configuration file {display_path} test failed"); + } + ConfigInput::Bytes { .. } => { + println!("configuration test failed"); + } + } +} + +fn print_mihomo_log(level: &str, msg: &str) { + let format = format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9][offset_hour sign:mandatory]:[offset_minute]" + ); + let timestamp = OffsetDateTime::now_local() + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(format) + .unwrap_or_else(|_| "1970-01-01T00:00:00.000000000+00:00".to_string()); + println!( + "time=\"{}\" level={} msg=\"{}\"", + timestamp, + level, + escape_logrus_text(msg) + ); +} + +fn escape_logrus_text(msg: &str) -> String { + msg.chars().fold(String::new(), |mut escaped, ch| { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + _ => escaped.push(ch), + } + escaped + }) +} diff --git a/clash-bin/tests/mihomo_config_test.rs b/clash-bin/tests/mihomo_config_test.rs new file mode 100644 index 000000000..4b4581d34 --- /dev/null +++ b/clash-bin/tests/mihomo_config_test.rs @@ -0,0 +1,258 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use std::{ + fs, + io::Write, + path::Path, + process::{Command, Output, Stdio}, +}; +use tempfile::tempdir; + +const VALID_CONFIG: &str = r#"mixed-port: 8899 +external-controller: 127.0.0.1:9090 +mode: global +bind-address: "0.0.0.0" +"#; + +fn clash_cmd() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_clash-rs")); + command + .env_remove("CLASH_CONFIG_FILE") + .env_remove("CLASH_CONFIG_STRING") + .env_remove("CLASH_HOME_DIR"); + command +} + +fn run_with_stdin(mut command: Command, stdin: &str) -> Output { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn clash-rs"); + child + .stdin + .as_mut() + .expect("open stdin") + .write_all(stdin.as_bytes()) + .expect("write stdin"); + child.wait_with_output().expect("wait for clash-rs") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[test] +fn file_success_outputs_mihomo_summary_on_stdout() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("ok.yaml"); + fs::write(&config, VALID_CONFIG).expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert!(stdout(&output).ends_with(&format!( + "configuration file {} test is successful\n", + path_display(&config) + ))); +} + +#[test] +fn file_success_accepts_go_style_equals_flag() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("ok.yaml"); + fs::write(&config, VALID_CONFIG).expect("write config"); + + let output = clash_cmd() + .arg("-t") + .arg(format!("-f={}", path_display(&config))) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + +#[test] +fn file_failure_logs_error_then_summary_on_stdout() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("invalid.yaml"); + fs::write(&config, "log-level: definitely-not-a-level\n").expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + + let stdout = stdout(&output); + let lines = stdout.lines().collect::>(); + assert!( + lines.first().is_some_and(|line| line.starts_with("time=\"") + && line.contains(" level=error msg=\"")), + "stdout:\n{stdout}" + ); + let summary = + format!("configuration file {} test failed", path_display(&config)); + assert_eq!(lines.last().copied(), Some(summary.as_str())); +} + +#[test] +fn stdin_success_uses_bytes_summary_and_does_not_create_default_config() { + let temp = tempdir().expect("create temp dir"); + let mut command = clash_cmd(); + command.current_dir(temp.path()).args(["-t", "-f", "-"]); + + let output = run_with_stdin(command, VALID_CONFIG); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert!( + stdout(&output) + .ends_with("configuration file config.yaml test is successful\n") + ); + assert!(!temp.path().join("config.yaml").exists()); +} + +#[test] +fn base64_config_success_uses_bytes_summary() { + let temp = tempdir().expect("create temp dir"); + let encoded = STANDARD.encode(VALID_CONFIG); + + let output = clash_cmd() + .current_dir(temp.path()) + .args(["-t", "-config", &encoded]) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + "configuration file config.yaml test is successful\n" + ); + assert!(!temp.path().join("config.yaml").exists()); +} + +#[test] +fn base64_decode_failure_is_stdout_fatal() { + let output = clash_cmd() + .args(["-t", "-config", "not@base64"]) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + let stdout = stdout(&output); + assert!(stdout.starts_with("time=\""), "stdout:\n{stdout}"); + assert!( + stdout.contains(" level=fatal msg=\"decode config:"), + "stdout:\n{stdout}" + ); +} + +#[test] +fn missing_file_mode_creates_mihomo_default_config() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("home").join("config.yaml"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + fs::read_to_string(&config).expect("read default config"), + "mixed-port: 7890" + ); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + +#[test] +fn empty_file_reports_mihomo_empty_file_error() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("empty.yaml"); + fs::write(&config, "").expect("write empty config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + + let stdout = stdout(&output); + assert!( + stdout.contains(&format!( + "configuration file {} is empty", + path_display(&config) + )), + "stdout:\n{stdout}" + ); + assert!(stdout.ends_with(&format!( + "configuration file {} test failed\n", + path_display(&config) + ))); +} From 5c70c87203fb4d71c4f9aa3f13044ef5450d2713 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:44:27 +0800 Subject: [PATCH 2/7] fix(config): align provider health-check defaults - allow proxy providers to omit health-check and apply mihomo-compatible defaults - default enabled health checks to interval 300 and lazy=true - return provider parse errors instead of panicking during config validation --- clash-bin/tests/mihomo_config_test.rs | 37 ++++++++ clash-lib/src/app/outbound/manager.rs | 14 ++- clash-lib/src/config/internal/convert/mod.rs | 4 +- .../config/internal/convert/rule_provider.rs | 8 +- clash-lib/src/config/internal/proxy.rs | 88 ++++++++++++++++++- 5 files changed, 140 insertions(+), 11 deletions(-) diff --git a/clash-bin/tests/mihomo_config_test.rs b/clash-bin/tests/mihomo_config_test.rs index 4b4581d34..4fc9bff65 100644 --- a/clash-bin/tests/mihomo_config_test.rs +++ b/clash-bin/tests/mihomo_config_test.rs @@ -131,6 +131,43 @@ fn file_failure_logs_error_then_summary_on_stdout() { assert_eq!(lines.last().copied(), Some(summary.as_str())); } +#[test] +fn proxy_provider_without_health_check_is_accepted() { + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("provider.yaml"); + fs::write( + &config, + r#"mixed-port: 8899 +proxy-providers: + 0.LocalProxyNode: + type: file + path: ./providers.yaml +"#, + ) + .expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + #[test] fn stdin_success_uses_bytes_summary_and_does_not_create_default_config() { let temp = tempdir().expect("create temp dir"); diff --git a/clash-lib/src/app/outbound/manager.rs b/clash-lib/src/app/outbound/manager.rs index dfe3aab2e..6fc51f084 100644 --- a/clash-lib/src/app/outbound/manager.rs +++ b/clash-lib/src/app/outbound/manager.rs @@ -909,6 +909,9 @@ impl OutboundManager { for (name, provider) in proxy_providers.into_iter() { match provider { OutboundProxyProviderDef::Http(http) => { + let health_check_interval = + http.health_check.effective_interval(); + let health_check_lazy = http.health_check.effective_lazy(); let vehicle = http_vehicle::Vehicle::new( http.url.parse::().unwrap_or_else(|_| { print_and_exit!("invalid provider url: {}", http.url); @@ -920,8 +923,8 @@ impl OutboundManager { let hc = HealthCheck::new( vec![], http.health_check.url, - http.health_check.interval, - http.health_check.lazy.unwrap_or_default(), + health_check_interval, + health_check_lazy, proxy_manager.clone(), ); @@ -938,6 +941,9 @@ impl OutboundManager { provider_registry.insert(name, Arc::new(RwLock::new(provider))); } OutboundProxyProviderDef::File(file) => { + let health_check_interval = + file.health_check.effective_interval(); + let health_check_lazy = file.health_check.effective_lazy(); let vehicle = file_vehicle::Vehicle::new( PathBuf::from(cwd.clone()) .join(&file.path) @@ -947,8 +953,8 @@ impl OutboundManager { let hc = HealthCheck::new( vec![], file.health_check.url, - file.health_check.interval, - file.health_check.lazy.unwrap_or_default(), + health_check_interval, + health_check_lazy, proxy_manager.clone(), ); diff --git a/clash-lib/src/config/internal/convert/mod.rs b/clash-lib/src/config/internal/convert/mod.rs index fcb44d2d1..712023852 100644 --- a/clash-lib/src/config/internal/convert/mod.rs +++ b/clash-lib/src/config/internal/convert/mod.rs @@ -74,7 +74,7 @@ pub(super) fn convert(mut c: def::Config) -> Result, _>>()?, - rule_providers: rule_provider::convert(c.rule_provider.take()), + rule_providers: rule_provider::convert(c.rule_provider.take())?, users: c .authentication .clone() @@ -143,8 +143,8 @@ pub(super) fn convert(mut c: def::Config) -> Result(rv) }) - .expect("proxy provider parse error") }) + .transpose()? .unwrap_or_default(), listeners: listener::convert(c.listeners.take(), &c)?, inbound_providers: c diff --git a/clash-lib/src/config/internal/convert/rule_provider.rs b/clash-lib/src/config/internal/convert/rule_provider.rs index 1e1f8f94b..b377df9c3 100644 --- a/clash-lib/src/config/internal/convert/rule_provider.rs +++ b/clash-lib/src/config/internal/convert/rule_provider.rs @@ -10,8 +10,8 @@ use crate::{ pub(super) fn convert( before: Option>>, -) -> HashMap { - before +) -> Result, Error> { + Ok(before .map(|m| { m.into_iter() .try_fold(HashMap::new(), |mut rv, (name, mut body)| { @@ -49,9 +49,9 @@ pub(super) fn convert( rv.insert(name, provider); Ok::, Error>(rv) }) - .expect("proxy provider parse error") }) - .unwrap_or_default() + .transpose()? + .unwrap_or_default()) } impl TryFrom> for RuleProviderDef { diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 06206ad54..343de9c9c 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -687,6 +687,7 @@ pub struct OutboundHttpProvider { pub url: String, pub interval: u64, pub path: String, + #[serde(default = "default_provider_health_check")] pub health_check: HealthCheck, } @@ -697,17 +698,52 @@ pub struct OutboundFileProvider { pub name: String, pub path: String, pub interval: Option, + #[serde(default = "default_provider_health_check")] pub health_check: HealthCheck, } -#[derive(serde::Serialize, serde::Deserialize, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct HealthCheck { + #[serde(default)] pub enable: bool, + #[serde(default)] pub url: String, + #[serde(default)] pub interval: u64, + #[serde(default = "default_provider_health_check_lazy")] pub lazy: Option, } +impl HealthCheck { + pub fn effective_interval(&self) -> u64 { + if !self.enable { + return 0; + } + if self.interval == 0 { + 300 + } else { + self.interval + } + } + + pub fn effective_lazy(&self) -> bool { + self.lazy.unwrap_or(true) + } +} + +fn default_provider_health_check() -> HealthCheck { + HealthCheck { + enable: false, + url: String::new(), + interval: 0, + lazy: Some(true), + } +} + +fn default_provider_health_check_lazy() -> Option { + Some(true) +} + impl TryFrom> for OutboundProxyProviderDef { type Error = crate::Error; @@ -726,6 +762,56 @@ impl TryFrom> for OutboundProxyProviderDef { } } +#[cfg(test)] +mod proxy_provider_tests { + use super::OutboundProxyProviderDef; + use serde_yaml::Value; + use std::collections::HashMap; + + fn value(input: &str) -> Value { + Value::String(input.to_owned()) + } + + #[test] + fn missing_health_check_uses_mihomo_defaults() { + let provider = OutboundProxyProviderDef::try_from(HashMap::from([ + ("name".to_owned(), value("provider")), + ("type".to_owned(), value("file")), + ("path".to_owned(), value("./provider.yaml")), + ])) + .expect("parse provider"); + + let OutboundProxyProviderDef::File(provider) = provider else { + panic!("expected file provider"); + }; + assert!(!provider.health_check.enable); + assert_eq!(provider.health_check.url, ""); + assert_eq!(provider.health_check.effective_interval(), 0); + assert!(provider.health_check.effective_lazy()); + } + + #[test] + fn enabled_health_check_defaults_interval_to_300() { + let provider = OutboundProxyProviderDef::try_from(HashMap::from([ + ("name".to_owned(), value("provider")), + ("type".to_owned(), value("file")), + ("path".to_owned(), value("./provider.yaml")), + ( + "health-check".to_owned(), + serde_yaml::from_str("enable: true").expect("parse health check"), + ), + ])) + .expect("parse provider"); + + let OutboundProxyProviderDef::File(provider) = provider else { + panic!("expected file provider"); + }; + assert!(provider.health_check.enable); + assert_eq!(provider.health_check.effective_interval(), 300); + assert!(provider.health_check.effective_lazy()); + } +} + #[cfg(all(test, feature = "tailscale"))] mod tailscale_tests { use super::{OutboundProxyProtocol, OutboundTailscale}; From 8a99e96066d5dfedd48f367d7829d8f0f8af2098 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:00:30 +0800 Subject: [PATCH 3/7] style: fix cargo fmt --- clash-bin/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clash-bin/src/main.rs b/clash-bin/src/main.rs index ff806e16a..667e7ad71 100644 --- a/clash-bin/src/main.rs +++ b/clash-bin/src/main.rs @@ -387,7 +387,8 @@ fn print_test_failure(input: &ConfigInput) { fn print_mihomo_log(level: &str, msg: &str) { let format = format_description!( - "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9][offset_hour sign:mandatory]:[offset_minute]" + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond \ + digits:9][offset_hour sign:mandatory]:[offset_minute]" ); let timestamp = OffsetDateTime::now_local() .unwrap_or_else(|_| OffsetDateTime::now_utc()) From ef2f08a976e474b1cc583e97293f3b151b179369 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:36:37 +0800 Subject: [PATCH 4/7] refactor(proxy): simplify health check defaults and implement Default for HealthCheck Co-authored-by: Copilot --- clash-lib/src/config/internal/proxy.rs | 30 ++++++++++++-------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 343de9c9c..c7f6de560 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -687,7 +687,7 @@ pub struct OutboundHttpProvider { pub url: String, pub interval: u64, pub path: String, - #[serde(default = "default_provider_health_check")] + #[serde(default)] pub health_check: HealthCheck, } @@ -698,7 +698,7 @@ pub struct OutboundFileProvider { pub name: String, pub path: String, pub interval: Option, - #[serde(default = "default_provider_health_check")] + #[serde(default)] pub health_check: HealthCheck, } @@ -710,10 +710,21 @@ pub struct HealthCheck { pub url: String, #[serde(default)] pub interval: u64, - #[serde(default = "default_provider_health_check_lazy")] + #[serde(default)] pub lazy: Option, } +impl Default for HealthCheck { + fn default() -> Self { + HealthCheck { + enable: false, + url: String::new(), + interval: 0, + lazy: Some(true), + } + } +} + impl HealthCheck { pub fn effective_interval(&self) -> u64 { if !self.enable { @@ -731,19 +742,6 @@ impl HealthCheck { } } -fn default_provider_health_check() -> HealthCheck { - HealthCheck { - enable: false, - url: String::new(), - interval: 0, - lazy: Some(true), - } -} - -fn default_provider_health_check_lazy() -> Option { - Some(true) -} - impl TryFrom> for OutboundProxyProviderDef { type Error = crate::Error; From cf9644b21272aba1563e11cbaeb76e7d5ddb1df2 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:40:44 +0800 Subject: [PATCH 5/7] refactor(main): rename mihomo logging functions to cli logging functions --- clash-bin/src/main.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/clash-bin/src/main.rs b/clash-bin/src/main.rs index 667e7ad71..c219e6834 100644 --- a/clash-bin/src/main.rs +++ b/clash-bin/src/main.rs @@ -117,13 +117,13 @@ fn main() -> anyhow::Result<()> { let config_input = match ConfigInput::resolve(&cli) { Ok(config_input) => config_input, Err(err) => { - print_mihomo_log("fatal", &err.to_string()); + print_cli_log("fatal", &err.to_string()); exit(1); } }; if let Err(err) = config_input.ensure_file() { - print_mihomo_log("fatal", &err.to_string()); + print_cli_log("fatal", &err.to_string()); exit(1); } @@ -134,7 +134,7 @@ fn main() -> anyhow::Result<()> { exit(0); } Err(e) => { - print_mihomo_log("error", &e.to_string()); + print_cli_log("error", &e.to_string()); print_test_failure(&config_input); exit(1); } @@ -225,11 +225,11 @@ impl ConfigInput { } if let Some(file) = cli.mihomo_file.as_ref() { - return resolve_mihomo_file(file); + return resolve_config_file(file); } if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") { - return resolve_mihomo_file(&PathBuf::from(file)); + return resolve_config_file(&PathBuf::from(file)); } let current_dir = @@ -337,7 +337,7 @@ fn decode_config(config: &str) -> anyhow::Result { Ok(ConfigInput::Bytes { content }) } -fn resolve_mihomo_file(file: &Path) -> anyhow::Result { +fn resolve_config_file(file: &Path) -> anyhow::Result { if file == Path::new("-") { let mut content = String::new(); std::io::stdin() @@ -385,7 +385,7 @@ fn print_test_failure(input: &ConfigInput) { } } -fn print_mihomo_log(level: &str, msg: &str) { +fn print_cli_log(level: &str, msg: &str) { let format = format_description!( "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond \ digits:9][offset_hour sign:mandatory]:[offset_minute]" From b8254d7b78567fee5e926b68cf162173f8b40538 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:04:30 +0800 Subject: [PATCH 6/7] Remove mihomo-specific config helper names --- clash-bin/src/main.rs | 61 ++++++++++--------- ...mo_config_test.rs => config_input_test.rs} | 6 +- clash-lib/src/config/internal/proxy.rs | 2 +- 3 files changed, 36 insertions(+), 33 deletions(-) rename clash-bin/tests/{mihomo_config_test.rs => config_input_test.rs} (97%) diff --git a/clash-bin/src/main.rs b/clash-bin/src/main.rs index c219e6834..80a76e2ca 100644 --- a/clash-bin/src/main.rs +++ b/clash-bin/src/main.rs @@ -44,10 +44,8 @@ struct Cli { help = "Specify configuration file" )] config: Option, - #[clap(long = "mihomo-file", hide = true, value_parser, value_name = "FILE")] - mihomo_file: Option, - #[clap(long = "mihomo-config", hide = true, value_name = "BASE64")] - mihomo_config: Option, + #[clap(long = "config-string", hide = true, value_name = "BASE64")] + config_string: Option, #[clap( short = 't', long, @@ -217,21 +215,13 @@ enum ConfigInput { impl ConfigInput { fn resolve(cli: &Cli) -> anyhow::Result { - if let Some(config) = cli.mihomo_config.as_deref() { + if let Some(config) = cli.config_string.as_deref() { return decode_config(config); } if let Ok(config) = std::env::var("CLASH_CONFIG_STRING") { return decode_config(&config); } - if let Some(file) = cli.mihomo_file.as_ref() { - return resolve_config_file(file); - } - - if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") { - return resolve_config_file(&PathBuf::from(file)); - } - let current_dir = std::env::current_dir().context("get current directory")?; let home_dir = cli @@ -243,17 +233,16 @@ impl ConfigInput { Some(directory) => current_dir.join(directory), None => current_dir, }; - let config = cli - .config - .as_ref() - .cloned() - .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_FILE)); - let path = if config.is_absolute() { - config - } else { - directory.join(config) - }; - Ok(Self::from_file_path(path)) + + if let Some(config) = cli.config.as_ref() { + return resolve_config_path(config, &directory); + } + + if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") { + return resolve_config_file(&PathBuf::from(file)); + } + + Ok(Self::from_file_path(directory.join(DEFAULT_CONFIG_FILE))) } fn from_file_path(path: PathBuf) -> Self { @@ -315,14 +304,14 @@ fn preprocess_args(args: impl IntoIterator) -> Vec { .map(|arg| match arg.as_str() { "-ext-ctl-unix" => "--ext-ctl-unix".to_string(), "-ext-ctl-pipe" => "--ext-ctl-pipe".to_string(), - "-config" => "--mihomo-config".to_string(), - "-f" => "--mihomo-file".to_string(), + "-config" => "--config-string".to_string(), + "-f" => "--config".to_string(), _ if arg.starts_with("-config=") => { - arg.replacen("-config=", "--mihomo-config=", 1) + arg.replacen("-config=", "--config-string=", 1) } - _ if arg.starts_with("-f=") => arg.replacen("-f=", "--mihomo-file=", 1), + _ if arg.starts_with("-f=") => arg.replacen("-f=", "--config=", 1), _ if arg.starts_with("-f") && arg.len() > 2 => { - format!("--mihomo-file={}", &arg[2..]) + format!("--config={}", &arg[2..]) } _ => arg, }) @@ -337,6 +326,20 @@ fn decode_config(config: &str) -> anyhow::Result { Ok(ConfigInput::Bytes { content }) } +fn resolve_config_path( + config: &Path, + directory: &Path, +) -> anyhow::Result { + if config == Path::new("-") { + return resolve_config_file(config); + } + if config.is_absolute() { + Ok(ConfigInput::from_file_path(config.to_path_buf())) + } else { + Ok(ConfigInput::from_file_path(directory.join(config))) + } +} + fn resolve_config_file(file: &Path) -> anyhow::Result { if file == Path::new("-") { let mut content = String::new(); diff --git a/clash-bin/tests/mihomo_config_test.rs b/clash-bin/tests/config_input_test.rs similarity index 97% rename from clash-bin/tests/mihomo_config_test.rs rename to clash-bin/tests/config_input_test.rs index 4fc9bff65..44efa64fb 100644 --- a/clash-bin/tests/mihomo_config_test.rs +++ b/clash-bin/tests/config_input_test.rs @@ -51,7 +51,7 @@ fn path_display(path: &Path) -> String { } #[test] -fn file_success_outputs_mihomo_summary_on_stdout() { +fn file_success_outputs_summary_on_stdout() { let temp = tempdir().expect("create temp dir"); let config = temp.path().join("ok.yaml"); fs::write(&config, VALID_CONFIG).expect("write config"); @@ -234,7 +234,7 @@ fn base64_decode_failure_is_stdout_fatal() { } #[test] -fn missing_file_mode_creates_mihomo_default_config() { +fn missing_file_mode_creates_default_config() { let temp = tempdir().expect("create temp dir"); let config = temp.path().join("home").join("config.yaml"); @@ -265,7 +265,7 @@ fn missing_file_mode_creates_mihomo_default_config() { } #[test] -fn empty_file_reports_mihomo_empty_file_error() { +fn empty_file_reports_empty_file_error() { let temp = tempdir().expect("create temp dir"); let config = temp.path().join("empty.yaml"); fs::write(&config, "").expect("write empty config"); diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index c7f6de560..61cf041e1 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -771,7 +771,7 @@ mod proxy_provider_tests { } #[test] - fn missing_health_check_uses_mihomo_defaults() { + fn missing_health_check_uses_default_values() { let provider = OutboundProxyProviderDef::try_from(HashMap::from([ ("name".to_owned(), value("provider")), ("type".to_owned(), value("file")), From ab9a96f57b36ac477da67b78ce86b7ad5a24a2c4 Mon Sep 17 00:00:00 2001 From: dev0 Date: Mon, 27 Apr 2026 18:07:48 -0700 Subject: [PATCH 7/7] fix(tests): skip config_input_test in cross env, fix Windows path in log In cross-compilation test runs (arm, aarch64, riscv, etc.) the clash-rs binary cannot be spawned as a child process from within a QEMU-emulated test runner -- glibc falls back to execing the foreign-arch ELF through /bin/sh, which exits 127. All nine config_input_test tests failed with exit code 127 or empty stdout. Fix: add a OnceLock-cached binary_can_be_spawned() probe that runs the binary with -v and checks for exit code 127. Each test calls skip_on_cross!() at the top to bail out early in such environments. On Windows, escape_logrus_text() doubles backslashes in log lines, so 'configuration file C:\Users\...' appears in stdout but the test was checking for 'C:\...' with single backslashes, causing empty_file_reports_empty_file_error to fail. Fix: restructure that test to check lines individually, mirroring file_failure_logs_error_then_summary_on_stdout: verify the first line contains 'is empty' (no path comparison) and check the unescaped summary line for the exact path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clash-bin/tests/config_input_test.rs | 66 ++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/clash-bin/tests/config_input_test.rs b/clash-bin/tests/config_input_test.rs index 44efa64fb..60e4bf0a8 100644 --- a/clash-bin/tests/config_input_test.rs +++ b/clash-bin/tests/config_input_test.rs @@ -4,6 +4,7 @@ use std::{ io::Write, path::Path, process::{Command, Output, Stdio}, + sync::OnceLock, }; use tempfile::tempdir; @@ -13,6 +14,42 @@ mode: global bind-address: "0.0.0.0" "#; +/// Returns `true` when the `clash-rs` binary can actually be executed as a +/// subprocess in the current environment. +/// +/// In cross-compilation test runs (e.g. `cross` + QEMU), the test binary +/// itself runs inside an emulator, but `execve` of a child binary for a +/// foreign architecture is not intercepted by the emulator. glibc falls back +/// to running the binary through `/bin/sh`, which exits with code 127 +/// ("cannot execute binary file"). Any test that spawns the binary must skip +/// itself in such environments. +fn binary_can_be_spawned() -> bool { + static RESULT: OnceLock = OnceLock::new(); + *RESULT.get_or_init(|| { + std::process::Command::new(env!("CARGO_BIN_EXE_clash-rs")) + .arg("-v") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.code() != Some(127)) + .unwrap_or(false) + }) +} + +/// Skip the calling test when running inside a cross-compilation environment +/// where the binary cannot be spawned as a subprocess. +macro_rules! skip_on_cross { + () => { + if !binary_can_be_spawned() { + eprintln!( + "SKIP: clash-rs binary cannot be executed as a subprocess \ + (cross-compilation / QEMU environment)" + ); + return; + } + }; +} + fn clash_cmd() -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_clash-rs")); command @@ -52,6 +89,7 @@ fn path_display(path: &Path) -> String { #[test] fn file_success_outputs_summary_on_stdout() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("ok.yaml"); fs::write(&config, VALID_CONFIG).expect("write config"); @@ -77,6 +115,7 @@ fn file_success_outputs_summary_on_stdout() { #[test] fn file_success_accepts_go_style_equals_flag() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("ok.yaml"); fs::write(&config, VALID_CONFIG).expect("write config"); @@ -105,6 +144,7 @@ fn file_success_accepts_go_style_equals_flag() { #[test] fn file_failure_logs_error_then_summary_on_stdout() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("invalid.yaml"); fs::write(&config, "log-level: definitely-not-a-level\n").expect("write config"); @@ -133,6 +173,7 @@ fn file_failure_logs_error_then_summary_on_stdout() { #[test] fn proxy_provider_without_health_check_is_accepted() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("provider.yaml"); fs::write( @@ -170,6 +211,7 @@ proxy-providers: #[test] fn stdin_success_uses_bytes_summary_and_does_not_create_default_config() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let mut command = clash_cmd(); command.current_dir(temp.path()).args(["-t", "-f", "-"]); @@ -192,6 +234,7 @@ fn stdin_success_uses_bytes_summary_and_does_not_create_default_config() { #[test] fn base64_config_success_uses_bytes_summary() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let encoded = STANDARD.encode(VALID_CONFIG); @@ -217,6 +260,7 @@ fn base64_config_success_uses_bytes_summary() { #[test] fn base64_decode_failure_is_stdout_fatal() { + skip_on_cross!(); let output = clash_cmd() .args(["-t", "-config", "not@base64"]) .output() @@ -235,6 +279,7 @@ fn base64_decode_failure_is_stdout_fatal() { #[test] fn missing_file_mode_creates_default_config() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("home").join("config.yaml"); @@ -266,6 +311,7 @@ fn missing_file_mode_creates_default_config() { #[test] fn empty_file_reports_empty_file_error() { + skip_on_cross!(); let temp = tempdir().expect("create temp dir"); let config = temp.path().join("empty.yaml"); fs::write(&config, "").expect("write empty config"); @@ -281,15 +327,19 @@ fn empty_file_reports_empty_file_error() { assert_eq!(stderr(&output), ""); let stdout = stdout(&output); + let lines = stdout.lines().collect::>(); + // The first line is a log entry; `escape_logrus_text` escapes path + // separators on Windows, so we only check for the "is empty" substring + // rather than the full path to stay cross-platform. assert!( - stdout.contains(&format!( - "configuration file {} is empty", - path_display(&config) - )), + lines.first().is_some_and( + |line| line.starts_with("time=\"") && line.contains("is empty") + ), "stdout:\n{stdout}" ); - assert!(stdout.ends_with(&format!( - "configuration file {} test failed\n", - path_display(&config) - ))); + // The summary line is printed via `println!` without any escaping, so + // comparing the full path here is safe on all platforms. + let summary = + format!("configuration file {} test failed", path_display(&config)); + assert_eq!(lines.last().copied(), Some(summary.as_str())); }