Skip to content

Commit e6d2e68

Browse files
committed
feat(requests): support custom amz headers
1 parent 6a6d6b6 commit e6d2e68

6 files changed

Lines changed: 221 additions & 8 deletions

File tree

crates/cli/src/commands/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use std::io::{IsTerminal, stderr, stdout};
88

99
use clap::{Parser, Subcommand, ValueEnum};
10+
use rc_core::{RequestHeader, set_global_request_headers};
1011

1112
use crate::exit_code::ExitCode;
1213
use crate::output::OutputConfig;
@@ -74,10 +75,18 @@ pub struct Cli {
7475
#[arg(long, global = true, default_value = "false")]
7576
pub debug: bool,
7677

78+
/// Add an x-amz-* request header to signed S3 requests
79+
#[arg(short = 'H', long = "header", global = true, value_parser = parse_request_header)]
80+
pub request_headers: Vec<RequestHeader>,
81+
7782
#[command(subcommand)]
7883
pub command: Commands,
7984
}
8085

86+
fn parse_request_header(value: &str) -> Result<RequestHeader, String> {
87+
RequestHeader::parse(value).map_err(|error| error.to_string())
88+
}
89+
8190
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
8291
pub enum OutputFormat {
8392
Auto,
@@ -247,6 +256,7 @@ pub enum Commands {
247256

248257
/// Execute the CLI command and return an exit code
249258
pub async fn execute(cli: Cli) -> ExitCode {
259+
set_global_request_headers(cli.request_headers.clone());
250260
let output_options = GlobalOutputOptions::from_cli(&cli);
251261

252262
match cli.command {
@@ -443,6 +453,35 @@ mod tests {
443453
assert_eq!(resolved.json, !std::io::stdout().is_terminal());
444454
}
445455

456+
#[test]
457+
fn cli_accepts_global_custom_amz_header() {
458+
let cli = Cli::try_parse_from([
459+
"rc",
460+
"-H",
461+
"x-amz-bucket-encrypt-enabled:1",
462+
"bucket",
463+
"list",
464+
"local/",
465+
])
466+
.expect("parse custom header");
467+
468+
assert_eq!(cli.request_headers.len(), 1);
469+
assert_eq!(cli.request_headers[0].name, "x-amz-bucket-encrypt-enabled");
470+
assert_eq!(cli.request_headers[0].value, "1");
471+
}
472+
473+
#[test]
474+
fn cli_rejects_non_amz_custom_header() {
475+
let error = Cli::try_parse_from(["rc", "-H", "authorization:secret", "ls", "local/"])
476+
.expect_err("non amz header should fail");
477+
478+
assert!(
479+
error
480+
.to_string()
481+
.contains("Only x-amz-* custom request headers are supported")
482+
);
483+
}
484+
446485
#[test]
447486
fn cli_accepts_bucket_cors_subcommand() {
448487
let cli = Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket"])

crates/cli/tests/help_contract.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const GLOBAL_OPTIONS: &[&str] = &[
1414
"--no-progress",
1515
"--quiet",
1616
"--debug",
17+
"--header",
1718
"--help",
1819
"--version",
1920
];

crates/core/src/alias.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! including connection details and credentials.
55
66
use std::env;
7+
use std::sync::{OnceLock, RwLock};
78

89
use serde::{Deserialize, Serialize};
910
use url::Url;
@@ -12,6 +13,80 @@ use crate::config::ConfigManager;
1213
use crate::error::{Error, Result};
1314

1415
const RC_HOST_PREFIX: &str = "RC_HOST_";
16+
const CUSTOM_HEADER_PREFIX: &str = "x-amz-";
17+
18+
static GLOBAL_REQUEST_HEADERS: OnceLock<RwLock<Vec<RequestHeader>>> = OnceLock::new();
19+
20+
/// Custom S3 request header applied to remote operations.
21+
#[derive(Debug, Clone, PartialEq, Eq)]
22+
pub struct RequestHeader {
23+
pub name: String,
24+
pub value: String,
25+
}
26+
27+
impl RequestHeader {
28+
pub fn parse(value: &str) -> Result<Self> {
29+
let (name, header_value) = value.split_once(':').ok_or_else(|| {
30+
Error::Config(
31+
"Header must use NAME:VALUE format, for example x-amz-meta-key:value".into(),
32+
)
33+
})?;
34+
35+
let name = name.trim().to_ascii_lowercase();
36+
let header_value = header_value.trim().to_string();
37+
38+
if name.is_empty() {
39+
return Err(Error::Config("Header name must not be empty".into()));
40+
}
41+
42+
if header_value.is_empty() {
43+
return Err(Error::Config("Header value must not be empty".into()));
44+
}
45+
46+
if !name.starts_with(CUSTOM_HEADER_PREFIX) {
47+
return Err(Error::Config(
48+
"Only x-amz-* custom request headers are supported".into(),
49+
));
50+
}
51+
52+
if !name
53+
.bytes()
54+
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
55+
{
56+
return Err(Error::Config(format!("Invalid header name '{name}'")));
57+
}
58+
59+
if !header_value.is_ascii() || header_value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
60+
return Err(Error::Config(format!("Invalid value for header '{name}'")));
61+
}
62+
63+
Ok(Self {
64+
name,
65+
value: header_value,
66+
})
67+
}
68+
}
69+
70+
/// Set process-wide custom request headers for this CLI invocation.
71+
pub fn set_global_request_headers(headers: Vec<RequestHeader>) {
72+
let storage = GLOBAL_REQUEST_HEADERS.get_or_init(|| RwLock::new(Vec::new()));
73+
let mut guard = storage
74+
.write()
75+
.expect("global request header lock should not be poisoned");
76+
*guard = headers;
77+
}
78+
79+
/// Get process-wide custom request headers for this CLI invocation.
80+
pub fn global_request_headers() -> Vec<RequestHeader> {
81+
let Some(storage) = GLOBAL_REQUEST_HEADERS.get() else {
82+
return Vec::new();
83+
};
84+
85+
storage
86+
.read()
87+
.expect("global request header lock should not be poisoned")
88+
.clone()
89+
}
1590

1691
/// Retry configuration for an alias
1792
#[derive(Debug, Clone, Serialize, Deserialize)]

crates/core/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ pub mod retry;
2121
pub mod select;
2222
pub mod traits;
2323

24-
pub use alias::{Alias, AliasManager, validate_alias_endpoint};
24+
pub use alias::{
25+
Alias, AliasManager, RequestHeader, global_request_headers, set_global_request_headers,
26+
validate_alias_endpoint,
27+
};
2528
pub use config::{Config, ConfigManager};
2629
pub use cors::{CorsConfiguration, CorsRule};
2730
pub use error::{Error, Result};

crates/s3/src/client.rs

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,26 @@ use aws_sigv4::http_request::{
99
SignableBody, SignableRequest, SignatureLocation, SigningSettings, sign,
1010
};
1111
use aws_sigv4::sign::v4;
12+
use aws_smithy_runtime_api::box_error::BoxError;
1213
use aws_smithy_runtime_api::client::http::{
1314
HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector,
1415
};
16+
use aws_smithy_runtime_api::client::interceptors::Intercept;
17+
use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
1518
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
1619
use aws_smithy_runtime_api::client::result::ConnectorError;
1720
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
1821
use aws_smithy_runtime_api::http::{Response, StatusCode};
1922
use aws_smithy_types::body::SdkBody;
23+
use aws_smithy_types::config_bag::ConfigBag;
2024
use bytes::Bytes;
2125
use jiff::Timestamp;
2226
use quick_xml::de::from_str as from_xml_str;
2327
use rc_core::{
2428
Alias, BucketNotification, Capabilities, CorsRule, Error, LifecycleRule, ListOptions,
2529
ListResult, NotificationTarget, ObjectInfo, ObjectStore, ObjectVersion,
26-
ObjectVersionListResult, RemotePath, ReplicationConfiguration, Result, SelectOptions,
30+
ObjectVersionListResult, RemotePath, ReplicationConfiguration, RequestHeader, Result,
31+
SelectOptions, global_request_headers,
2732
};
2833
use reqwest::Method;
2934
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
@@ -673,6 +678,7 @@ pub struct S3Client {
673678
inner: aws_sdk_s3::Client,
674679
xml_http_client: reqwest::Client,
675680
alias: Alias,
681+
request_headers: Vec<RequestHeader>,
676682
}
677683

678684
/// Request-level options for delete operations.
@@ -682,6 +688,33 @@ pub struct DeleteRequestOptions {
682688
pub force_delete: bool,
683689
}
684690

691+
#[derive(Debug, Clone)]
692+
struct CustomHeaderInterceptor {
693+
headers: Vec<RequestHeader>,
694+
}
695+
696+
impl Intercept for CustomHeaderInterceptor {
697+
fn name(&self) -> &'static str {
698+
"CustomHeaderInterceptor"
699+
}
700+
701+
fn modify_before_signing(
702+
&self,
703+
context: &mut BeforeTransmitInterceptorContextMut<'_>,
704+
_runtime_components: &RuntimeComponents,
705+
_cfg: &mut ConfigBag,
706+
) -> std::result::Result<(), BoxError> {
707+
let request = context.request_mut();
708+
for header in &self.headers {
709+
request
710+
.headers_mut()
711+
.try_insert(header.name.clone(), header.value.clone())
712+
.map_err(|error| Box::new(error) as BoxError)?;
713+
}
714+
Ok(())
715+
}
716+
}
717+
685718
impl S3Client {
686719
/// Create a new S3 client from an alias configuration
687720
pub async fn new(alias: Alias) -> Result<Self> {
@@ -718,7 +751,8 @@ impl S3Client {
718751
let config = config_loader.load().await;
719752

720753
// Build S3 client with path-style addressing for compatibility
721-
let s3_config = aws_sdk_s3::config::Builder::from(&config)
754+
let request_headers = global_request_headers();
755+
let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&config)
722756
.force_path_style(force_path_style_for_alias(&alias))
723757
// Improve compatibility with S3-compatible backends by only sending request
724758
// checksums when the operation explicitly requires them.
@@ -727,15 +761,23 @@ impl S3Client {
727761
)
728762
.response_checksum_validation(
729763
aws_sdk_s3::config::ResponseChecksumValidation::WhenRequired,
730-
)
731-
.build();
764+
);
765+
766+
if !request_headers.is_empty() {
767+
s3_config_builder = s3_config_builder.interceptor(CustomHeaderInterceptor {
768+
headers: request_headers.clone(),
769+
});
770+
}
771+
772+
let s3_config = s3_config_builder.build();
732773

733774
let client = aws_sdk_s3::Client::from_conf(s3_config);
734775

735776
Ok(Self {
736777
inner: client,
737778
xml_http_client,
738779
alias,
780+
request_headers,
739781
})
740782
}
741783

@@ -1148,6 +1190,14 @@ impl S3Client {
11481190
);
11491191
}
11501192

1193+
for header in &self.request_headers {
1194+
let name = HeaderName::from_bytes(header.name.as_bytes())
1195+
.map_err(|e| Error::Auth(format!("Invalid custom header name: {e}")))?;
1196+
let value = HeaderValue::from_str(&header.value)
1197+
.map_err(|e| Error::Auth(format!("Invalid custom header value: {e}")))?;
1198+
headers.insert(name, value);
1199+
}
1200+
11511201
let signed_headers = self
11521202
.sign_xml_request(&method, url.as_str(), &headers, &body)
11531203
.await?;
@@ -2767,6 +2817,14 @@ mod tests {
27672817
fn test_s3_client_with_endpoint(
27682818
endpoint: &str,
27692819
response: Option<http::Response<SdkBody>>,
2820+
) -> (S3Client, CaptureRequestReceiver) {
2821+
test_s3_client_with_endpoint_and_headers(endpoint, response, Vec::new())
2822+
}
2823+
2824+
fn test_s3_client_with_endpoint_and_headers(
2825+
endpoint: &str,
2826+
response: Option<http::Response<SdkBody>>,
2827+
request_headers: Vec<RequestHeader>,
27702828
) -> (S3Client, CaptureRequestReceiver) {
27712829
let (http_client, request_receiver) = capture_request(response);
27722830
let credentials = Credentials::new(
@@ -2776,20 +2834,28 @@ mod tests {
27762834
None,
27772835
"rc-test-credentials",
27782836
);
2779-
let config = aws_sdk_s3::config::Builder::new()
2837+
let mut config_builder = aws_sdk_s3::config::Builder::new()
27802838
.credentials_provider(credentials)
27812839
.endpoint_url(endpoint)
27822840
.region(aws_sdk_s3::config::Region::new("us-east-1"))
27832841
.force_path_style(true)
27842842
.behavior_version_latest()
2785-
.http_client(http_client)
2786-
.build();
2843+
.http_client(http_client);
2844+
2845+
if !request_headers.is_empty() {
2846+
config_builder = config_builder.interceptor(CustomHeaderInterceptor {
2847+
headers: request_headers.clone(),
2848+
});
2849+
}
2850+
2851+
let config = config_builder.build();
27872852

27882853
let alias = Alias::new("test", endpoint, "access-key", "secret-key");
27892854
let client = S3Client {
27902855
inner: aws_sdk_s3::Client::from_conf(config),
27912856
xml_http_client: reqwest::Client::new(),
27922857
alias,
2858+
request_headers,
27932859
};
27942860

27952861
(client, request_receiver)
@@ -3469,6 +3535,34 @@ mod tests {
34693535
assert_eq!(request.headers().get("x-rustfs-force-delete"), Some("true"));
34703536
}
34713537

3538+
#[tokio::test]
3539+
async fn custom_headers_are_added_before_sending_sdk_requests() {
3540+
let (client, request_receiver) = test_s3_client_with_endpoint_and_headers(
3541+
"https://example.com",
3542+
None,
3543+
vec![RequestHeader {
3544+
name: "x-amz-bucket-encrypt-enabled".to_string(),
3545+
value: "1".to_string(),
3546+
}],
3547+
);
3548+
let path = RemotePath::new("test", "bucket", "key.txt");
3549+
3550+
let _ = client.delete_object(&path).await;
3551+
3552+
let request = request_receiver.expect_request();
3553+
assert_eq!(
3554+
request.headers().get("x-amz-bucket-encrypt-enabled"),
3555+
Some("1")
3556+
);
3557+
assert!(
3558+
request
3559+
.headers()
3560+
.get("authorization")
3561+
.expect("authorization header")
3562+
.contains("x-amz-bucket-encrypt-enabled")
3563+
);
3564+
}
3565+
34723566
#[tokio::test]
34733567
async fn delete_object_without_force_delete_omits_rustfs_header() {
34743568
let (client, request_receiver) = test_s3_client(None);

docs/reference/rc/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Global options shown in command syntax use the same meaning everywhere:
5555
| `--no-progress` | Disable progress bars. |
5656
| `-q, --quiet` | Suppress non-error output. |
5757
| `--debug` | Enable debug logging. |
58+
| `-H, --header NAME:VALUE` | Add an `x-amz-*` header to signed S3 requests. |
5859

5960
## Credentials
6061

0 commit comments

Comments
 (0)