fix: restore default CORS fallback and STS object ACL ownership (#2053)

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
安正超
2026-03-03 01:08:50 +08:00
committed by GitHub
parent fff96a0921
commit 08e1f4670b
13 changed files with 798 additions and 1105 deletions

View File

@@ -22,10 +22,11 @@ use tracing_subscriber::EnvFilter;
/// Build an `EnvFilter` from the given log level string.
///
/// If the `RUST_LOG` environment variable is set, it takes precedence over the
/// provided `logger_level`. For non-verbose levels (`info`, `warn`, `error`),
/// noisy internal crates (`hyper`, `tonic`, `h2`, `reqwest`, `tower`) are
/// automatically silenced to reduce log noise.
/// If `default_level` is provided, it is used directly. Otherwise, the
/// `RUST_LOG` environment variable takes precedence over `logger_level`.
/// For non-verbose levels (`info`, `warn`, `error`), noisy internal crates
/// (`hyper`, `tonic`, `h2`, `reqwest`, `tower`) are automatically silenced
/// based on the effective log configuration.
///
/// # Arguments
/// * `logger_level` - The desired log level string (e.g., `"info"`, `"debug"`).
@@ -35,13 +36,48 @@ use tracing_subscriber::EnvFilter;
///
/// # Returns
/// A configured `EnvFilter` ready to be attached to a `tracing_subscriber` registry.
fn is_verbose_level(level: &str) -> bool {
matches!(level.to_ascii_lowercase().as_str(), "trace" | "debug")
}
fn rust_log_requests_verbose(rust_log: &str) -> bool {
rust_log.split(',').any(|directive| {
let directive = directive.trim();
if directive.is_empty() {
return false;
}
let level = directive.rsplit('=').next().unwrap_or("").trim();
is_verbose_level(level)
})
}
fn should_suppress_noisy_crates(logger_level: &str, default_level: Option<&str>, rust_log: Option<&str>) -> bool {
if let Some(level) = default_level {
return !is_verbose_level(level);
}
if let Some(rust_log) = rust_log {
return !rust_log_requests_verbose(rust_log);
}
!is_verbose_level(logger_level)
}
pub(super) fn build_env_filter(logger_level: &str, default_level: Option<&str>) -> EnvFilter {
let level = default_level.unwrap_or(logger_level);
let mut filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
let rust_log = if default_level.is_none() {
std::env::var("RUST_LOG").ok()
} else {
None
};
let mut filter = default_level
.map(EnvFilter::new)
.unwrap_or_else(|| EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)));
// Suppress chatty infrastructure crates unless the operator explicitly
// requests trace/debug output.
if !matches!(logger_level, "trace" | "debug") {
if should_suppress_noisy_crates(logger_level, default_level, rust_log.as_deref()) {
let directives: SmallVec<[&str; 5]> = smallvec::smallvec!["hyper", "tonic", "h2", "reqwest", "tower"];
for directive in directives {
filter = filter.add_directive(format!("{directive}=off").parse().unwrap());
@@ -70,7 +106,7 @@ mod tests {
#[test]
fn test_build_env_filter_suppresses_noisy_crates() {
// For info level, hyper/tonic/etc. should be suppressed with OFF.
let filter = build_env_filter("info", None);
let filter = build_env_filter("debug", Some("info"));
let dbg = format!("{filter:?}");
// The Debug output uses `LevelFilter::OFF` for suppressed crates.
assert!(
@@ -82,7 +118,7 @@ mod tests {
#[test]
fn test_build_env_filter_debug_no_suppression() {
// For debug level, our code does NOT inject any OFF directives.
let filter = build_env_filter("debug", None);
let filter = build_env_filter("info", Some("debug"));
let dbg = format!("{filter:?}");
// Verify the filter builds without panicking and contains the debug level.
assert!(!dbg.is_empty());
@@ -91,4 +127,11 @@ mod tests {
"Expected 'LevelFilter::DEBUG' in filter debug output: {dbg}"
);
}
#[test]
fn test_should_suppress_noisy_crates_respects_rust_log_debug() {
assert!(!should_suppress_noisy_crates("info", None, Some("debug")));
assert!(!should_suppress_noisy_crates("info", None, Some("s3=info,hyper=debug")));
assert!(should_suppress_noisy_crates("info", None, Some("info")));
}
}

View File

@@ -19,12 +19,9 @@ use crate::auth::get_condition_values;
use crate::error::ApiError;
use crate::server::RemoteAddr;
use crate::storage::access::{ReqInfo, authorize_request, req_info_ref};
use crate::storage::ecfs::{
RUSTFS_OWNER, default_owner, is_public_grant, parse_acl_json_or_canned_bucket, serialize_acl, stored_acl_from_canned_bucket,
stored_acl_from_grant_headers, stored_acl_from_policy, stored_grant_to_dto, stored_owner_to_dto,
};
use crate::storage::ecfs::{RUSTFS_OWNER, default_owner};
use crate::storage::helper::OperationHelper;
use crate::storage::s3_api::{encryption, replication, tagging};
use crate::storage::s3_api::{acl, encryption, replication, tagging};
use crate::storage::*;
use futures::StreamExt;
use http::StatusCode;
@@ -33,7 +30,7 @@ use rustfs_config::RUSTFS_REGION;
use rustfs_ecstore::bucket::{
lifecycle::bucket_lifecycle_ops::validate_transition_tier,
metadata::{
BUCKET_ACL_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG,
BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG,
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_SSECONFIG, BUCKET_TAGGING_CONFIG,
BUCKET_VERSIONING_CONFIG,
},
@@ -158,12 +155,6 @@ impl DefaultBucketUsecase {
};
let CreateBucketInput {
bucket,
acl,
grant_full_control,
grant_read,
grant_read_acp,
grant_write,
grant_write_acp,
object_lock_enabled_for_bucket,
..
} = req.input;
@@ -190,13 +181,7 @@ impl DefaultBucketUsecase {
Err(StorageError::BucketExists(_)) => {
// Per S3 spec: bucket namespace is global. Owner recreating returns 200 OK;
// non-owner gets 409 BucketAlreadyExists.
let bucket_owner_id = match metadata_sys::get_bucket_acl_config(&bucket).await {
Ok((acl, _)) => parse_acl_json_or_canned_bucket(&acl, &default_owner()).owner.id,
Err(StorageError::ConfigNotFound) => default_owner().id,
Err(e) => return Err(ApiError::from(e).into()),
};
let is_owner = requester_id.as_deref().is_some_and(|req_id| req_id == bucket_owner_id);
let is_owner = requester_id.as_deref().is_some_and(|req_id| req_id == default_owner().id);
if is_owner {
let output = CreateBucketOutput::default();
@@ -214,28 +199,6 @@ impl DefaultBucketUsecase {
Err(e) => return Err(ApiError::from(e).into()),
}
let owner = default_owner();
let mut stored_acl = stored_acl_from_grant_headers(
&owner,
grant_read.map(|v| v.to_string()),
grant_write.map(|v| v.to_string()),
grant_read_acp.map(|v| v.to_string()),
grant_write_acp.map(|v| v.to_string()),
grant_full_control.map(|v| v.to_string()),
)?;
if stored_acl.is_none()
&& let Some(canned) = acl
{
stored_acl = Some(stored_acl_from_canned_bucket(canned.as_str(), &owner));
}
let stored_acl = stored_acl.unwrap_or_else(|| stored_acl_from_canned_bucket(BucketCannedACL::PRIVATE, &owner));
let data = serialize_acl(&stored_acl)?;
metadata_sys::update(&bucket, BUCKET_ACL_CONFIG, data)
.await
.map_err(ApiError::from)?;
let output = CreateBucketOutput::default();
let result = Ok(S3Response::new(output));
@@ -250,13 +213,7 @@ impl DefaultBucketUsecase {
let PutBucketAclInput {
bucket,
acl,
access_control_policy,
grant_full_control,
grant_read,
grant_read_acp,
grant_write,
grant_write_acp,
..
} = req.input;
@@ -269,43 +226,13 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
let owner = default_owner();
let mut stored_acl = access_control_policy
.as_ref()
.map(|policy| stored_acl_from_policy(policy, &owner))
.transpose()?;
if stored_acl.is_none() {
stored_acl = stored_acl_from_grant_headers(
&owner,
grant_read.map(|v| v.to_string()),
grant_write.map(|v| v.to_string()),
grant_read_acp.map(|v| v.to_string()),
grant_write_acp.map(|v| v.to_string()),
grant_full_control.map(|v| v.to_string()),
)?;
if access_control_policy.is_some() {
return Err(s3_error!(
NotImplemented,
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
));
}
if stored_acl.is_none()
&& let Some(canned) = acl
{
stored_acl = Some(stored_acl_from_canned_bucket(canned.as_str(), &owner));
}
let stored_acl = stored_acl.unwrap_or_else(|| stored_acl_from_canned_bucket(BucketCannedACL::PRIVATE, &owner));
if let Ok((config, _)) = metadata_sys::get_public_access_block_config(&bucket).await
&& config.block_public_acls.unwrap_or(false)
&& stored_acl.grants.iter().any(is_public_grant)
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
let data = serialize_acl(&stored_acl)?;
metadata_sys::update(&bucket, BUCKET_ACL_CONFIG, data)
.await
.map_err(ApiError::from)?;
Ok(S3Response::new(PutBucketAclOutput::default()))
}
@@ -392,25 +319,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
let owner = default_owner();
let stored_acl = match metadata_sys::get_bucket_acl_config(&bucket).await {
Ok((acl, _)) => parse_acl_json_or_canned_bucket(&acl, &owner),
Err(err) => {
if err != StorageError::ConfigNotFound {
return Err(ApiError::from(err).into());
}
stored_acl_from_canned_bucket(BucketCannedACL::PRIVATE, &owner)
}
};
let mut sorted_grants = stored_acl.grants.clone();
sorted_grants.sort_by_key(|grant| grant.grantee.grantee_type != "Group");
let grants = sorted_grants.iter().map(stored_grant_to_dto).collect();
Ok(S3Response::new(GetBucketAclOutput {
grants: Some(grants),
owner: Some(stored_owner_to_dto(&stored_acl.owner)),
}))
Ok(S3Response::new(acl::build_get_bucket_acl_output()))
}
#[instrument(level = "debug", skip(self, req))]
@@ -922,21 +831,7 @@ impl DefaultBucketUsecase {
Err(_) => false,
};
let owner = default_owner();
let acl_public = match metadata_sys::get_bucket_acl_config(&bucket).await {
Ok((acl, _)) => {
let stored_acl = parse_acl_json_or_canned_bucket(&acl, &owner);
stored_acl
.grants
.iter()
.any(|grant| is_public_grant(grant) && !ignore_public_acls)
}
Err(_) => false,
};
if acl_public {
is_public = true;
}
let _ = ignore_public_acls;
let policy_public = match metadata_sys::get_bucket_policy(&bucket).await {
Ok((cfg, _)) => cfg.statements.iter().any(|statement| {

View File

@@ -17,7 +17,7 @@
use crate::app::context::{AppContext, default_notify_interface, get_global_app_context};
use crate::config::workload_profiles::RustFSBufferConfig;
use crate::error::ApiError;
use crate::storage::access::{ReqInfo, authorize_request, has_bypass_governance_header, req_info_mut};
use crate::storage::access::{authorize_request, has_bypass_governance_header, req_info_mut};
use crate::storage::concurrency::{
CachedGetObject, ConcurrencyManager, GetObjectGuard, get_concurrency_aware_buffer_size, get_concurrency_manager,
};
@@ -29,7 +29,7 @@ use crate::storage::options::{
filter_object_metadata, get_content_sha256_with_query, get_opts, put_opts,
};
use crate::storage::s3_api::multipart::parse_list_parts_params;
use crate::storage::s3_api::{restore, select};
use crate::storage::s3_api::{acl, restore, select};
use crate::storage::*;
use bytes::Bytes;
use datafusion::arrow::{
@@ -287,11 +287,6 @@ impl DefaultObjectUsecase {
body,
bucket,
key,
acl,
grant_full_control,
grant_read,
grant_read_acp,
grant_write_acp,
content_length,
content_type,
tagging,
@@ -318,14 +313,6 @@ impl DefaultObjectUsecase {
// Validate object key
validate_object_key(&key, "PUT")?;
if let Some(acl) = &acl
&& let Ok((config, _)) = metadata_sys::get_public_access_block_config(&bucket).await
&& config.block_public_acls.unwrap_or(false)
&& is_public_canned_acl(acl.as_str())
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
if let Some(size) = content_length {
self.check_bucket_quota(&bucket, QuotaOperation::PutObject, size as u64)
.await?;
@@ -407,33 +394,6 @@ impl DefaultObjectUsecase {
)?;
let mut metadata = metadata.unwrap_or_default();
let owner = req
.extensions
.get::<ReqInfo>()
.and_then(|info| info.cred.as_ref())
.map(|cred| owner_from_access_key(&cred.access_key))
.unwrap_or_else(default_owner);
let bucket_owner = default_owner();
let mut stored_acl = stored_acl_from_grant_headers(
&owner,
grant_read.map(|v| v.to_string()),
None,
grant_read_acp.map(|v| v.to_string()),
grant_write_acp.map(|v| v.to_string()),
grant_full_control.map(|v| v.to_string()),
)?;
if stored_acl.is_none()
&& let Some(canned) = acl.as_ref()
{
stored_acl = Some(stored_acl_from_canned_object(canned.as_str(), &bucket_owner, &owner));
}
let stored_acl =
stored_acl.unwrap_or_else(|| stored_acl_from_canned_object(ObjectCannedACL::PRIVATE, &bucket_owner, &owner));
let acl_data = serialize_acl(&stored_acl)?;
metadata.insert(INTERNAL_ACL_METADATA_KEY.to_string(), String::from_utf8_lossy(&acl_data).to_string());
if let Some(content_type) = content_type {
metadata.insert("content-type".to_string(), content_type.to_string());
}
@@ -622,13 +582,7 @@ impl DefaultObjectUsecase {
let PutObjectAclInput {
bucket,
key,
acl,
access_control_policy,
grant_full_control,
grant_read,
grant_read_acp,
grant_write,
grant_write_acp,
version_id,
..
} = req.input;
@@ -640,64 +594,15 @@ impl DefaultObjectUsecase {
let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
.await
.map_err(ApiError::from)?;
let info = store.get_object_info(&bucket, &key, &opts).await.map_err(ApiError::from)?;
store.get_object_info(&bucket, &key, &opts).await.map_err(ApiError::from)?;
let bucket_owner = default_owner();
let existing_owner = info
.user_defined
.get(INTERNAL_ACL_METADATA_KEY)
.and_then(|acl| serde_json::from_str::<StoredAcl>(acl).ok())
.map(|acl| acl.owner)
.unwrap_or_else(|| bucket_owner.clone());
let mut stored_acl = access_control_policy
.as_ref()
.map(|policy| stored_acl_from_policy(policy, &existing_owner))
.transpose()?;
if stored_acl.is_none() {
stored_acl = stored_acl_from_grant_headers(
&existing_owner,
grant_read.map(|v| v.to_string()),
grant_write.map(|v| v.to_string()),
grant_read_acp.map(|v| v.to_string()),
grant_write_acp.map(|v| v.to_string()),
grant_full_control.map(|v| v.to_string()),
)?;
if access_control_policy.is_some() {
return Err(s3_error!(
NotImplemented,
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
));
}
if stored_acl.is_none()
&& let Some(canned) = acl
{
stored_acl = Some(stored_acl_from_canned_object(canned.as_str(), &bucket_owner, &existing_owner));
}
let stored_acl =
stored_acl.unwrap_or_else(|| stored_acl_from_canned_object(ObjectCannedACL::PRIVATE, &bucket_owner, &existing_owner));
if let Ok((config, _)) = metadata_sys::get_public_access_block_config(&bucket).await
&& config.block_public_acls.unwrap_or(false)
&& stored_acl.grants.iter().any(is_public_grant)
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
let acl_data = serialize_acl(&stored_acl)?;
let mut eval_metadata = HashMap::new();
eval_metadata.insert(INTERNAL_ACL_METADATA_KEY.to_string(), String::from_utf8_lossy(&acl_data).to_string());
let popts = ObjectOptions {
mod_time: info.mod_time,
version_id: opts.version_id,
eval_metadata: Some(eval_metadata),
..Default::default()
};
store.put_object_metadata(&bucket, &key, &popts).await.map_err(|e| {
error!("put_object_metadata failed, {}", e.to_string());
s3_error!(InternalError, "{}", e.to_string())
})?;
Ok(S3Response::new(PutObjectAclOutput::default()))
}
@@ -1606,31 +1511,9 @@ impl DefaultObjectUsecase {
let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
.await
.map_err(ApiError::from)?;
let info = store.get_object_info(&bucket, &key, &opts).await.map_err(ApiError::from)?;
store.get_object_info(&bucket, &key, &opts).await.map_err(ApiError::from)?;
let bucket_owner = default_owner();
let object_owner = info
.user_defined
.get(INTERNAL_ACL_METADATA_KEY)
.and_then(|acl| serde_json::from_str::<StoredAcl>(acl).ok())
.map(|acl| acl.owner)
.unwrap_or_else(default_owner);
let stored_acl = info
.user_defined
.get(INTERNAL_ACL_METADATA_KEY)
.map(|acl| parse_acl_json_or_canned_object(acl, &bucket_owner, &object_owner))
.unwrap_or_else(|| stored_acl_from_canned_object(ObjectCannedACL::PRIVATE, &bucket_owner, &object_owner));
let mut sorted_grants = stored_acl.grants.clone();
sorted_grants.sort_by_key(|grant| grant.grantee.grantee_type != "Group");
let grants = sorted_grants.iter().map(stored_grant_to_dto).collect();
Ok(S3Response::new(GetObjectAclOutput {
grants: Some(grants),
owner: Some(stored_owner_to_dto(&stored_acl.owner)),
..Default::default()
}))
Ok(S3Response::new(acl::build_get_object_acl_output()))
}
pub async fn execute_get_object_attributes(

View File

@@ -343,6 +343,29 @@ pub struct ConditionalCorsService<S> {
cors_origins: Arc<Option<String>>,
}
async fn resolve_s3_options_cors_headers(bucket: &str, request_headers: &HeaderMap) -> Option<HeaderMap> {
apply_cors_headers(bucket, &http::Method::OPTIONS, request_headers).await
}
fn clear_cors_response_headers(headers: &mut HeaderMap) {
headers.remove(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN);
headers.remove(cors::response::ACCESS_CONTROL_ALLOW_METHODS);
headers.remove(cors::response::ACCESS_CONTROL_ALLOW_HEADERS);
headers.remove(cors::response::ACCESS_CONTROL_EXPOSE_HEADERS);
headers.remove(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS);
headers.remove(cors::response::ACCESS_CONTROL_MAX_AGE);
}
fn apply_bucket_cors_result(response_headers: &mut HeaderMap, bucket_cors_headers: &HeaderMap) {
// Bucket-level CORS is authoritative for S3 object/bucket paths.
// Clear any previously-populated CORS response headers (e.g. generic/system defaults),
// then apply the evaluated bucket result (which may be intentionally empty).
clear_cors_response_headers(response_headers);
for (key, value) in bucket_cors_headers.iter() {
response_headers.insert(key, value.clone());
}
}
impl<S, ResBody> Service<HttpRequest<Incoming>> for ConditionalCorsService<S>
where
S: Service<HttpRequest<Incoming>, Response = Response<ResBody>> + Clone + Send + 'static,
@@ -364,12 +387,32 @@ where
let request_headers = req.headers().clone();
let cors_origins = self.cors_origins.clone();
let is_s3 = ConditionalCorsLayer::is_s3_path(&path);
let is_root = path == "/";
if method == Method::OPTIONS {
let has_acrm = request_headers.contains_key(cors::request::ACCESS_CONTROL_REQUEST_METHOD);
if is_root {
return Box::pin(async move {
if !has_acrm || !request_headers.contains_key(cors::standard::ORIGIN) {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(ResBody::default())
.unwrap());
}
let mut response = Response::builder().status(StatusCode::OK).body(ResBody::default()).unwrap();
let cors_layer = ConditionalCorsLayer {
cors_origins: (*cors_origins).clone(),
};
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
Ok(response)
});
}
if is_s3 {
let path_trimmed = path.trim_start_matches('/');
let bucket = path_trimmed.split('/').next().unwrap_or("").to_string();
let has_acrm = request_headers.contains_key(cors::request::ACCESS_CONTROL_REQUEST_METHOD);
return Box::pin(async move {
if !has_acrm || !request_headers.contains_key(cors::standard::ORIGIN) {
@@ -379,20 +422,27 @@ where
.unwrap());
}
if !bucket.is_empty()
&& let Some(cors_headers) = apply_cors_headers(&bucket, &method, &request_headers).await
{
let mut response = Response::builder().status(StatusCode::OK).body(ResBody::default()).unwrap();
for (key, value) in cors_headers.iter() {
response.headers_mut().insert(key, value.clone());
let cors_layer = ConditionalCorsLayer {
cors_origins: (*cors_origins).clone(),
};
if let Some(cors_headers) = resolve_s3_options_cors_headers(&bucket, &request_headers).await {
let cors_allowed = cors_headers.contains_key(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN);
let status = if cors_allowed { StatusCode::OK } else { StatusCode::FORBIDDEN };
let mut response = Response::builder().status(status).body(ResBody::default()).unwrap();
if cors_allowed {
for (key, value) in cors_headers.iter() {
response.headers_mut().insert(key, value.clone());
}
}
return Ok(response);
}
Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.body(ResBody::default())
.unwrap())
// No bucket-level CORS config: fall back to global/default CORS behavior.
let mut response = Response::builder().status(StatusCode::OK).body(ResBody::default()).unwrap();
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
Ok(response)
});
}
@@ -415,19 +465,30 @@ where
if request_headers.contains_key(cors::standard::ORIGIN)
&& !response.headers().contains_key(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN)
{
let cors_layer = ConditionalCorsLayer {
cors_origins: (*cors_origins).clone(),
};
if is_s3 {
let bucket = path.trim_start_matches('/').split('/').next().unwrap_or("");
if !bucket.is_empty()
&& let Some(cors_headers) = apply_cors_headers(bucket, &method, &request_headers).await
{
for (key, value) in cors_headers.iter() {
response.headers_mut().insert(key, value.clone());
if path == "/" {
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
} else if !bucket.is_empty() {
match apply_cors_headers(bucket, &method, &request_headers).await {
Some(bucket_cors_headers) => {
// Bucket-level CORS is authoritative when configured, even if it
// intentionally resolves to an empty header set (no rule match).
apply_bucket_cors_result(response.headers_mut(), &bucket_cors_headers);
}
None => {
// No bucket-level CORS config: fall back to global/default policy.
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
}
}
} else {
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
}
} else {
let cors_layer = ConditionalCorsLayer {
cors_origins: (*cors_origins).clone(),
};
cors_layer.apply_cors_headers(&request_headers, response.headers_mut());
}
}
@@ -532,4 +593,73 @@ mod tests {
"https://allowed.com"
);
}
#[tokio::test]
async fn test_resolve_s3_options_cors_headers_no_headers_without_match() {
let mut req_headers = HeaderMap::new();
req_headers.insert("origin", "https://example.com".parse().unwrap());
req_headers.insert("access-control-request-method", "GET".parse().unwrap());
let headers = resolve_s3_options_cors_headers("bbb", &req_headers).await;
assert!(headers.is_none());
}
#[test]
fn test_apply_bucket_cors_result_clears_existing_cors_headers_with_empty_result() {
let mut response_headers = HeaderMap::new();
response_headers.insert(
cors::response::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("https://foo.example"),
);
response_headers.insert(
cors::response::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS, HEAD"),
);
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("*"));
response_headers.insert(cors::response::ACCESS_CONTROL_EXPOSE_HEADERS, HeaderValue::from_static("etag"));
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_static("true"));
response_headers.insert(cors::response::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("3600"));
let bucket_cors_headers = HeaderMap::new();
apply_bucket_cors_result(&mut response_headers, &bucket_cors_headers);
assert!(response_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
assert!(response_headers.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).is_none());
assert!(response_headers.get(cors::response::ACCESS_CONTROL_ALLOW_HEADERS).is_none());
assert!(response_headers.get(cors::response::ACCESS_CONTROL_EXPOSE_HEADERS).is_none());
assert!(
response_headers
.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none()
);
assert!(response_headers.get(cors::response::ACCESS_CONTROL_MAX_AGE).is_none());
}
#[test]
fn test_apply_bucket_cors_result_replaces_existing_cors_headers() {
let mut response_headers = HeaderMap::new();
response_headers.insert(
cors::response::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("https://foo.example"),
);
response_headers.insert(
cors::response::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS, HEAD"),
);
let mut bucket_cors_headers = HeaderMap::new();
bucket_cors_headers.insert(
cors::response::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("https://allowed.example"),
);
bucket_cors_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("GET"));
apply_bucket_cors_result(&mut response_headers, &bucket_cors_headers);
assert_eq!(
response_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://allowed.example"
);
assert_eq!(response_headers.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).unwrap(), "GET");
}
}

View File

@@ -13,14 +13,7 @@
// limitations under the License.
use super::ecfs::FS;
use super::ecfs::{
ACL_GROUP_ALL_USERS, ACL_GROUP_AUTHENTICATED_USERS, INTERNAL_ACL_METADATA_KEY, StoredAcl, default_owner,
parse_acl_json_or_canned_bucket, parse_acl_json_or_canned_object, stored_acl_from_canned_bucket,
stored_acl_from_canned_object,
};
use super::options::get_opts;
use crate::auth::{check_key_valid, get_condition_values_with_query, get_session_token};
use crate::error::ApiError;
use crate::license::license_check;
use crate::server::RemoteAddr;
use metrics::counter;
@@ -28,7 +21,7 @@ use rustfs_ecstore::bucket::metadata_sys;
use rustfs_ecstore::bucket::policy_sys::PolicySys;
use rustfs_ecstore::error::{StorageError, is_err_bucket_not_found};
use rustfs_ecstore::new_object_layer_fn;
use rustfs_ecstore::store_api::{BucketOperations, ObjectOperations};
use rustfs_ecstore::store_api::BucketOperations;
use rustfs_iam::error::Error as IamError;
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::{Args, BucketPolicyArgs};
@@ -48,165 +41,6 @@ pub(crate) struct ReqInfo {
pub region: Option<s3s::region::Region>,
}
#[derive(Clone, Copy)]
enum AclTarget {
Bucket,
Object,
}
#[derive(Clone, Copy)]
enum AclPermission {
Read,
Write,
ReadAcp,
WriteAcp,
}
fn acl_permission_for_action(action: &Action) -> Option<(AclTarget, AclPermission)> {
match action {
Action::S3Action(S3Action::ListBucketAction) => Some((AclTarget::Bucket, AclPermission::Read)),
Action::S3Action(S3Action::PutObjectAction) => Some((AclTarget::Bucket, AclPermission::Write)),
Action::S3Action(S3Action::GetBucketAclAction) => Some((AclTarget::Bucket, AclPermission::ReadAcp)),
Action::S3Action(S3Action::PutBucketAclAction) => Some((AclTarget::Bucket, AclPermission::WriteAcp)),
Action::S3Action(S3Action::GetObjectAction) => Some((AclTarget::Object, AclPermission::Read)),
Action::S3Action(S3Action::GetObjectAclAction) => Some((AclTarget::Object, AclPermission::ReadAcp)),
Action::S3Action(S3Action::PutObjectAclAction) => Some((AclTarget::Object, AclPermission::WriteAcp)),
_ => None,
}
}
fn permission_matches(grant_perm: &str, required: AclPermission) -> bool {
if grant_perm == Permission::FULL_CONTROL {
return true;
}
match required {
AclPermission::Read => grant_perm == Permission::READ,
AclPermission::Write => grant_perm == Permission::WRITE,
AclPermission::ReadAcp => grant_perm == Permission::READ_ACP,
AclPermission::WriteAcp => grant_perm == Permission::WRITE_ACP,
}
}
fn acl_allows(
acl: &StoredAcl,
user_id: Option<&str>,
is_authenticated: bool,
required: AclPermission,
ignore_public_acls: bool,
) -> bool {
for grant in &acl.grants {
if !permission_matches(grant.permission.as_str(), required) {
continue;
}
match grant.grantee.grantee_type.as_str() {
"CanonicalUser" => {
if user_id.is_some_and(|id| grant.grantee.id.as_deref() == Some(id)) {
return true;
}
}
"Group" => {
if ignore_public_acls {
continue;
}
if let Some(uri) = grant.grantee.uri.as_deref() {
if uri == ACL_GROUP_ALL_USERS {
return true;
}
if uri == ACL_GROUP_AUTHENTICATED_USERS && is_authenticated {
return true;
}
}
}
_ => {}
}
}
false
}
async fn load_bucket_acl(bucket: &str) -> S3Result<StoredAcl> {
let owner = default_owner();
match metadata_sys::get_bucket_acl_config(bucket).await {
Ok((acl, _)) => Ok(parse_acl_json_or_canned_bucket(&acl, &owner)),
Err(err) => {
if err == StorageError::ConfigNotFound {
Ok(stored_acl_from_canned_bucket(BucketCannedACL::PRIVATE, &owner))
} else {
Err(S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))
}
}
}
}
async fn load_object_acl(bucket: &str, object: &str, version_id: Option<&str>, headers: &http::HeaderMap) -> S3Result<StoredAcl> {
let Some(store) = new_object_layer_fn() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let opts = get_opts(bucket, object, version_id.map(|v| v.to_string()), None, headers)
.await
.map_err(ApiError::from)?;
let info = store.get_object_info(bucket, object, &opts).await.map_err(ApiError::from)?;
let bucket_owner = default_owner();
let object_owner = info
.user_defined
.get(INTERNAL_ACL_METADATA_KEY)
.and_then(|acl| serde_json::from_str::<StoredAcl>(acl).ok())
.map(|acl| acl.owner)
.unwrap_or_else(default_owner);
Ok(info
.user_defined
.get(INTERNAL_ACL_METADATA_KEY)
.map(|acl| parse_acl_json_or_canned_object(acl, &bucket_owner, &object_owner))
.unwrap_or_else(|| stored_acl_from_canned_object(ObjectCannedACL::PRIVATE, &bucket_owner, &object_owner)))
}
async fn check_acl_access<T>(req: &S3Request<T>, req_info: &ReqInfo, action: &Action, policy_allowed: bool) -> S3Result<bool> {
let Some((target, permission)) = acl_permission_for_action(action) else {
return Ok(true);
};
// For object-level operations, do not bypass on account-level is_owner.
// The bucket/account owner must have explicit ACL grant to access objects owned by others.
let bypass_owner = match target {
AclTarget::Bucket => req_info.is_owner,
AclTarget::Object => false,
};
if bypass_owner || policy_allowed {
return Ok(true);
}
let bucket = req_info.bucket.as_deref().unwrap_or("");
if bucket.is_empty() {
return Ok(true);
}
let ignore_public_acls = match metadata_sys::get_public_access_block_config(bucket).await {
Ok((config, _)) => config.ignore_public_acls.unwrap_or(false),
Err(_) => false,
};
let user_id = req_info.cred.as_ref().map(|cred| cred.access_key.as_str());
let is_authenticated = user_id.is_some();
let acl = match target {
AclTarget::Bucket => load_bucket_acl(bucket).await?,
AclTarget::Object => {
let object = req_info.object.as_deref().unwrap_or("");
if object.is_empty() {
return Ok(true);
}
load_object_acl(bucket, object, req_info.version_id.as_deref(), &req.headers).await?
}
};
Ok(acl_allows(&acl, user_id, is_authenticated, permission, ignore_public_acls))
}
pub(crate) fn req_info_ref<T>(req: &S3Request<T>) -> S3Result<&ReqInfo> {
req.extensions
.get::<ReqInfo>()
@@ -299,7 +133,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
&& !PolicySys::is_allowed(&BucketPolicyArgs {
bucket: bucket_name,
action,
// Run this early check in deny-only mode so ACL/IAM fallbacks can still grant access.
// Run this early check in deny-only mode so IAM fallback can still grant access.
is_owner: true,
account: &cred.access_key,
groups: &cred.groups,
@@ -355,26 +189,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
.await;
if iam_allowed {
let policy_allowed = if !bucket_name.is_empty() {
PolicySys::is_allowed(&BucketPolicyArgs {
bucket: bucket_name,
action,
is_owner: false,
account: &cred.access_key,
groups: &cred.groups,
conditions: &conditions,
object: req_info.object.as_deref().unwrap_or(""),
})
.await
} else {
false
};
if check_acl_access(req, req_info, &action, policy_allowed).await? {
return Ok(());
}
return Err(s3_error!(AccessDenied, "Access Denied"));
return Ok(());
}
let policy_allowed_fallback = PolicySys::is_allowed(&BucketPolicyArgs {
@@ -389,13 +204,6 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
.await;
if policy_allowed_fallback {
// For object-level operations, bucket owner (is_owner) must still pass ACL check.
// Policy may have allowed due to is_owner, but object owner is authoritative.
if let Some((AclTarget::Object, _)) = acl_permission_for_action(&action)
&& !check_acl_access(req, req_info, &action, false).await?
{
return Err(s3_error!(AccessDenied, "Access Denied"));
}
return Ok(());
}
@@ -455,7 +263,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
&& !PolicySys::is_allowed(&BucketPolicyArgs {
bucket: bucket_name,
action,
// Run this early check in deny-only mode so ACL checks are not bypassed.
// Run this early check in deny-only mode so later policy checks are not bypassed.
is_owner: true,
account: "",
groups: &None,
@@ -509,10 +317,6 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
{
return Ok(());
}
if acl_permission_for_action(&action).is_some() && check_acl_access(req, req_info, &action, false).await? {
return Ok(());
}
}
}
@@ -532,18 +336,10 @@ fn get_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::GetBucketPolicyAction)
}
fn get_object_acl_authorize_action() -> Action {
Action::S3Action(S3Action::GetObjectAclAction)
}
fn put_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::PutBucketPolicyAction)
}
fn put_object_acl_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAclAction)
}
#[async_trait::async_trait]
impl S3Access for FS {
// /// Checks whether the current request has accesses to the resources.
@@ -1104,12 +900,7 @@ impl S3Access for FS {
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let tag_conds = self
.fetch_tag_conditions(&req.input.bucket, &req.input.key, req.input.version_id.as_deref(), "get_object_acl")
.await?;
req.extensions.insert(tag_conds);
authorize_request(req, get_object_acl_authorize_action()).await
authorize_request(req, Action::S3Action(S3Action::GetObjectAclAction)).await
}
/// Checks whether the GetObjectAttributes request has accesses to the resources.
@@ -1526,12 +1317,7 @@ impl S3Access for FS {
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
let tag_conds = self
.fetch_tag_conditions(&req.input.bucket, &req.input.key, req.input.version_id.as_deref(), "put_object_acl")
.await?;
req.extensions.insert(tag_conds);
authorize_request(req, put_object_acl_authorize_action()).await
authorize_request(req, Action::S3Action(S3Action::PutObjectAclAction)).await
}
/// Checks whether the PutObjectLegalHold request has accesses to the resources.
@@ -1659,21 +1445,11 @@ mod tests {
assert_eq!(get_bucket_policy_authorize_action(), Action::S3Action(S3Action::GetBucketPolicyAction));
}
#[test]
fn get_object_acl_uses_get_object_acl_action() {
assert_eq!(get_object_acl_authorize_action(), Action::S3Action(S3Action::GetObjectAclAction));
}
#[test]
fn put_bucket_policy_uses_put_bucket_policy_action() {
assert_eq!(put_bucket_policy_authorize_action(), Action::S3Action(S3Action::PutBucketPolicyAction));
}
#[test]
fn put_object_acl_uses_put_object_acl_action() {
assert_eq!(put_object_acl_authorize_action(), Action::S3Action(S3Action::PutObjectAclAction));
}
/// Object tag conditions must use keys like ExistingObjectTag/<tag-key> so that
/// bucket policy conditions (e.g. s3:ExistingObjectTag/security) are evaluated correctly.
#[test]

View File

@@ -22,7 +22,6 @@ use rustfs_ecstore::{
store_api::{BucketOperations, BucketOptions, ObjectOperations, ObjectOptions},
};
use s3s::{S3, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, dto::*, s3_error};
use serde::{Deserialize, Serialize};
use std::{fmt::Debug, sync::LazyLock};
use tokio::io::{AsyncRead, AsyncSeek};
use tracing::{debug, error, instrument, warn};
@@ -30,511 +29,23 @@ use uuid::Uuid;
const DEFAULT_OWNER_ID: &str = "rustfsadmin";
const DEFAULT_OWNER_DISPLAY_NAME: &str = "RustFS Tester";
const DEFAULT_OWNER_EMAIL: &str = "tester@rustfs.local";
const DEFAULT_ALT_ID: &str = "rustfsalt";
const DEFAULT_ALT_DISPLAY_NAME: &str = "RustFS Alt Tester";
const DEFAULT_ALT_EMAIL: &str = "alt@rustfs.local";
pub(crate) const INTERNAL_ACL_METADATA_KEY: &str = "x-rustfs-internal-acl";
pub(crate) static RUSTFS_OWNER: LazyLock<Owner> = LazyLock::new(|| Owner {
display_name: Some(DEFAULT_OWNER_DISPLAY_NAME.to_owned()),
id: Some(DEFAULT_OWNER_ID.to_owned()),
});
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[derive(Clone, Debug, Default)]
pub(crate) struct StoredOwner {
pub(crate) id: String,
pub(crate) display_name: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct StoredGrantee {
pub(crate) grantee_type: String,
pub(crate) id: Option<String>,
pub(crate) display_name: Option<String>,
pub(crate) uri: Option<String>,
pub(crate) email_address: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct StoredGrant {
pub(crate) grantee: StoredGrantee,
pub(crate) permission: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct StoredAcl {
pub(crate) owner: StoredOwner,
pub(crate) grants: Vec<StoredGrant>,
}
pub(crate) fn default_owner() -> StoredOwner {
StoredOwner {
id: DEFAULT_OWNER_ID.to_string(),
display_name: DEFAULT_OWNER_DISPLAY_NAME.to_string(),
}
}
pub(crate) fn owner_from_access_key(access_key: &str) -> StoredOwner {
if access_key == DEFAULT_OWNER_ID {
return default_owner();
}
if access_key == DEFAULT_ALT_ID {
return StoredOwner {
id: DEFAULT_ALT_ID.to_string(),
display_name: DEFAULT_ALT_DISPLAY_NAME.to_string(),
};
}
StoredOwner {
id: access_key.to_string(),
display_name: access_key.to_string(),
}
}
fn user_id_from_email(email: &str) -> Option<StoredOwner> {
if email.eq_ignore_ascii_case(DEFAULT_OWNER_EMAIL) {
return Some(default_owner());
}
if email.eq_ignore_ascii_case(DEFAULT_ALT_EMAIL) {
return Some(StoredOwner {
id: DEFAULT_ALT_ID.to_string(),
display_name: DEFAULT_ALT_DISPLAY_NAME.to_string(),
});
}
None
}
fn display_name_for_user_id(user_id: &str) -> Option<String> {
if user_id == DEFAULT_OWNER_ID {
return Some(DEFAULT_OWNER_DISPLAY_NAME.to_string());
}
if user_id == DEFAULT_ALT_ID {
return Some(DEFAULT_ALT_DISPLAY_NAME.to_string());
}
None
}
fn is_known_user_id(user_id: &str) -> bool {
user_id == DEFAULT_OWNER_ID || user_id == DEFAULT_ALT_ID
}
pub(crate) fn stored_owner_to_dto(owner: &StoredOwner) -> Owner {
Owner {
id: Some(owner.id.clone()),
display_name: Some(owner.display_name.clone()),
}
}
pub(crate) fn stored_grant_to_dto(grant: &StoredGrant) -> Grant {
let grantee = match grant.grantee.grantee_type.as_str() {
"Group" => Grantee {
type_: Type::from_static(Type::GROUP),
display_name: None,
email_address: None,
id: None,
uri: grant.grantee.uri.clone(),
},
_ => Grantee {
type_: Type::from_static(Type::CANONICAL_USER),
display_name: grant.grantee.display_name.clone(),
email_address: None,
id: grant.grantee.id.clone(),
uri: None,
},
};
Grant {
grantee: Some(grantee),
permission: Some(Permission::from(grant.permission.clone())),
}
}
pub(crate) fn is_public_grant(grant: &StoredGrant) -> bool {
matches!(grant.grantee.grantee_type.as_str(), "Group")
&& grant
.grantee
.uri
.as_deref()
.is_some_and(|uri| uri == ACL_GROUP_ALL_USERS || uri == ACL_GROUP_AUTHENTICATED_USERS)
}
fn grants_for_canned_bucket_acl(acl: &str, owner: &StoredOwner) -> Vec<StoredGrant> {
let mut grants = Vec::new();
match acl {
BucketCannedACL::PUBLIC_READ => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
}
BucketCannedACL::PUBLIC_READ_WRITE => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::WRITE.to_string(),
});
}
BucketCannedACL::AUTHENTICATED_READ => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_AUTHENTICATED_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
}
_ => {}
}
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(owner.id.clone()),
display_name: Some(owner.display_name.clone()),
uri: None,
email_address: None,
},
permission: Permission::FULL_CONTROL.to_string(),
});
grants
}
fn grants_for_canned_object_acl(acl: &str, bucket_owner: &StoredOwner, object_owner: &StoredOwner) -> Vec<StoredGrant> {
let mut grants = Vec::new();
match acl {
ObjectCannedACL::PUBLIC_READ => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
}
ObjectCannedACL::PUBLIC_READ_WRITE => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_ALL_USERS.to_string()),
email_address: None,
},
permission: Permission::WRITE.to_string(),
});
}
ObjectCannedACL::AUTHENTICATED_READ => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(ACL_GROUP_AUTHENTICATED_USERS.to_string()),
email_address: None,
},
permission: Permission::READ.to_string(),
});
}
ObjectCannedACL::BUCKET_OWNER_READ => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(bucket_owner.id.clone()),
display_name: Some(bucket_owner.display_name.clone()),
uri: None,
email_address: None,
},
permission: Permission::READ.to_string(),
});
}
ObjectCannedACL::BUCKET_OWNER_FULL_CONTROL => {
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(bucket_owner.id.clone()),
display_name: Some(bucket_owner.display_name.clone()),
uri: None,
email_address: None,
},
permission: Permission::FULL_CONTROL.to_string(),
});
}
_ => {}
}
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(object_owner.id.clone()),
display_name: Some(object_owner.display_name.clone()),
uri: None,
email_address: None,
},
permission: Permission::FULL_CONTROL.to_string(),
});
grants
}
pub(crate) fn stored_acl_from_canned_bucket(acl: &str, owner: &StoredOwner) -> StoredAcl {
StoredAcl {
owner: owner.clone(),
grants: grants_for_canned_bucket_acl(acl, owner),
}
}
pub(crate) fn stored_acl_from_canned_object(acl: &str, bucket_owner: &StoredOwner, object_owner: &StoredOwner) -> StoredAcl {
StoredAcl {
owner: object_owner.clone(),
grants: grants_for_canned_object_acl(acl, bucket_owner, object_owner),
}
}
pub(crate) fn parse_acl_json_or_canned_bucket(data: &str, owner: &StoredOwner) -> StoredAcl {
match serde_json::from_str::<StoredAcl>(data) {
Ok(acl) => acl,
Err(_) => stored_acl_from_canned_bucket(data, owner),
}
}
pub(crate) fn parse_acl_json_or_canned_object(data: &str, bucket_owner: &StoredOwner, object_owner: &StoredOwner) -> StoredAcl {
match serde_json::from_str::<StoredAcl>(data) {
Ok(acl) => acl,
Err(_) => stored_acl_from_canned_object(data, bucket_owner, object_owner),
}
}
pub(crate) fn serialize_acl(acl: &StoredAcl) -> std::result::Result<Vec<u8>, S3Error> {
serde_json::to_vec(acl).map_err(|e| s3_error!(InternalError, "serialize acl failed {e}"))
}
fn grantee_from_grant(grantee: &Grantee) -> Result<StoredGrantee, S3Error> {
match grantee.type_.as_str() {
Type::GROUP => {
let uri = grantee
.uri
.clone()
.filter(|uri| uri == ACL_GROUP_ALL_USERS || uri == ACL_GROUP_AUTHENTICATED_USERS)
.ok_or_else(|| s3_error!(InvalidArgument))?;
Ok(StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(uri),
email_address: None,
})
}
Type::CANONICAL_USER => {
let id = grantee.id.clone().ok_or_else(|| s3_error!(InvalidArgument))?;
if !is_known_user_id(&id) {
return Err(s3_error!(InvalidArgument));
}
let display_name = display_name_for_user_id(&id).or_else(|| grantee.display_name.clone());
Ok(StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(id),
display_name,
uri: None,
email_address: None,
})
}
Type::AMAZON_CUSTOMER_BY_EMAIL => {
let email = grantee.email_address.clone().ok_or_else(|| s3_error!(InvalidArgument))?;
let owner = user_id_from_email(&email).ok_or_else(|| s3_error!(UnresolvableGrantByEmailAddress))?;
Ok(StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(owner.id),
display_name: Some(owner.display_name),
uri: None,
email_address: None,
})
}
_ => Err(s3_error!(InvalidArgument)),
}
}
pub(crate) fn stored_acl_from_policy(policy: &AccessControlPolicy, owner_fallback: &StoredOwner) -> Result<StoredAcl, S3Error> {
let owner = policy
.owner
.as_ref()
.and_then(|owner| {
let id = owner.id.clone()?;
let display_name = owner.display_name.clone()?;
Some(StoredOwner { id, display_name })
})
.unwrap_or_else(|| owner_fallback.clone());
let grants = policy
.grants
.clone()
.unwrap_or_default()
.into_iter()
.map(|grant| {
let permission = grant
.permission
.clone()
.ok_or_else(|| s3_error!(InvalidArgument))?
.as_str()
.to_string();
let grantee = grant
.grantee
.as_ref()
.ok_or_else(|| s3_error!(InvalidArgument))
.and_then(grantee_from_grant)?;
Ok(StoredGrant { grantee, permission })
})
.collect::<Result<Vec<_>, S3Error>>()?;
Ok(StoredAcl { owner, grants })
}
fn parse_grant_header(value: &str) -> Result<Vec<StoredGrantee>, S3Error> {
let mut grantees = Vec::new();
for entry in value.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let mut parts = entry.splitn(2, '=');
let key = parts.next().unwrap_or_default().trim();
let val = parts.next().unwrap_or_default().trim();
match key {
"id" => {
if !is_known_user_id(val) {
return Err(s3_error!(InvalidArgument));
}
grantees.push(StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(val.to_string()),
display_name: display_name_for_user_id(val),
uri: None,
email_address: None,
});
}
"uri" => {
if val != ACL_GROUP_ALL_USERS && val != ACL_GROUP_AUTHENTICATED_USERS {
return Err(s3_error!(InvalidArgument));
}
grantees.push(StoredGrantee {
grantee_type: "Group".to_string(),
id: None,
display_name: None,
uri: Some(val.to_string()),
email_address: None,
});
}
"emailAddress" => {
let owner = user_id_from_email(val).ok_or_else(|| s3_error!(UnresolvableGrantByEmailAddress))?;
grantees.push(StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(owner.id),
display_name: Some(owner.display_name),
uri: None,
email_address: None,
});
}
_ => return Err(s3_error!(InvalidArgument)),
}
}
Ok(grantees)
}
pub(crate) fn stored_acl_from_grant_headers(
owner: &StoredOwner,
grant_read: Option<String>,
grant_write: Option<String>,
grant_read_acp: Option<String>,
grant_write_acp: Option<String>,
grant_full_control: Option<String>,
) -> Result<Option<StoredAcl>, S3Error> {
let mut grants = Vec::new();
let mut push_grants = |value: Option<String>, permission: &str| -> Result<(), S3Error> {
if let Some(value) = value {
for grantee in parse_grant_header(&value)? {
grants.push(StoredGrant {
grantee,
permission: permission.to_string(),
});
}
}
Ok(())
};
push_grants(grant_read, Permission::READ)?;
push_grants(grant_write, Permission::WRITE)?;
push_grants(grant_read_acp, Permission::READ_ACP)?;
push_grants(grant_write_acp, Permission::WRITE_ACP)?;
push_grants(grant_full_control, Permission::FULL_CONTROL)?;
if grants.is_empty() {
return Ok(None);
}
grants.push(StoredGrant {
grantee: StoredGrantee {
grantee_type: "CanonicalUser".to_string(),
id: Some(owner.id.clone()),
display_name: Some(owner.display_name.clone()),
uri: None,
email_address: None,
},
permission: Permission::FULL_CONTROL.to_string(),
});
Ok(Some(StoredAcl {
owner: owner.clone(),
grants,
}))
}
#[derive(Debug, Clone)]
pub struct FS {
// pub store: ECStore,
@@ -659,13 +170,6 @@ impl FS {
}
}
pub(crate) const ACL_GROUP_ALL_USERS: &str = "http://acs.amazonaws.com/groups/global/AllUsers";
pub(crate) const ACL_GROUP_AUTHENTICATED_USERS: &str = "http://acs.amazonaws.com/groups/global/AuthenticatedUsers";
pub(crate) fn is_public_canned_acl(acl: &str) -> bool {
matches!(acl, "public-read" | "public-read-write" | "authenticated-read")
}
pub(crate) fn parse_object_version_id(version_id: Option<String>) -> S3Result<Option<Uuid>> {
if let Some(vid) = version_id {
let uuid = Uuid::parse_str(&vid).map_err(|e| {

View File

@@ -641,13 +641,23 @@ pub(crate) fn needs_cors_processing(headers: &HeaderMap) -> bool {
/// 2. Retrieves the bucket's CORS configuration
/// 3. Matches the origin against CORS rules
/// 4. Validates AllowedHeaders if request headers are present
/// 5. Returns headers to add to the response if a match is found
/// 5. Returns one of:
/// - `None`: bucket has no CORS config (or request has no valid `Origin`)
/// - `Some(empty headers)`: bucket CORS exists but request is denied / no rule matched
/// - `Some(non-empty headers)`: bucket CORS exists and request matched
///
/// Note: This function should only be called if `needs_cors_processing()` returns true
/// to avoid unnecessary overhead for non-CORS requests.
pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, headers: &HeaderMap) -> Option<HeaderMap> {
use http::HeaderValue;
fn is_credentialed_request(headers: &HeaderMap) -> bool {
headers.contains_key(http::header::AUTHORIZATION)
|| headers.contains_key(http::header::COOKIE)
|| headers.contains_key("x-amz-security-token")
|| headers.contains_key("x-amz-content-sha256")
}
// Get Origin header from request
let origin = headers.get(cors::standard::ORIGIN)?.to_str().ok()?;
@@ -659,14 +669,14 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
// Early return if no CORS rules configured
if cors_config.cors_rules.is_empty() {
return None;
return Some(HeaderMap::new());
}
// Check if method is supported and get its string representation
const SUPPORTED_METHODS: &[&str] = &["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"];
let method_str = method.as_str();
if !SUPPORTED_METHODS.contains(&method_str) {
return None;
return Some(HeaderMap::new());
}
// Use Access-Control-Request-Method if present (for preflight and non-preflight requests),
@@ -740,19 +750,37 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
let mut response_headers = HeaderMap::new();
// Access-Control-Allow-Origin
// If origin is "*", use "*", otherwise echo back the origin
// Credentials mode + wildcard allow list requires echoing the request origin.
// Browsers reject `Access-Control-Allow-Origin: *` with `credentials: include`.
let has_wildcard_origin = rule.allowed_origins.iter().any(|o| o == "*");
let credentialed_request = is_credentialed_request(headers);
let mut origin_reflected = false;
if has_wildcard_origin {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
if credentialed_request {
if let Ok(origin_value) = HeaderValue::from_str(origin) {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_value);
origin_reflected = true;
}
} else {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
}
} else if let Ok(origin_value) = HeaderValue::from_str(origin) {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_value);
origin_reflected = true;
}
// Vary: Origin (required for caching, except when using wildcard)
if !has_wildcard_origin {
// Vary: Origin whenever origin is reflected (non-"*" allow-origin).
// This prevents proxy/browser caches from reusing CORS headers across different origins.
if origin_reflected {
response_headers.insert(cors::standard::VARY, HeaderValue::from_static("Origin"));
}
// Credentials mode requires explicit allow-credentials.
if credentialed_request {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_static("true"));
}
// Access-Control-Allow-Methods (required for preflight)
if is_preflight || !rule.allowed_methods.is_empty() {
let methods_str = rule.allowed_methods.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ");
@@ -788,7 +816,7 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
return Some(response_headers);
}
None // No matching rule found
Some(HeaderMap::new()) // No matching rule found
}
/// Check if an origin matches a pattern (supports wildcards like https://*.example.com)
pub(crate) fn matches_origin_pattern(pattern: &str, origin: &str) -> bool {

View File

@@ -15,6 +15,7 @@
#[cfg(test)]
mod tests {
use crate::config::workload_profiles::WorkloadProfile;
use crate::server::cors;
use crate::storage::ecfs::FS;
use crate::storage::ecfs::RUSTFS_OWNER;
use crate::storage::{
@@ -25,13 +26,14 @@ mod tests {
};
use http::{HeaderMap, HeaderValue, StatusCode};
use rustfs_config::MI_B;
use rustfs_ecstore::bucket::{metadata::BucketMetadata, metadata_sys};
use rustfs_ecstore::set_disk::DEFAULT_READ_BUFFER_SIZE;
use rustfs_ecstore::store_api::ObjectInfo;
use rustfs_utils::http::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, RESERVED_METADATA_PREFIX_LOWER};
use rustfs_zip::CompressionFormat;
use s3s::dto::{
Delimiter, LambdaFunctionConfiguration, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
ObjectLockRetentionMode, QueueConfiguration, TopicConfiguration,
CORSConfiguration, CORSRule, Delimiter, LambdaFunctionConfiguration, ObjectLockLegalHold, ObjectLockLegalHoldStatus,
ObjectLockRetention, ObjectLockRetentionMode, QueueConfiguration, TopicConfiguration,
};
use s3s::{S3Error, S3ErrorCode, s3_error};
use time::OffsetDateTime;
@@ -1126,6 +1128,84 @@ mod tests {
// This is expected behavior - we just verify it doesn't panic
}
#[tokio::test]
async fn test_apply_cors_headers_unmatched_origin_with_cors_config() {
if metadata_sys::get_global_bucket_metadata_sys().is_none() {
eprintln!("Skipping test: GLOBAL_BucketMetadataSys not initialized");
return;
}
let bucket = "test-bucket-no-match-cors";
let mut bm = BucketMetadata::new(bucket);
bm.cors_config = Some(CORSConfiguration {
cors_rules: vec![CORSRule {
allowed_headers: Some(vec!["*".to_string()]),
allowed_methods: vec!["GET".to_string()],
allowed_origins: vec!["https://allowed.example.com".to_string()],
expose_headers: None,
id: Some("non-match-origin".to_string()),
max_age_seconds: None,
}],
});
metadata_sys::set_bucket_metadata(bucket.to_string(), bm).await.unwrap();
let mut headers = HeaderMap::new();
headers.insert(cors::standard::ORIGIN, "https://disallowed.example.com".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::GET, &headers).await;
assert!(
result.is_some(),
"Expected Some empty headers when bucket has CORS config but origin does not match any rule"
);
let result = result.unwrap();
assert!(result.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
assert!(result.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).is_none());
metadata_sys::set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
.await
.unwrap();
}
#[tokio::test]
async fn test_apply_cors_headers_credentialed_request_with_wildcard_origin() {
if metadata_sys::get_global_bucket_metadata_sys().is_none() {
eprintln!("Skipping test: GLOBAL_BucketMetadataSys not initialized");
return;
}
let bucket = "test-bucket-credentialed-cors";
let mut bm = BucketMetadata::new(bucket);
bm.cors_config = Some(CORSConfiguration {
cors_rules: vec![CORSRule {
allowed_headers: Some(vec!["*".to_string()]),
allowed_methods: vec!["GET".to_string()],
allowed_origins: vec!["*".to_string()],
expose_headers: None,
id: Some("credentialed-unit".to_string()),
max_age_seconds: None,
}],
});
metadata_sys::set_bucket_metadata(bucket.to_string(), bm).await.unwrap();
let mut headers = HeaderMap::new();
headers.insert(cors::standard::ORIGIN, "https://console.localhost".parse().unwrap());
headers.insert(cors::request::ACCESS_CONTROL_REQUEST_METHOD, "GET".parse().unwrap());
headers.insert(cors::request::ACCESS_CONTROL_REQUEST_HEADERS, "x-amz-content-sha256".parse().unwrap());
headers.insert(http::header::AUTHORIZATION, "AWS4-HMAC-SHA256 Credential=test/20260302/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256, Signature=abc".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::OPTIONS, &headers).await.unwrap();
assert_eq!(
result.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://console.localhost",
);
assert_eq!(result.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
assert_eq!(result.get(cors::standard::VARY).unwrap(), "Origin");
metadata_sys::set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
.await
.unwrap();
}
#[tokio::test]
async fn test_apply_cors_headers_unsupported_method() {
// Test with unsupported HTTP method

View File

@@ -0,0 +1,425 @@
# Excluded S3 feature tests
# =========================
#
# These tests are intentionally excluded from RustFS compatibility gating.
#
# Exclusion buckets:
# - Vendor-specific behavior not required for RustFS S3 compatibility
# - Intentionally unsupported by product decision (for example ACL authorization)
# Vendor-specific / non-portable tests
test_100_continue_error_retry
test_account_usage
test_atomic_conditional_write_1mb
test_atomic_dual_conditional_write_1mb
test_atomic_write_bucket_gone
test_bucket_get_location
test_bucket_head_extended
test_bucket_header_acl_grants
test_bucket_list_return_data
test_bucket_list_return_data_versioning
test_bucket_logging_bucket_acl_required
test_bucket_logging_bucket_auth_type
test_bucket_logging_cleanup_bucket_concurrent_deletion_j
test_bucket_logging_cleanup_bucket_concurrent_deletion_j_single
test_bucket_logging_cleanup_bucket_concurrent_deletion_s
test_bucket_logging_cleanup_bucket_concurrent_deletion_s_single
test_bucket_logging_cleanup_bucket_deletion_j
test_bucket_logging_cleanup_bucket_deletion_j_single
test_bucket_logging_cleanup_bucket_deletion_s
test_bucket_logging_cleanup_bucket_deletion_s_single
test_bucket_logging_cleanup_concurrent_disabling_j
test_bucket_logging_cleanup_concurrent_disabling_j_single
test_bucket_logging_cleanup_concurrent_disabling_s
test_bucket_logging_cleanup_concurrent_disabling_s_single
test_bucket_logging_cleanup_concurrent_updating_j
test_bucket_logging_cleanup_concurrent_updating_j_single
test_bucket_logging_cleanup_concurrent_updating_s
test_bucket_logging_cleanup_concurrent_updating_s_single
test_bucket_logging_cleanup_disabling_j
test_bucket_logging_cleanup_disabling_j_single
test_bucket_logging_cleanup_disabling_s
test_bucket_logging_cleanup_disabling_s_single
test_bucket_logging_cleanup_updating_j
test_bucket_logging_cleanup_updating_j_single
test_bucket_logging_cleanup_updating_s
test_bucket_logging_cleanup_updating_s_single
test_bucket_logging_concurrent_flush_j
test_bucket_logging_concurrent_flush_j_single
test_bucket_logging_concurrent_flush_s
test_bucket_logging_concurrent_flush_s_single
test_bucket_logging_conf_concurrent_updating_pfx_j
test_bucket_logging_conf_concurrent_updating_pfx_s
test_bucket_logging_conf_concurrent_updating_roll_j
test_bucket_logging_conf_concurrent_updating_roll_s
test_bucket_logging_conf_updating_pfx_j
test_bucket_logging_conf_updating_pfx_s
test_bucket_logging_conf_updating_roll_j
test_bucket_logging_conf_updating_roll_s
test_bucket_logging_copy_objects
test_bucket_logging_copy_objects_bucket
test_bucket_logging_copy_objects_bucket_versioned
test_bucket_logging_copy_objects_versioned
test_bucket_logging_delete_objects
test_bucket_logging_delete_objects_versioned
test_bucket_logging_event_type_j
test_bucket_logging_event_type_s
test_bucket_logging_flush_empty
test_bucket_logging_flush_j
test_bucket_logging_flush_j_single
test_bucket_logging_flush_s
test_bucket_logging_flush_s_single
test_bucket_logging_get_objects
test_bucket_logging_get_objects_versioned
test_bucket_logging_head_objects
test_bucket_logging_head_objects_versioned
test_bucket_logging_key_filter_j
test_bucket_logging_key_filter_s
test_bucket_logging_mpu_copy
test_bucket_logging_mpu_copy_versioned
test_bucket_logging_mpu_j
test_bucket_logging_mpu_s
test_bucket_logging_mpu_versioned_j
test_bucket_logging_mpu_versioned_s
test_bucket_logging_mtime
test_bucket_logging_multi_delete
test_bucket_logging_multi_delete_versioned
test_bucket_logging_multiple_prefixes
test_bucket_logging_notupdating_j
test_bucket_logging_notupdating_j_single
test_bucket_logging_notupdating_s
test_bucket_logging_notupdating_s_single
test_bucket_logging_object_acl_required
test_bucket_logging_object_meta
test_bucket_logging_part_cleanup_concurrent_deletion_j
test_bucket_logging_part_cleanup_concurrent_deletion_s
test_bucket_logging_part_cleanup_concurrent_disabling_j
test_bucket_logging_part_cleanup_concurrent_disabling_s
test_bucket_logging_part_cleanup_concurrent_updating_j
test_bucket_logging_part_cleanup_concurrent_updating_s
test_bucket_logging_part_cleanup_deletion_j
test_bucket_logging_part_cleanup_deletion_s
test_bucket_logging_part_cleanup_disabling_j
test_bucket_logging_part_cleanup_disabling_s
test_bucket_logging_part_cleanup_updating_j
test_bucket_logging_part_cleanup_updating_s
test_bucket_logging_partitioned_key
test_bucket_logging_permission_change_j
test_bucket_logging_permission_change_s
test_bucket_logging_put_and_flush
test_bucket_logging_put_concurrency
test_bucket_logging_put_objects
test_bucket_logging_put_objects_versioned
test_bucket_logging_roll_time
test_bucket_logging_simple_key
test_bucket_logging_single_prefix
test_bucket_logging_target_cleanup_j
test_bucket_logging_target_cleanup_j_single
test_bucket_logging_target_cleanup_s
test_bucket_logging_target_cleanup_s_single
test_bucket_policy_get_obj_acl_existing_tag
test_bucket_policy_get_obj_existing_tag
test_bucket_policy_get_obj_tagging_existing_tag
test_bucket_policy_put_obj_copy_source
test_bucket_policy_put_obj_copy_source_meta
test_bucket_policy_put_obj_kms_noenc
test_bucket_policy_put_obj_request_obj_tag
test_bucket_policy_put_obj_s3_incorrect_algo_sse_s3
test_bucket_policy_put_obj_s3_noenc
test_bucket_policy_put_obj_tagging_existing_tag
test_bucket_policy_set_condition_operator_end_with_IfExists
test_bucket_policy_upload_part_copy
test_bucket_recreate_new_acl
test_bucket_recreate_overwrite_acl
test_copy_object_ifmatch_failed
test_copy_object_ifmatch_good
test_copy_object_ifnonematch_failed
test_copy_object_ifnonematch_good
test_cors_presigned_get_object_tenant_v2
test_cors_presigned_get_object_v2
test_cors_presigned_put_object_tenant_v2
test_cors_presigned_put_object_v2
test_create_bucket_bucket_owner_enforced
test_create_bucket_bucket_owner_preferred
test_create_bucket_object_writer
test_delete_marker_expiration
test_delete_marker_nonversioned
test_delete_marker_suspended
test_delete_marker_versioned
test_delete_object_current_if_match
test_delete_object_current_if_match_last_modified_time
test_delete_object_current_if_match_size
test_delete_object_if_match
test_delete_object_if_match_last_modified_time
test_delete_object_if_match_size
test_delete_object_version_if_match
test_delete_object_version_if_match_last_modified_time
test_delete_object_version_if_match_size
test_delete_objects_current_if_match
test_delete_objects_current_if_match_last_modified_time
test_delete_objects_current_if_match_size
test_delete_objects_if_match
test_delete_objects_if_match_last_modified_time
test_delete_objects_if_match_size
test_delete_objects_version_if_match
test_delete_objects_version_if_match_last_modified_time
test_delete_objects_version_if_match_size
test_delete_tags_obj_public
test_encrypted_transfer_13b
test_encrypted_transfer_1MB
test_encrypted_transfer_1b
test_encrypted_transfer_1kb
test_encryption_sse_c_deny_algo_with_bucket_policy
test_encryption_sse_c_enforced_with_bucket_policy
test_encryption_sse_c_multipart_invalid_chunks_1
test_encryption_sse_c_multipart_invalid_chunks_2
test_encryption_sse_c_multipart_upload
test_encryption_sse_c_post_object_authenticated_request
test_encryption_sse_c_unaligned_multipart_upload
test_expected_bucket_owner
test_get_multipart_checksum_object_attributes
test_get_multipart_object_attributes
test_get_obj_tagging
test_get_object_attributes
test_get_paginated_multipart_object_attributes
test_get_single_multipart_object_attributes
test_get_sse_c_encrypted_object_attributes
test_get_tags_acl_public
test_head_bucket_usage
test_lifecycle_cloud_multiple_transition
test_lifecycle_cloud_transition
test_lifecycle_cloud_transition_large_obj
test_lifecycle_deletemarker_expiration
test_lifecycle_deletemarker_expiration_with_days_tag
test_lifecycle_expiration
test_lifecycle_expiration_date
test_lifecycle_expiration_header_and_tags_head
test_lifecycle_expiration_header_head
test_lifecycle_expiration_header_tags_head
test_lifecycle_expiration_newer_noncurrent
test_lifecycle_expiration_noncur_tags1
test_lifecycle_expiration_size_gt
test_lifecycle_expiration_size_lt
test_lifecycle_expiration_tags1
test_lifecycle_expiration_tags2
test_lifecycle_expiration_versioned_tags2
test_lifecycle_expiration_versioning_enabled
test_lifecycle_multipart_expiration
test_lifecycle_noncur_cloud_transition
test_lifecycle_noncur_expiration
test_lifecycle_noncur_transition
test_lifecycle_transition
test_lifecycle_transition_single_rule_multi_trans
test_lifecyclev2_expiration
test_list_buckets_anonymous
test_list_buckets_paginated
test_list_multipart_upload
test_list_multipart_upload_owner
test_multipart_checksum_sha256
test_multipart_copy_multiple_sizes
test_multipart_copy_versioned
test_multipart_get_part
test_multipart_put_current_object_if_match
test_multipart_put_current_object_if_none_match
test_multipart_put_object_if_match
test_multipart_single_get_part
test_multipart_sse_c_get_part
test_multipart_upload
test_multipart_upload_contents
test_multipart_upload_resend_part
test_multipart_upload_small
test_multipart_use_cksum_helper_crc32
test_multipart_use_cksum_helper_crc32c
test_multipart_use_cksum_helper_crc64nvme
test_multipart_use_cksum_helper_sha1
test_multipart_use_cksum_helper_sha256
test_non_multipart_get_part
test_non_multipart_sse_c_get_part
test_object_copy_canned_acl
test_object_header_acl_grants
test_object_lock_changing_mode_from_compliance
test_object_lock_changing_mode_from_governance_with_bypass
test_object_lock_changing_mode_from_governance_without_bypass
test_object_lock_delete_multipart_object_with_legal_hold_on
test_object_lock_delete_multipart_object_with_retention
test_object_lock_delete_object_with_legal_hold_off
test_object_lock_delete_object_with_legal_hold_on
test_object_lock_delete_object_with_retention
test_object_lock_delete_object_with_retention_and_marker
test_object_lock_get_legal_hold
test_object_lock_get_obj_lock
test_object_lock_get_obj_metadata
test_object_lock_get_obj_retention
test_object_lock_get_obj_retention_iso8601
test_object_lock_multi_delete_object_with_retention
test_object_lock_put_legal_hold
test_object_lock_put_legal_hold_invalid_status
test_object_lock_put_obj_lock
test_object_lock_put_obj_lock_invalid_days
test_object_lock_put_obj_lock_invalid_mode
test_object_lock_put_obj_lock_invalid_status
test_object_lock_put_obj_lock_invalid_years
test_object_lock_put_obj_lock_with_days_and_years
test_object_lock_put_obj_retention
test_object_lock_put_obj_retention_increase_period
test_object_lock_put_obj_retention_invalid_mode
test_object_lock_put_obj_retention_override_default_retention
test_object_lock_put_obj_retention_shorten_period
test_object_lock_put_obj_retention_shorten_period_bypass
test_object_lock_put_obj_retention_versionid
test_object_lock_suspend_versioning
test_object_lock_uploading_obj
test_object_raw_get_x_amz_expires_not_expired
test_object_raw_get_x_amz_expires_not_expired_tenant
test_object_raw_get_x_amz_expires_out_max_range
test_object_raw_get_x_amz_expires_out_positive_range
test_object_raw_put_authenticated_expired
test_object_read_unreadable
test_object_requestid_matches_header_on_error
test_object_set_get_unicode_metadata
test_object_write_with_chunked_transfer_encoding
test_post_object_invalid_date_format
test_post_object_invalid_request_field_value
test_post_object_missing_policy_condition
test_post_object_request_missing_policy_specified_field
test_post_object_set_key_from_filename
test_post_object_success_redirect_action
test_post_object_tags_anonymous_request
test_post_object_wrong_bucket
test_put_bucket_logging_account_j
test_put_bucket_logging_account_s
test_put_bucket_logging_extensions
test_put_bucket_logging_policy_wildcard_objects
test_put_bucket_logging_tenant_j
test_put_bucket_logging_tenant_s
test_put_bucket_ownership_bucket_owner_enforced
test_put_bucket_ownership_bucket_owner_preferred
test_put_bucket_ownership_object_writer
test_put_current_object_if_match
test_put_current_object_if_none_match
test_put_delete_tags
test_put_max_tags
test_put_modify_tags
test_put_obj_with_tags
test_put_object_current_if_match
test_put_object_if_match
test_put_tags_acl_public
test_ranged_big_request_response_code
test_ranged_request_response_code
test_ranged_request_return_trailing_bytes_response_code
test_ranged_request_skip_leading_bytes_response_code
test_read_through
test_restore_noncur_obj
test_restore_object_permanent
test_restore_object_temporary
test_sse_kms_default_post_object_authenticated_request
test_sse_kms_default_upload_1b
test_sse_kms_default_upload_1kb
test_sse_kms_default_upload_1mb
test_sse_kms_default_upload_8mb
test_sse_kms_method_head
test_sse_kms_multipart_invalid_chunks_1
test_sse_kms_multipart_invalid_chunks_2
test_sse_kms_multipart_upload
test_sse_kms_post_object_authenticated_request
test_sse_kms_present
test_sse_kms_transfer_13b
test_sse_kms_transfer_1MB
test_sse_kms_transfer_1b
test_sse_kms_transfer_1kb
test_sse_s3_default_method_head
test_sse_s3_default_multipart_upload
test_sse_s3_default_post_object_authenticated_request
test_sse_s3_default_upload_1b
test_sse_s3_default_upload_1kb
test_sse_s3_default_upload_1mb
test_sse_s3_default_upload_8mb
test_sse_s3_encrypted_upload_1b
test_sse_s3_encrypted_upload_1kb
test_sse_s3_encrypted_upload_1mb
test_sse_s3_encrypted_upload_8mb
test_versioned_object_acl_no_version_specified
test_versioning_copy_obj_version
test_versioning_multi_object_delete_with_marker_create
test_versioning_obj_create_overwrite_multipart
test_versioning_obj_suspended_copy
test_versioning_stack_delete_merkers
# Intentionally unsupported by design: ACL-related tests
test_object_raw_get
test_object_raw_get_bucket_gone
test_object_raw_get_object_gone
test_object_copy_not_owned_object_bucket
test_100_continue
test_cors_origin_response
test_cors_origin_wildcard
test_bucket_policy_put_obj_grant
test_bucket_list_objects_anonymous
test_bucket_listv2_objects_anonymous
test_post_object_anonymous_request
test_post_object_set_success_code
test_post_object_set_invalid_success_code
test_access_bucket_private_object_private
test_access_bucket_private_object_publicread
test_access_bucket_private_object_publicreadwrite
test_access_bucket_private_objectv2_private
test_access_bucket_private_objectv2_publicread
test_access_bucket_private_objectv2_publicreadwrite
test_access_bucket_publicread_object_private
test_access_bucket_publicread_object_publicread
test_access_bucket_publicread_object_publicreadwrite
test_access_bucket_publicreadwrite_object_private
test_access_bucket_publicreadwrite_object_publicread
test_access_bucket_publicreadwrite_object_publicreadwrite
test_object_anon_put_write_access
test_get_public_acl_bucket_policy_status
test_get_authpublic_acl_bucket_policy_status
test_get_publicpolicy_acl_bucket_policy_status
test_get_nonpublicpolicy_acl_bucket_policy_status
test_block_public_put_bucket_acls
test_block_public_object_canned_acls
test_ignore_public_acls
test_bucket_policy_acl
test_bucketv2_policy_acl
test_bucket_policy_put_obj_acl
test_object_presigned_put_object_with_acl
test_object_put_acl_mtime
test_versioned_object_acl
test_object_presigned_put_object_with_acl_tenant
test_bucket_acl_canned
test_bucket_acl_canned_authenticatedread
test_bucket_acl_canned_during_create
test_bucket_acl_canned_private_to_private
test_bucket_acl_canned_publicreadwrite
test_bucket_acl_default
test_bucket_acl_grant_email
test_bucket_acl_grant_email_not_exist
test_bucket_acl_grant_nonexist_user
test_bucket_acl_grant_userid_fullcontrol
test_bucket_acl_grant_userid_read
test_bucket_acl_grant_userid_readacp
test_bucket_acl_grant_userid_write
test_bucket_acl_grant_userid_writeacp
test_bucket_acl_revoke_all
test_bucket_concurrent_set_canned_acl
test_object_acl
test_object_acl_canned
test_object_acl_canned_authenticatedread
test_object_acl_canned_bucketownerfullcontrol
test_object_acl_canned_bucketownerread
test_object_acl_canned_during_create
test_object_acl_canned_publicreadwrite
test_object_acl_default
test_object_acl_full_control_verify_attributes
test_object_acl_full_control_verify_owner
test_object_acl_read
test_object_acl_readacp
test_object_acl_write
test_object_acl_writeacp
test_put_bucket_acl_grant_group_read
test_object_raw_authenticated_bucket_acl
test_object_raw_authenticated_object_acl
test_object_raw_get_bucket_acl
test_object_raw_get_object_acl
test_cors_presigned_put_object_with_acl
test_cors_presigned_put_object_tenant_with_acl

View File

@@ -3,6 +3,7 @@
#
# These tests SHOULD PASS on RustFS for standard S3 API compatibility.
# Run these tests to verify RustFS S3 compatibility.
# Intentionally excluded tests (for example ACL authorization tests) are tracked in excluded_tests.txt.
#
# Covered operations:
# - Bucket: Create, Delete, List, Head, GetLocation
@@ -23,7 +24,6 @@
# - SSE-KMS: KMS-related edge cases
# - Bucket Policy: Multipart upload authorization, SSE condition keys, grant header conditions
# - Versioning: Concurrent multi-object delete
# - ACL: Bucket and Object ACL
# - Object Copy: Same/cross bucket, metadata, versioning
# - POST Object: HTML form upload
# - Raw requests: Authenticated and anonymous
@@ -36,7 +36,6 @@
# - DeleteObject: Proper NoSuchBucket for deleted buckets
# - Multipart Copy: InvalidRange when CopySourceRange exceeds source size
#
# Total: 396 tests
test_basic_key_count
test_bucket_create_naming_bad_short_one
@@ -120,13 +119,6 @@ test_bucketv2_notexist
test_bucketv2_policy_another_bucket
test_get_bucket_policy_status
test_get_nonpublicpolicy_principal_bucket_policy_status
test_get_public_acl_bucket_policy_status
test_get_authpublic_acl_bucket_policy_status
test_get_publicpolicy_acl_bucket_policy_status
test_get_nonpublicpolicy_acl_bucket_policy_status
test_block_public_put_bucket_acls
test_block_public_object_canned_acls
test_ignore_public_acls
test_set_get_del_bucket_policy
test_get_object_ifmatch_good
test_get_object_ifmodifiedsince_good
@@ -146,7 +138,6 @@ test_multipart_upload_overwrite_existing_object
test_multipart_upload_size_too_small
test_object_copy_bucket_not_found
test_object_copy_key_not_found
test_object_copy_not_owned_object_bucket
test_object_head_zero_bytes
test_get_obj_head_tagging
test_object_metadata_replaced_on_put
@@ -223,17 +214,10 @@ test_get_undefined_public_block
# Bucket policy tests
test_bucketv2_policy
test_bucket_policy_acl
test_bucketv2_policy_acl
test_bucket_policy_another_bucket
test_bucket_policy_allow_notprincipal
test_bucket_policy_multipart
test_bucket_policy_put_obj_acl
test_bucket_policy_put_obj_grant
test_bucket_policy_put_obj_kms_s3
test_bucket_policy_put_obj_s3_kms
test_object_presigned_put_object_with_acl
test_object_put_acl_mtime
# Object ownership
test_create_bucket_no_ownership_controls
@@ -276,7 +260,6 @@ test_object_lock_put_obj_retention_invalid_bucket
# Versioning tests
test_get_versioned_object_attributes
test_versioned_concurrent_object_create_and_remove
test_versioned_object_acl
test_versioning_bucket_create_suspend
test_versioning_bucket_atomic_upload_return_version_id
test_versioning_bucket_multipart_upload_return_version_id
@@ -294,54 +277,8 @@ test_versioning_obj_plain_null_version_removal
test_versioning_obj_suspend_versions
# Tenant and presigned tests
test_object_presigned_put_object_with_acl_tenant
# ACL tests (bucket and object)
test_access_bucket_private_object_private
test_access_bucket_private_object_publicread
test_access_bucket_private_object_publicreadwrite
test_access_bucket_private_objectv2_private
test_access_bucket_private_objectv2_publicread
test_access_bucket_private_objectv2_publicreadwrite
test_access_bucket_publicread_object_private
test_access_bucket_publicread_object_publicread
test_access_bucket_publicread_object_publicreadwrite
test_access_bucket_publicreadwrite_object_private
test_access_bucket_publicreadwrite_object_publicread
test_access_bucket_publicreadwrite_object_publicreadwrite
test_bucket_acl_canned
test_bucket_acl_canned_authenticatedread
test_bucket_acl_canned_during_create
test_bucket_acl_canned_private_to_private
test_bucket_acl_canned_publicreadwrite
test_bucket_acl_default
test_bucket_acl_grant_email
test_bucket_acl_grant_email_not_exist
test_bucket_acl_grant_nonexist_user
test_bucket_acl_grant_userid_fullcontrol
test_bucket_acl_grant_userid_read
test_bucket_acl_grant_userid_readacp
test_bucket_acl_grant_userid_write
test_bucket_acl_grant_userid_writeacp
test_bucket_acl_revoke_all
test_bucket_concurrent_set_canned_acl
test_object_acl
test_object_acl_canned
test_object_acl_canned_authenticatedread
test_object_acl_canned_bucketownerfullcontrol
test_object_acl_canned_bucketownerread
test_object_acl_canned_during_create
test_object_acl_canned_publicreadwrite
test_object_acl_default
test_object_acl_full_control_verify_attributes
test_object_acl_full_control_verify_owner
test_object_acl_read
test_object_acl_readacp
test_object_acl_write
test_object_acl_writeacp
test_object_anon_put
test_object_anon_put_write_access
test_put_bucket_acl_grant_group_read
# Bucket creation and naming
test_bucket_create_exists
@@ -356,7 +293,6 @@ test_bucket_list_delimiter_prefix
test_bucket_list_delimiter_prefix_underscore
test_bucket_list_many
test_bucket_list_maxkeys_one
test_bucket_list_objects_anonymous
test_bucket_list_objects_anonymous_fail
test_bucket_list_unordered
test_bucket_listv2_both_continuationtoken_startafter
@@ -364,14 +300,12 @@ test_bucket_listv2_delimiter_prefix
test_bucket_listv2_delimiter_prefix_underscore
test_bucket_listv2_many
test_bucket_listv2_maxkeys_one
test_bucket_listv2_objects_anonymous
test_bucket_listv2_objects_anonymous_fail
test_bucket_listv2_unordered
# Object copy tests
test_object_copy_16m
test_object_copy_diff_bucket
test_object_copy_not_owned_bucket
test_object_copy_replacing_metadata
test_object_copy_retaining_metadata
test_object_copy_same_bucket
@@ -388,20 +322,12 @@ test_object_content_encoding_aws_chunked
# Raw request tests
test_object_raw_authenticated
test_object_raw_authenticated_bucket_acl
test_object_raw_authenticated_bucket_gone
test_object_raw_authenticated_object_acl
test_object_raw_authenticated_object_gone
test_object_raw_get
test_object_raw_get_bucket_acl
test_object_raw_get_bucket_gone
test_object_raw_get_object_acl
test_object_raw_get_object_gone
test_object_raw_get_x_amz_expires_out_range_zero
test_object_raw_response_headers
# POST Object tests
test_post_object_anonymous_request
test_post_object_authenticated_no_content_type
test_post_object_authenticated_request
test_post_object_authenticated_request_bad_access_key
@@ -420,8 +346,6 @@ test_post_object_missing_content_length_argument
test_post_object_missing_expires_condition
test_post_object_missing_signature
test_post_object_no_key_specified
test_post_object_set_invalid_success_code
test_post_object_set_success_code
test_post_object_upload_larger_than_chunk
test_post_object_upload_size_below_minimum
test_post_object_upload_size_limit_exceeded
@@ -474,18 +398,13 @@ test_object_checksum_crc64nvme
# CORS tests
test_set_cors
test_cors_origin_response
test_cors_origin_wildcard
test_cors_header_option
test_cors_presigned_get_object
test_cors_presigned_get_object_tenant
test_cors_presigned_put_object
test_cors_presigned_put_object_with_acl
test_cors_presigned_put_object_tenant
test_cors_presigned_put_object_tenant_with_acl
# HTTP and DeleteObject tests
test_100_continue
test_abort_multipart_upload_not_found
test_post_object_tags_authenticated_request
test_object_delete_key_bucket_gone

View File

@@ -1,8 +1,9 @@
# Non-standard S3 tests (Ceph/RGW/MinIO specific)
# ================================================
# Legacy non-standard S3 test list (compatibility fallback)
# =========================================================
#
# This file is kept for backward compatibility.
# Active exclusion classification now uses excluded_tests.txt.
# These tests use vendor-specific extensions not part of AWS S3 API.
# They are PERMANENTLY EXCLUDED from RustFS compatibility testing.
#
# Exclusion reasons:
# - fails_on_aws marker: Ceph-specific features

View File

@@ -61,9 +61,9 @@ log_error() {
# Test Classification Files
# =============================================================================
# Tests are classified into three categories stored in text files:
# - non_standard_tests.txt: Ceph/RGW specific tests (permanently excluded)
# - unimplemented_tests.txt: Standard S3 features not yet implemented
# - implemented_tests.txt: Tests that should pass on RustFS
# - unimplemented_tests.txt: Standard S3 features planned but not yet implemented
# - excluded_tests.txt: Tests intentionally excluded from RustFS gating
#
# By default, only tests listed in implemented_tests.txt are run.
# Use TESTEXPR env var to override and run custom test selection.
@@ -72,8 +72,9 @@ log_error() {
# Test list files location
TEST_LISTS_DIR="${SCRIPT_DIR}"
IMPLEMENTED_TESTS_FILE="${TEST_LISTS_DIR}/implemented_tests.txt"
NON_STANDARD_TESTS_FILE="${TEST_LISTS_DIR}/non_standard_tests.txt"
UNIMPLEMENTED_TESTS_FILE="${TEST_LISTS_DIR}/unimplemented_tests.txt"
EXCLUDED_TESTS_FILE="${TEST_LISTS_DIR}/excluded_tests.txt"
LEGACY_NON_STANDARD_TESTS_FILE="${TEST_LISTS_DIR}/non_standard_tests.txt"
# =============================================================================
# build_testexpr_from_file: Read test names from file and build pytest -k expr
@@ -110,7 +111,7 @@ build_testexpr_from_file() {
# MARKEXPR: pytest marker expression (safety net for marker-based filtering)
# =============================================================================
# Even though we use file-based test selection, we keep marker exclusions
# as a safety net to ensure no non-standard tests slip through.
# as a safety net to ensure excluded tests do not slip through.
# =============================================================================
if [[ -z "${MARKEXPR:-}" ]]; then
# Minimal marker exclusions as safety net (file-based filtering is primary)
@@ -121,7 +122,7 @@ fi
# TESTEXPR: pytest -k expression to select specific tests
# =============================================================================
# By default, builds an inclusion expression from implemented_tests.txt,
# combined with an exclusion expression from non_standard_tests.txt and
# combined with an exclusion expression from excluded_tests.txt and
# unimplemented_tests.txt to prevent substring-matching collisions.
#
# For example, "test_object_raw_get" in the include list would also match
@@ -144,10 +145,16 @@ if [[ -z "${TESTEXPR:-}" ]]; then
TEST_COUNT=$(grep -v '^#' "${IMPLEMENTED_TESTS_FILE}" | grep -v '^[[:space:]]*$' | wc -l | xargs)
log_info "Loaded ${TEST_COUNT} tests from implemented_tests.txt"
# Build exclusion expression from non-standard and unimplemented lists
# Build exclusion expression from excluded and unimplemented lists
# to guard against pytest -k substring matching false positives
EXCLUDE_EXPR=""
for exclude_file in "${NON_STANDARD_TESTS_FILE}" "${UNIMPLEMENTED_TESTS_FILE}"; do
EXCLUDE_FILES=("${EXCLUDED_TESTS_FILE}" "${UNIMPLEMENTED_TESTS_FILE}")
if [[ ! -f "${EXCLUDED_TESTS_FILE}" && -f "${LEGACY_NON_STANDARD_TESTS_FILE}" ]]; then
log_warn "excluded_tests.txt not found, fallback to legacy non_standard_tests.txt"
EXCLUDE_FILES=("${LEGACY_NON_STANDARD_TESTS_FILE}" "${UNIMPLEMENTED_TESTS_FILE}")
fi
for exclude_file in "${EXCLUDE_FILES[@]}"; do
if [[ -f "${exclude_file}" ]]; then
FILE_EXPR=$(build_testexpr_from_file "${exclude_file}")
if [[ -n "${FILE_EXPR}" ]]; then
@@ -161,7 +168,7 @@ if [[ -z "${TESTEXPR:-}" ]]; then
if [[ -n "${EXCLUDE_EXPR}" ]]; then
TESTEXPR="(${INCLUDE_EXPR}) and not (${EXCLUDE_EXPR})"
log_info "Added exclusion guard from non_standard + unimplemented lists"
log_info "Added exclusion guard from excluded + unimplemented lists"
else
TESTEXPR="${INCLUDE_EXPR}"
fi
@@ -246,9 +253,9 @@ Environment Variables:
Final path: \${DATA_ROOT}/test-data/\${CONTAINER_NAME}
Test Classification Files (in scripts/s3-tests/):
implemented_tests.txt - Tests that should pass (run by default)
unimplemented_tests.txt - Standard S3 features not yet implemented
non_standard_tests.txt - Ceph/RGW specific tests (permanently excluded)
implemented_tests.txt - Implemented tests (run by default)
unimplemented_tests.txt - Standard S3 tests planned but not implemented
excluded_tests.txt - Tests intentionally excluded from RustFS gating
Notes:
- Tests are loaded from implemented_tests.txt by default

View File

@@ -1,8 +1,8 @@
# Unimplemented S3 feature tests
# ==============================
#
# These tests cover STANDARD S3 features not yet implemented in RustFS.
# They are TEMPORARILY EXCLUDED and should be enabled as features are added.
# These tests cover STANDARD S3 features planned but not yet implemented in RustFS.
# They are TEMPORARILY EXCLUDED and should move to implemented_tests.txt when done.
#
# Unimplemented features:
# - Bucket Logging: Access logging
@@ -12,6 +12,8 @@
# Failed tests
test_bucket_create_delete_bucket_ownership
test_bucket_logging_owner
test_object_copy_not_owned_bucket
test_bucket_policy_multipart
test_post_object_upload_checksum
test_put_bucket_logging
test_put_bucket_logging_errors