diff --git a/crates/cli/src/commands/alias.rs b/crates/cli/src/commands/alias.rs index 4ea3eea..f0ac42a 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -8,7 +8,7 @@ use serde::Serialize; use crate::exit_code::ExitCode; use crate::output::{Formatter, OutputConfig}; -use rc_core::{Alias, AliasManager}; +use rc_core::{Alias, AliasManager, validate_alias_endpoint}; /// Alias subcommands for managing storage service connections #[derive(Subcommand, Debug)] @@ -134,6 +134,11 @@ async fn execute_set(args: SetArgs, manager: &AliasManager, formatter: &Formatte return ExitCode::UsageError; } + if let Err(e) = validate_alias_endpoint(&args.endpoint) { + formatter.error(&alias_endpoint_error_message(e)); + return ExitCode::UsageError; + } + // Validate signature version if args.signature != "v4" && args.signature != "v2" { formatter.error("Signature must be 'v4' or 'v2'"); @@ -250,6 +255,13 @@ async fn execute_remove( } } +fn alias_endpoint_error_message(error: rc_core::Error) -> String { + match error { + rc_core::Error::Config(message) => message, + other => format!("Invalid endpoint: {other}"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,4 +295,37 @@ mod tests { assert_eq!(info.endpoint, "http://localhost:9000"); assert_eq!(info.region, "us-east-1"); } + + #[tokio::test] + async fn test_execute_set_rejects_invalid_endpoint_url() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let manager = AliasManager::with_config_manager(rc_core::ConfigManager::with_path( + temp_dir.path().join("config.toml"), + )); + let formatter = Formatter::new(OutputConfig::default()); + let args = SetArgs { + name: "rustfs".to_string(), + endpoint: "http://rustfs-node{1...32}:9000".to_string(), + access_key: "accesskey".to_string(), + secret_key: "secretkey".to_string(), + region: "us-east-1".to_string(), + signature: "v4".to_string(), + bucket_lookup: "auto".to_string(), + insecure: false, + }; + + let exit_code = execute_set(args, &manager, &formatter).await; + + assert_eq!(exit_code, ExitCode::UsageError); + assert!(manager.get("rustfs").is_err()); + } + + #[test] + fn test_alias_endpoint_error_message_omits_config_prefix() { + let message = alias_endpoint_error_message(rc_core::Error::Config( + "Endpoint must include a host".to_string(), + )); + + assert_eq!(message, "Endpoint must include a host"); + } } diff --git a/crates/core/src/alias.rs b/crates/core/src/alias.rs index 9d7fa25..b2b4d42 100644 --- a/crates/core/src/alias.rs +++ b/crates/core/src/alias.rs @@ -124,6 +124,26 @@ pub struct Alias { pub timeout: Option, } +/// Validate that an alias endpoint is a usable HTTP(S) URL. +pub fn validate_alias_endpoint(value: &str) -> Result<()> { + if value.contains('{') || value.contains('}') { + return Err(Error::Config( + "Endpoint must be a single S3 service URL; RustFS volume expansion patterns are not supported".into(), + )); + } + + let url = Url::parse(value) + .map_err(|e| Error::Config(format!("Endpoint must be a valid URL: {e}")))?; + + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::Config( + "Endpoint must not include credentials; pass access key and secret key as separate arguments".into(), + )); + } + + validate_http_endpoint_url(&url, "Endpoint") +} + fn default_region() -> String { "us-east-1".to_string() } @@ -237,15 +257,7 @@ fn parse_env_alias(name: &str, value: &str) -> Result { let mut url = Url::parse(value) .map_err(|e| Error::Config(format!("{var_name} must be a valid URL: {e}")))?; - if !matches!(url.scheme(), "http" | "https") { - return Err(Error::Config(format!( - "{var_name} must use an http or https URL" - ))); - } - - if url.host_str().is_none() { - return Err(Error::Config(format!("{var_name} must include a host"))); - } + validate_http_endpoint_url(&url, &var_name)?; let access_key = url.username(); let Some(secret_key) = url.password() else { @@ -274,6 +286,20 @@ fn parse_env_alias(name: &str, value: &str) -> Result { Ok(Alias::new(name, endpoint, access_key, secret_key)) } +fn validate_http_endpoint_url(url: &Url, label: &str) -> Result<()> { + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::Config(format!( + "{label} must use an http or https URL" + ))); + } + + if url.host_str().is_none() { + return Err(Error::Config(format!("{label} must include a host"))); + } + + Ok(()) +} + fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result { urlencoding::decode(value) .map(|decoded| decoded.into_owned()) @@ -478,6 +504,64 @@ mod tests { assert_eq!(alias.bucket_lookup, "auto"); } + #[test] + fn test_validate_alias_endpoint_rejects_volume_expansion_endpoint() { + let result = validate_alias_endpoint("http://rustfs-node{1...32}:9000"); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("RustFS volume expansion patterns are not supported") + ); + } + + #[test] + fn test_validate_alias_endpoint_rejects_missing_scheme() { + let result = validate_alias_endpoint("localhost:9000"); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Endpoint must use an http or https URL") + ); + } + + #[test] + fn test_validate_alias_endpoint_rejects_non_http_scheme() { + let result = validate_alias_endpoint("ftp://localhost:9000"); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Endpoint must use an http or https URL") + ); + } + + #[test] + fn test_validate_alias_endpoint_rejects_embedded_credentials() { + let result = validate_alias_endpoint("http://access:secret@localhost:9000"); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Endpoint must not include credentials") + ); + } + + #[test] + fn test_validate_alias_endpoint_accepts_http_url_with_host() { + validate_alias_endpoint("http://localhost:9000").unwrap(); + validate_alias_endpoint("https://s3.amazonaws.com").unwrap(); + } + #[test] fn test_parse_rc_host_alias_decodes_credentials() { let alias = diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5246622..c4f41f9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -21,7 +21,7 @@ pub mod retry; pub mod select; pub mod traits; -pub use alias::{Alias, AliasManager}; +pub use alias::{Alias, AliasManager, validate_alias_endpoint}; pub use config::{Config, ConfigManager}; pub use cors::{CorsConfiguration, CorsRule}; pub use error::{Error, Result};