Skip to content
Open
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
39 changes: 39 additions & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use std::io::{IsTerminal, stderr, stdout};

use clap::{Parser, Subcommand, ValueEnum};
use rc_core::{RequestHeader, set_global_request_headers};

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

/// Add an x-amz-* request header to signed S3 requests
#[arg(short = 'H', long = "header", global = true, value_parser = parse_request_header)]
pub request_headers: Vec<RequestHeader>,

#[command(subcommand)]
pub command: Commands,
}

fn parse_request_header(value: &str) -> Result<RequestHeader, String> {
RequestHeader::parse(value).map_err(|error| error.to_string())
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
Auto,
Expand Down Expand Up @@ -247,6 +256,7 @@ pub enum Commands {

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

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

#[test]
fn cli_accepts_global_custom_amz_header() {
let cli = Cli::try_parse_from([
"rc",
"-H",
"x-amz-bucket-encrypt-enabled:1",
"bucket",
"list",
"local/",
])
.expect("parse custom header");

assert_eq!(cli.request_headers.len(), 1);
assert_eq!(cli.request_headers[0].name, "x-amz-bucket-encrypt-enabled");
assert_eq!(cli.request_headers[0].value, "1");
}

#[test]
fn cli_rejects_non_amz_custom_header() {
let error = Cli::try_parse_from(["rc", "-H", "authorization:secret", "ls", "local/"])
.expect_err("non amz header should fail");

assert!(
error
.to_string()
.contains("Only x-amz-* custom request headers are supported")
);
}

#[test]
fn cli_accepts_bucket_cors_subcommand() {
let cli = Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket"])
Expand Down
1 change: 1 addition & 0 deletions crates/cli/tests/help_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const GLOBAL_OPTIONS: &[&str] = &[
"--no-progress",
"--quiet",
"--debug",
"--header",
"--help",
"--version",
];
Expand Down
75 changes: 75 additions & 0 deletions crates/core/src/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! including connection details and credentials.

use std::env;
use std::sync::{OnceLock, RwLock};

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

const RC_HOST_PREFIX: &str = "RC_HOST_";
const CUSTOM_HEADER_PREFIX: &str = "x-amz-";

static GLOBAL_REQUEST_HEADERS: OnceLock<RwLock<Vec<RequestHeader>>> = OnceLock::new();

/// Custom S3 request header applied to remote operations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestHeader {
pub name: String,
pub value: String,
}

impl RequestHeader {
pub fn parse(value: &str) -> Result<Self> {
let (name, header_value) = value.split_once(':').ok_or_else(|| {
Error::Config(
"Header must use NAME:VALUE format, for example x-amz-meta-key:value".into(),
)
})?;

let name = name.trim().to_ascii_lowercase();
let header_value = header_value.trim().to_string();

if name.is_empty() {
return Err(Error::Config("Header name must not be empty".into()));
}

if header_value.is_empty() {
return Err(Error::Config("Header value must not be empty".into()));
}

if !name.starts_with(CUSTOM_HEADER_PREFIX) {
return Err(Error::Config(
"Only x-amz-* custom request headers are supported".into(),
));
}

if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
{
return Err(Error::Config(format!("Invalid header name '{name}'")));
}

if !header_value.is_ascii() || header_value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
return Err(Error::Config(format!("Invalid value for header '{name}'")));
}

Ok(Self {
name,
value: header_value,
})
}
}

/// Set process-wide custom request headers for this CLI invocation.
pub fn set_global_request_headers(headers: Vec<RequestHeader>) {
let storage = GLOBAL_REQUEST_HEADERS.get_or_init(|| RwLock::new(Vec::new()));
let mut guard = storage
.write()
.expect("global request header lock should not be poisoned");
*guard = headers;
}

/// Get process-wide custom request headers for this CLI invocation.
pub fn global_request_headers() -> Vec<RequestHeader> {
let Some(storage) = GLOBAL_REQUEST_HEADERS.get() else {
return Vec::new();
};

storage
.read()
.expect("global request header lock should not be poisoned")
.clone()
}

/// Retry configuration for an alias
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
5 changes: 4 additions & 1 deletion crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ pub mod retry;
pub mod select;
pub mod traits;

pub use alias::{Alias, AliasManager, validate_alias_endpoint};
pub use alias::{
Alias, AliasManager, RequestHeader, global_request_headers, set_global_request_headers,
validate_alias_endpoint,
};
pub use config::{Config, ConfigManager};
pub use cors::{CorsConfiguration, CorsRule};
pub use error::{Error, Result};
Expand Down
Loading
Loading