Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions clash-bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -49,3 +51,6 @@ human-panic = "2.0"


aws-lc-rs = { version = "1.16", optional = true, default-features = false }

[dev-dependencies]
tempfile = "3"
275 changes: 236 additions & 39 deletions clash-bin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -35,10 +41,11 @@ 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<PathBuf>,
#[clap(long = "config-string", hide = true, value_name = "BASE64")]
config_string: Option<String>,
#[clap(
short = 't',
long,
Expand Down Expand Up @@ -93,13 +100,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<String> = 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 {
Expand All @@ -111,43 +112,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_cli_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_cli_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_cli_log("error", &e.to_string());
print_test_failure(&config_input);
exit(1);
}
}
Expand All @@ -174,7 +160,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;

Expand Down Expand Up @@ -221,3 +207,214 @@ 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<Self> {
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);
}

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,
};

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 {
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<clash::ClashRuntimeConfig> {
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<Item = String>) -> Vec<String> {
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" => "--config-string".to_string(),
"-f" => "--config".to_string(),
_ if arg.starts_with("-config=") => {
arg.replacen("-config=", "--config-string=", 1)
}
_ if arg.starts_with("-f=") => arg.replacen("-f=", "--config=", 1),
_ if arg.starts_with("-f") && arg.len() > 2 => {
format!("--config={}", &arg[2..])
}
_ => arg,
})
.collect()
}

fn decode_config(config: &str) -> anyhow::Result<ConfigInput> {
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 })
Comment on lines +321 to +326
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd clash-bin && find . -name "main.rs" -type f

Repository: Watfaq/clash-rs

Length of output: 73


🏁 Script executed:

cat -n clash-bin/src/main.rs | sed -n '310,340p'

Repository: Watfaq/clash-rs

Length of output: 1254


🏁 Script executed:

# Let's check the full context of decode_config and how ConfigInput is used
rg -A 20 "fn decode_config" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 701


🏁 Script executed:

# Check how ConfigInput is used in the codebase
rg "ConfigInput" clash-bin/src/main.rs | head -20

Repository: Watfaq/clash-rs

Length of output: 932


🏁 Script executed:

# Check if there are any tests for decode_config
rg -B 5 -A 10 "decode_config\|ConfigInput" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Check ConfigInput enum definition to understand the structure better
rg -B 5 -A 15 "enum ConfigInput" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 653


🏁 Script executed:

# Check how STANDARD is defined (base64 standard)
rg "STANDARD" clash-bin/src/main.rs | head -10

Repository: Watfaq/clash-rs

Length of output: 143


🏁 Script executed:

# Look for any tests or usages of decode_config
rg -B 3 -A 5 "decode_config" clash-bin/src/ --type rs

Repository: Watfaq/clash-rs

Length of output: 86


🏁 Script executed:

# Check if there's documentation or examples showing what inputs are expected
rg -B 5 -A 5 "config-string\|config_string" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Check the full ConfigInput implementation to understand how the content is used
rg -A 30 "impl ConfigInput" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 1167


🏁 Script executed:

# Check if there are any validation or parsing steps after ConfigInput::Bytes is created
rg -B 5 -A 10 "config_input.load\|config_input\.load\|ConfigInput::" clash-bin/src/main.rs | head -40

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Look at how the config content is parsed after being read
rg -B 3 -A 3 "Bytes.*content" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 940


🏁 Script executed:

# Check if there's a reason for using lossy conversion by looking at git history or comments
rg -B 10 "from_utf8_lossy" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 367


🏁 Script executed:

# Check how clash::Config::Str handles the content parsing
rg -B 5 -A 5 "try_parse\|Config::Str" clash-bin/src/ | head -40

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Look for any documentation or comments about expected input format
rg -B 5 "config_string\|CLASH_CONFIG_STRING" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Check if there are integration tests or examples showing expected behavior
find clash-bin -name "*.rs" -type f | xargs rg -l "decode_config\|config_string" | head -5

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Check if there are comments in the codebase about UTF-8 handling
rg "utf.?8\|UTF.?8" clash-bin/src/main.rs

