From ac4efcd0a0e9791140f4fba053ae584ca19fc65c Mon Sep 17 00:00:00 2001 From: overtrue Date: Sat, 23 May 2026 11:49:47 +0800 Subject: [PATCH 1/3] feat: support mTLS and anonymous aliases --- crates/cli/src/commands/alias.rs | 94 +++++++++++++++++++++--- crates/cli/tests/error_contract.rs | 111 +++++++++++++++++++++++++++++ crates/cli/tests/help_contract.rs | 12 +++- crates/core/src/alias.rs | 19 ++++- crates/core/src/config.rs | 3 + crates/s3/src/admin.rs | 25 +++++++ crates/s3/src/client.rs | 89 +++++++++++++++++------ 7 files changed, 318 insertions(+), 35 deletions(-) diff --git a/crates/cli/src/commands/alias.rs b/crates/cli/src/commands/alias.rs index d037f53..6f8c096 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -32,11 +32,23 @@ pub struct SetArgs { /// S3 endpoint URL (e.g., `http://localhost:9000`, `https://s3.amazonaws.com`) pub endpoint: String, - /// Access key ID - pub access_key: String, + /// Access key ID (omit with --anonymous) + pub access_key: Option, - /// Secret access key - pub secret_key: String, + /// Secret access key (omit with --anonymous) + pub secret_key: Option, + + /// Send requests without SigV4 credentials + #[arg(long, default_value = "false")] + pub anonymous: bool, + + /// Path to PEM client certificate for mTLS + #[arg(long)] + pub client_cert: Option, + + /// Path to PEM client private key for mTLS + #[arg(long)] + pub client_key: Option, /// AWS region (default: us-east-1) #[arg(long, default_value = "us-east-1")] @@ -83,6 +95,8 @@ struct AliasInfo { endpoint: String, region: String, bucket_lookup: String, + auth_mode: String, + mtls: bool, } impl From<&Alias> for AliasInfo { @@ -92,6 +106,13 @@ impl From<&Alias> for AliasInfo { endpoint: alias.endpoint.clone(), region: alias.region.clone(), bucket_lookup: alias.bucket_lookup.clone(), + auth_mode: if alias.anonymous { + "anonymous" + } else { + "sigv4" + } + .to_string(), + mtls: alias.client_cert.is_some() && alias.client_key.is_some(), } } } @@ -136,6 +157,36 @@ async fn execute_set(args: SetArgs, manager: &AliasManager, formatter: &Formatte return formatter.fail(ExitCode::UsageError, &alias_endpoint_error_message(e)); } + if args.client_cert.is_some() != args.client_key.is_some() { + return formatter.fail( + ExitCode::UsageError, + "--client-cert and --client-key must be supplied together", + ); + } + + let has_access_key = args + .access_key + .as_ref() + .is_some_and(|value| !value.is_empty()); + let has_secret_key = args + .secret_key + .as_ref() + .is_some_and(|value| !value.is_empty()); + + if args.anonymous && (has_access_key || has_secret_key) { + return formatter.fail( + ExitCode::UsageError, + "Anonymous aliases must not include access key or secret key credentials", + ); + } + + if !args.anonymous && (!has_access_key || !has_secret_key) { + return formatter.fail( + ExitCode::UsageError, + "Access key and secret key are required unless --anonymous is set", + ); + } + // Validate signature version if args.signature != "v4" && args.signature != "v2" { return formatter.fail(ExitCode::UsageError, "Signature must be 'v4' or 'v2'"); @@ -153,9 +204,12 @@ async fn execute_set(args: SetArgs, manager: &AliasManager, formatter: &Formatte let mut alias = Alias::new( &args.name, &args.endpoint, - &args.access_key, - &args.secret_key, + args.access_key.as_deref().unwrap_or_default(), + args.secret_key.as_deref().unwrap_or_default(), ); + alias.anonymous = args.anonymous; + alias.client_cert = args.client_cert; + alias.client_key = args.client_key; alias.region = args.region; alias.signature = args.signature; alias.bucket_lookup = args.bucket_lookup; @@ -202,8 +256,20 @@ async fn execute_list(args: ListArgs, manager: &AliasManager, formatter: &Format let styled_url = formatter.style_url(&alias.endpoint); let styled_region = formatter.style_date(&alias.region); let styled_lookup = formatter.style_date(&alias.bucket_lookup); + let styled_auth = formatter.style_date(if alias.anonymous { + "anonymous" + } else { + "sigv4" + }); + let styled_mtls = formatter.style_date( + if alias.client_cert.is_some() && alias.client_key.is_some() { + "enabled" + } else { + "disabled" + }, + ); formatter.println(&format!( - "{styled_name} {styled_url} (region: {styled_region}, lookup: {styled_lookup})" + "{styled_name} {styled_url} (region: {styled_region}, lookup: {styled_lookup}, auth: {styled_auth}, mtls: {styled_mtls})" )); } } else { @@ -277,8 +343,11 @@ mod tests { let args = SetArgs { name: "test".to_string(), endpoint: "http://localhost:9000".to_string(), - access_key: "accesskey".to_string(), - secret_key: "secretkey".to_string(), + access_key: Some("accesskey".to_string()), + secret_key: Some("secretkey".to_string()), + anonymous: false, + client_cert: None, + client_key: None, region: "us-east-1".to_string(), signature: "v4".to_string(), bucket_lookup: "auto".to_string(), @@ -311,8 +380,11 @@ mod tests { 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(), + access_key: Some("accesskey".to_string()), + secret_key: Some("secretkey".to_string()), + anonymous: false, + client_cert: None, + client_key: None, region: "us-east-1".to_string(), signature: "v4".to_string(), bucket_lookup: "auto".to_string(), diff --git a/crates/cli/tests/error_contract.rs b/crates/cli/tests/error_contract.rs index fcb9597..32bb0f2 100644 --- a/crates/cli/tests/error_contract.rs +++ b/crates/cli/tests/error_contract.rs @@ -202,3 +202,114 @@ fn alias_set_json_error_rejects_invalid_signature() { 0 ); } + +#[test] +fn alias_set_anonymous_accepts_missing_credentials_and_lists_auth_mode() { + let config_dir = tempfile::tempdir().expect("create temp config dir"); + + let output = run_rc_with_config( + &[ + "alias", + "set", + "public", + "https://public.example.com", + "--anonymous", + "--client-cert", + "/tmp/client.pem", + "--client-key", + "/tmp/client.key", + "--json", + ], + config_dir.path(), + ); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let list_output = run_rc_with_config(&["alias", "list", "--json"], config_dir.path()); + assert!( + list_output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&list_output.stderr) + ); + + let stdout = String::from_utf8(list_output.stdout).expect("stdout should be UTF-8"); + let payload: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON"); + let alias = &payload["aliases"][0]; + assert_eq!(alias["name"], "public"); + assert_eq!(alias["auth_mode"], "anonymous"); + assert_eq!(alias["mtls"], true); +} + +#[test] +fn alias_set_anonymous_rejects_supplied_credentials() { + let config_dir = tempfile::tempdir().expect("create temp config dir"); + + let output = run_rc_with_config( + &[ + "alias", + "set", + "bad", + "https://public.example.com", + "accesskey", + "secretkey", + "--anonymous", + "--json", + ], + config_dir.path(), + ); + + 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"); + let json: serde_json::Value = serde_json::from_str(&stderr).expect("stderr is valid JSON"); + assert_eq!( + json["error"], + "Anonymous aliases must not include access key or secret key credentials" + ); +} + +#[test] +fn alias_set_rejects_partial_client_identity() { + let config_dir = tempfile::tempdir().expect("create temp config dir"); + + let output = run_rc_with_config( + &[ + "alias", + "set", + "bad", + "https://public.example.com", + "accesskey", + "secretkey", + "--client-cert", + "/tmp/client.pem", + "--json", + ], + config_dir.path(), + ); + + 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"); + let json: serde_json::Value = serde_json::from_str(&stderr).expect("stderr is valid JSON"); + assert_eq!( + json["error"], + "--client-cert and --client-key must be supplied together" + ); +} diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs index b51fa3a..a19da83 100644 --- a/crates/cli/tests/help_contract.rs +++ b/crates/cli/tests/help_contract.rs @@ -399,8 +399,16 @@ fn nested_subcommand_help_contract() { let cases = [ HelpCase { args: &["alias", "set"], - usage: "Usage: rc alias set [OPTIONS] ", - expected_tokens: &["--region", "--signature", "--bucket-lookup", "--insecure"], + usage: "Usage: rc alias set [OPTIONS] [ACCESS_KEY] [SECRET_KEY]", + expected_tokens: &[ + "--anonymous", + "--client-cert", + "--client-key", + "--region", + "--signature", + "--bucket-lookup", + "--insecure", + ], }, HelpCase { args: &["alias", "list"], diff --git a/crates/core/src/alias.rs b/crates/core/src/alias.rs index 7ee194b..ebaabf2 100644 --- a/crates/core/src/alias.rs +++ b/crates/core/src/alias.rs @@ -164,12 +164,24 @@ pub struct Alias { /// S3 endpoint URL pub endpoint: String, - /// Access key ID + /// Access key ID. Empty when anonymous authentication is enabled. pub access_key: String, - /// Secret access key + /// Secret access key. Empty when anonymous authentication is enabled. pub secret_key: String, + /// Send requests without SigV4 authentication. + #[serde(default)] + pub anonymous: bool, + + /// Path to PEM client certificate for mTLS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_cert: Option, + + /// Path to PEM client private key for mTLS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_key: Option, + /// AWS region #[serde(default = "default_region")] pub region: String, @@ -244,6 +256,9 @@ impl Alias { endpoint: endpoint.into(), access_key: access_key.into(), secret_key: secret_key.into(), + anonymous: false, + client_cert: None, + client_key: None, region: default_region(), signature: default_signature(), bucket_lookup: default_bucket_lookup(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index f7d2d4e..7950fdb 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -231,6 +231,9 @@ mod tests { endpoint: "http://localhost:9000".to_string(), access_key: "accesskey".to_string(), secret_key: "secretkey".to_string(), + anonymous: false, + client_cert: None, + client_key: None, region: "us-east-1".to_string(), signature: "v4".to_string(), bucket_lookup: "auto".to_string(), diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index 4cfeed8..73a01ed 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -30,6 +30,7 @@ pub struct AdminClient { access_key: String, secret_key: String, region: String, + anonymous: bool, } impl AdminClient { @@ -56,6 +57,25 @@ impl AdminClient { } } + if let (Some(cert_path), Some(key_path)) = + (alias.client_cert.as_deref(), alias.client_key.as_deref()) + { + let mut identity_pem = std::fs::read(cert_path).map_err(|e| { + Error::Network(format!( + "Failed to read client certificate '{cert_path}': {e}" + )) + })?; + let key_pem = std::fs::read(key_path).map_err(|e| { + Error::Network(format!("Failed to read client key '{key_path}': {e}")) + })?; + identity_pem.extend_from_slice(b"\n"); + identity_pem.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&identity_pem).map_err(|e| { + Error::Network(format!("Invalid client certificate/key identity: {e}")) + })?; + builder = builder.use_rustls_tls().identity(identity); + } + let http_client = builder .build() .map_err(|e| Error::Network(format!("Failed to create HTTP client: {e}")))?; @@ -66,6 +86,7 @@ impl AdminClient { access_key: alias.access_key.clone(), secret_key: alias.secret_key.clone(), region: alias.region.clone(), + anonymous: alias.anonymous, }) } @@ -89,6 +110,10 @@ impl AdminClient { headers: &HeaderMap, body: &[u8], ) -> Result { + if self.anonymous { + return Ok(headers.clone()); + } + let credentials = Credentials::new( &self.access_key, &self.secret_key, diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs index 7ed1c09..367a68b 100644 --- a/crates/s3/src/client.rs +++ b/crates/s3/src/client.rs @@ -60,13 +60,23 @@ struct ReqwestConnector { } impl ReqwestConnector { - async fn new(insecure: bool, ca_bundle: Option<&str>) -> Result { - let client = build_reqwest_client(insecure, ca_bundle).await?; + async fn new( + insecure: bool, + ca_bundle: Option<&str>, + client_cert: Option<&str>, + client_key: Option<&str>, + ) -> Result { + let client = build_reqwest_client(insecure, ca_bundle, client_cert, client_key).await?; Ok(Self { client }) } } -async fn build_reqwest_client(insecure: bool, ca_bundle: Option<&str>) -> Result { +async fn build_reqwest_client( + insecure: bool, + ca_bundle: Option<&str>, + client_cert: Option<&str>, + client_key: Option<&str>, +) -> Result { // NOTE: When `insecure = true`, `danger_accept_invalid_certs` disables all TLS // certificate verification. Any CA bundle provided will still be added to the // trust store but is rendered ineffective for this connection. @@ -82,6 +92,25 @@ async fn build_reqwest_client(insecure: bool, ca_bundle: Option<&str>) -> Result builder = builder.add_root_certificate(cert); } + if let (Some(cert_path), Some(key_path)) = (client_cert, client_key) { + let mut identity_pem = tokio::fs::read(cert_path).await.map_err(|e| { + Error::Network(format!( + "Failed to read client certificate '{cert_path}': {e}" + )) + })?; + let key_pem = tokio::fs::read(key_path) + .await + .map_err(|e| Error::Network(format!("Failed to read client key '{key_path}': {e}")))?; + identity_pem.extend_from_slice( + b" +", + ); + identity_pem.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&identity_pem) + .map_err(|e| Error::Network(format!("Invalid client certificate/key identity: {e}")))?; + builder = builder.use_rustls_tls().identity(identity); + } + let client = builder .build() .map_err(|e| Error::Network(format!("Failed to build HTTP client: {e}")))?; @@ -724,31 +753,47 @@ impl S3Client { let access_key = alias.access_key.clone(); let secret_key = alias.secret_key.clone(); - // Build credentials provider - let credentials = aws_credential_types::Credentials::new( - access_key, - secret_key, - None, // session token - None, // expiry - "rc-static-credentials", - ); - // Build SDK config loader let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .credentials_provider(credentials) .region(aws_config::Region::new(region)) .endpoint_url(&endpoint); + if alias.anonymous { + config_loader = config_loader.no_credentials(); + } else { + let credentials = aws_credential_types::Credentials::new( + access_key, + secret_key, + None, // session token + None, // expiry + "rc-static-credentials", + ); + config_loader = config_loader.credentials_provider(credentials); + } + // When insecure mode is enabled or a custom CA bundle is provided, use the reqwest // connector which supports danger_accept_invalid_certs and custom root certificates. - if alias.insecure || alias.ca_bundle.is_some() { - let connector = - ReqwestConnector::new(alias.insecure, alias.ca_bundle.as_deref()).await?; + if alias.insecure + || alias.ca_bundle.is_some() + || (alias.client_cert.is_some() && alias.client_key.is_some()) + { + let connector = ReqwestConnector::new( + alias.insecure, + alias.ca_bundle.as_deref(), + alias.client_cert.as_deref(), + alias.client_key.as_deref(), + ) + .await?; config_loader = config_loader.http_client(connector); } - let xml_http_client = - build_reqwest_client(alias.insecure, alias.ca_bundle.as_deref()).await?; + let xml_http_client = build_reqwest_client( + alias.insecure, + alias.ca_bundle.as_deref(), + alias.client_cert.as_deref(), + alias.client_key.as_deref(), + ) + .await?; let config = config_loader.load().await; // Build S3 client with path-style addressing for compatibility @@ -1117,6 +1162,10 @@ impl S3Client { headers: &HeaderMap, body: &[u8], ) -> Result { + if self.alias.anonymous { + return Ok(headers.clone()); + } + let credentials = Credentials::new( &self.alias.access_key, &self.alias.secret_key, @@ -3604,7 +3653,7 @@ mod tests { #[tokio::test] async fn reqwest_connector_insecure_without_ca_bundle_succeeds() { // When insecure is true and no CA bundle is provided, the connector should be created. - let connector = ReqwestConnector::new(true, None).await; + let connector = ReqwestConnector::new(true, None, None, None).await; assert!( connector.is_ok(), "Expected insecure connector creation to succeed" @@ -3614,7 +3663,7 @@ mod tests { #[tokio::test] async fn reqwest_connector_invalid_ca_bundle_path_surfaces_error() { // Use an obviously invalid path (empty string) to trigger a read error. - let result = ReqwestConnector::new(false, Some("")).await; + let result = ReqwestConnector::new(false, Some(""), None, None).await; match result { Err(Error::Network(msg)) => { assert!( From 15b52cad340d2f68a9f04bfc0ccd33066a9aa0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=AD=A3=E8=B6=85?= Date: Sat, 23 May 2026 12:47:47 +0800 Subject: [PATCH 2/3] Potential fix for pull request finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: 安正超 --- crates/s3/src/client.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs index 367a68b..af0b001 100644 --- a/crates/s3/src/client.rs +++ b/crates/s3/src/client.rs @@ -101,10 +101,7 @@ async fn build_reqwest_client( let key_pem = tokio::fs::read(key_path) .await .map_err(|e| Error::Network(format!("Failed to read client key '{key_path}': {e}")))?; - identity_pem.extend_from_slice( - b" -", - ); + identity_pem.extend_from_slice(b"\n"); identity_pem.extend_from_slice(&key_pem); let identity = reqwest::Identity::from_pem(&identity_pem) .map_err(|e| Error::Network(format!("Invalid client certificate/key identity: {e}")))?; From 5609b9e73e10f183c149def136a8853b538c7b5b Mon Sep 17 00:00:00 2001 From: overtrue Date: Sat, 23 May 2026 13:23:41 +0800 Subject: [PATCH 3/3] fix(cli): update alias list golden snapshot --- .../golden__alias_tests__alias_list_with_aliases.snap | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/cli/tests/snapshots/golden__alias_tests__alias_list_with_aliases.snap b/crates/cli/tests/snapshots/golden__alias_tests__alias_list_with_aliases.snap index 47c713f..83fd3a7 100644 --- a/crates/cli/tests/snapshots/golden__alias_tests__alias_list_with_aliases.snap +++ b/crates/cli/tests/snapshots/golden__alias_tests__alias_list_with_aliases.snap @@ -1,18 +1,23 @@ --- source: crates/cli/tests/golden.rs +assertion_line: 144 expression: json --- { "aliases": [ { + "auth_mode": "sigv4", "bucket_lookup": "auto", "endpoint": "http://localhost:9000", + "mtls": false, "name": "local", "region": "us-east-1" }, { + "auth_mode": "sigv4", "bucket_lookup": "auto", "endpoint": "https://s3.amazonaws.com", + "mtls": false, "name": "s3", "region": "us-west-2" }