Skip to content
Merged
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
94 changes: 83 additions & 11 deletions crates/cli/src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Secret access key
pub secret_key: String,
/// Secret access key (omit with --anonymous)
pub secret_key: Option<String>,

/// 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<String>,

/// Path to PEM client private key for mTLS
#[arg(long)]
pub client_key: Option<String>,

/// AWS region (default: us-east-1)
#[arg(long, default_value = "us-east-1")]
Expand Down Expand Up @@ -83,6 +95,8 @@ struct AliasInfo {
endpoint: String,
region: String,
bucket_lookup: String,
auth_mode: String,
mtls: bool,
}

impl From<&Alias> for AliasInfo {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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'");
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
111 changes: 111 additions & 0 deletions crates/cli/tests/error_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
12 changes: 10 additions & 2 deletions crates/cli/tests/help_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,16 @@ fn nested_subcommand_help_contract() {
let cases = [
HelpCase {
args: &["alias", "set"],
usage: "Usage: rc alias set [OPTIONS] <NAME> <ENDPOINT> <ACCESS_KEY> <SECRET_KEY>",
expected_tokens: &["--region", "--signature", "--bucket-lookup", "--insecure"],
usage: "Usage: rc alias set [OPTIONS] <NAME> <ENDPOINT> [ACCESS_KEY] [SECRET_KEY]",
expected_tokens: &[
"--anonymous",
"--client-cert",
"--client-key",
"--region",
"--signature",
"--bucket-lookup",
"--insecure",
],
},
HelpCase {
args: &["alias", "list"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
19 changes: 17 additions & 2 deletions crates/core/src/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Path to PEM client private key for mTLS.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_key: Option<String>,

/// AWS region
#[serde(default = "default_region")]
pub region: String,
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading