diff --git a/crates/cli/src/commands/alias.rs b/crates/cli/src/commands/alias.rs index f0ac42a..dd5342d 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -180,8 +180,9 @@ async fn execute_set(args: SetArgs, manager: &AliasManager, formatter: &Formatte ExitCode::Success } Err(e) => { - formatter.error(&e.to_string()); - ExitCode::GeneralError + let code = exit_code_from_error(&e); + formatter.error_with_code(code, &e.to_string()); + code } } } @@ -218,8 +219,9 @@ async fn execute_list(args: ListArgs, manager: &AliasManager, formatter: &Format ExitCode::Success } Err(e) => { - formatter.error(&e.to_string()); - ExitCode::GeneralError + let code = exit_code_from_error(&e); + formatter.error_with_code(code, &e.to_string()); + code } } } @@ -249,8 +251,9 @@ async fn execute_remove( ExitCode::NotFound } Err(e) => { - formatter.error(&e.to_string()); - ExitCode::GeneralError + let code = exit_code_from_error(&e); + formatter.error_with_code(code, &e.to_string()); + code } } } @@ -262,6 +265,10 @@ fn alias_endpoint_error_message(error: rc_core::Error) -> String { } } +fn exit_code_from_error(error: &rc_core::Error) -> ExitCode { + ExitCode::from_i32(error.exit_code()).unwrap_or(ExitCode::GeneralError) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cli/src/output/formatter.rs b/crates/cli/src/output/formatter.rs index 93820ce..3b4a740 100644 --- a/crates/cli/src/output/formatter.rs +++ b/crates/cli/src/output/formatter.rs @@ -133,6 +133,8 @@ fn infer_error_metadata(message: &str) -> (&'static str, bool, Option) { if normalized.contains("invalid") || normalized.contains("cannot be empty") || normalized.contains("must be") + || normalized.contains("must use") + || normalized.contains("must include") || normalized.contains("must specify") || normalized.contains("expected:") || normalized.contains("use -r/--recursive") diff --git a/crates/cli/tests/env_alias.rs b/crates/cli/tests/env_alias.rs index 9d6c0c0..142036f 100644 --- a/crates/cli/tests/env_alias.rs +++ b/crates/cli/tests/env_alias.rs @@ -94,3 +94,79 @@ fn ls_resolves_rc_host_alias_from_environment() { ); assert_eq!(payload["details"]["type"], "network_error"); } + +#[test] +fn alias_list_rejects_invalid_rc_host_percent_encoding_without_credentials() { + let config_dir = tempfile::tempdir().expect("create config dir"); + + let output = Command::new(rc_binary()) + .args(["alias", "list", "--json"]) + .env("RC_CONFIG_DIR", config_dir.path()) + .env( + "RC_HOST_badalias", + "https://ACCESS_KEY:SECRET%ZZ@rustfs.local:9000", + ) + .output() + .expect("run rc command"); + + assert_eq!( + output.status.code(), + Some(2), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(!stderr.contains("ACCESS_KEY")); + assert!(!stderr.contains("SECRET")); + + let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output"); + assert!( + payload["error"] + .as_str() + .expect("error message") + .contains("invalid percent-encoding in secret key"), + "payload: {payload}" + ); + assert_eq!(payload["code"], 2); + assert_eq!(payload["details"]["type"], "usage_error"); +} + +#[test] +fn alias_list_rejects_invalid_rc_host_scheme_without_credentials() { + let config_dir = tempfile::tempdir().expect("create config dir"); + + let output = Command::new(rc_binary()) + .args(["alias", "list", "--json"]) + .env("RC_CONFIG_DIR", config_dir.path()) + .env( + "RC_HOST_badalias", + "ftp://ACCESS_KEY:SECRET_KEY@rustfs.local:9000", + ) + .output() + .expect("run rc command"); + + assert_eq!( + output.status.code(), + Some(2), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(!stderr.contains("ACCESS_KEY")); + assert!(!stderr.contains("SECRET_KEY")); + + let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output"); + assert!( + payload["error"] + .as_str() + .expect("error message") + .contains("must use an http or https URL"), + "payload: {payload}" + ); + assert_eq!(payload["code"], 2); + assert_eq!(payload["details"]["type"], "usage_error"); +} diff --git a/crates/core/src/alias.rs b/crates/core/src/alias.rs index b2b4d42..6749e7c 100644 --- a/crates/core/src/alias.rs +++ b/crates/core/src/alias.rs @@ -301,6 +301,12 @@ fn validate_http_endpoint_url(url: &Url, label: &str) -> Result<()> { } fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result { + if has_invalid_percent_encoding(value) { + return Err(Error::Config(format!( + "{var_name} contains invalid percent-encoding in {field}" + ))); + } + urlencoding::decode(value) .map(|decoded| decoded.into_owned()) .map_err(|e| { @@ -310,6 +316,29 @@ fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Resu }) } +fn has_invalid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] != b'%' { + index += 1; + continue; + } + + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return true; + } + + index += 3; + } + + false +} + fn merge_env_aliases(mut aliases: Vec, env_aliases: Vec) -> Vec { for env_alias in env_aliases { aliases.retain(|alias| alias.name != env_alias.name); @@ -579,6 +608,16 @@ mod tests { assert!(matches!(result.unwrap_err(), Error::Config(_))); } + #[test] + fn test_parse_rc_host_alias_rejects_invalid_percent_encoding() { + let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%ZZ@rustfs.local"); + + assert!(result.is_err()); + let error = result.unwrap_err().to_string(); + assert!(error.contains("invalid percent-encoding in secret key")); + assert!(!error.contains("SECRET")); + } + #[test] fn test_env_aliases_from_vars_filters_rc_host_prefix() { let aliases = env_aliases_from_vars(vec![