Repository: Watfaq/clash-rs

Length of output: 41


🏁 Script executed:

# Verify the base64 crate version to understand decode behavior
cat clash-bin/Cargo.toml | grep -A 5 "base64"

Repository: Watfaq/clash-rs

Length of output: 265


Reject non-UTF-8 base64 configs instead of silently repairing them.

String::from_utf8_lossy replaces invalid bytes with U+FFFD, so the parsed YAML can differ from the caller's payload. This should fail fast with a UTF-8 error instead of continuing with modified content.

🩹 Proposed fix
 fn decode_config(config: &str) -> anyhow::Result<ConfigInput> {
     let content = STANDARD
         .decode(config)
         .map_err(|err| anyhow!("decode config: {err}"))?;
-    let content = String::from_utf8_lossy(&content).into_owned();
+    let content =
+        String::from_utf8(content).map_err(|err| anyhow!("decode config as UTF-8: {err}"))?;
     Ok(ConfigInput::Bytes { content })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-bin/src/main.rs` around lines 321 - 326, The function decode_config
currently uses String::from_utf8_lossy which silently replaces invalid UTF-8
bytes; change decode_config to validate UTF-8 strictly (e.g., use
String::from_utf8 or std::str::from_utf8 on the decoded byte Vec) and return an
error via anyhow! when UTF-8 validation fails so that ConfigInput::Bytes {
content } only receives valid UTF-8 content and invalid base64 payloads are
rejected instead of being repaired.

}

fn resolve_config_path(
config: &Path,
directory: &Path,
) -> anyhow::Result<ConfigInput> {
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<ConfigInput> {
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()))
Comment on lines +329 to +356
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the configured home directory when -f - receives no stdin.

When stdin is empty, the fallback path becomes plain config.yaml, so --directory <dir> -f - (and CLASH_HOME_DIR with CLASH_CONFIG_FILE=-) creates the default file in the process cwd instead of the resolved config directory.

🩹 Proposed fix
 fn resolve_config_path(
     config: &Path,
     directory: &Path,
 ) -> anyhow::Result<ConfigInput> {
     if config == Path::new("-") {
-        return resolve_config_file(config);
+        return resolve_config_file(config, Some(directory));
     }
     if config.is_absolute() {
         Ok(ConfigInput::from_file_path(config.to_path_buf()))
     } else {
         Ok(ConfigInput::from_file_path(directory.join(config)))
@@
         if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") {
-            return resolve_config_file(&PathBuf::from(file));
+            return resolve_config_file(&PathBuf::from(file), Some(&directory));
         }
@@
-fn resolve_config_file(file: &Path) -> anyhow::Result<ConfigInput> {
+fn resolve_config_file(
+    file: &Path,
+    directory: Option<&Path>,
+) -> anyhow::Result<ConfigInput> {
     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,
-        )));
+        let fallback = directory
+            .map(|dir| dir.join(DEFAULT_CONFIG_FILE))
+            .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_FILE));
+        return Ok(ConfigInput::from_file_path(fallback));
     }
     Ok(ConfigInput::from_file_path(file.to_path_buf()))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-bin/src/main.rs` around lines 329 - 356, The fallback for "-f -"
currently returns DEFAULT_CONFIG_FILE in the process CWD; change
resolve_config_file and its caller so the configured home directory is
preserved: update resolve_config_file to accept an extra directory: &Path
parameter and, when stdin is empty, return
ConfigInput::from_file_path(directory.join(DEFAULT_CONFIG_FILE)) instead of
PathBuf::from(DEFAULT_CONFIG_FILE); then update resolve_config_path to call
resolve_config_file(config, directory) when config == Path::new("-"). Use the
existing symbols resolve_config_path, resolve_config_file, DEFAULT_CONFIG_FILE,
and ConfigInput::from_file_path to locate and implement the changes.

}

fn absolutize(path: PathBuf) -> anyhow::Result<PathBuf> {
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_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]"
);
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
})
}
Loading
Loading