feat(s3): advance parity coverage (#2278)

This commit is contained in:
weisd
2026-03-24 17:29:33 +08:00
committed by GitHub
parent 8aa59b12cb
commit 28f57b228c
60 changed files with 15509 additions and 412 deletions
+2 -1
View File
@@ -89,6 +89,7 @@ reqwest = { workspace = true }
socket2 = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "process", "io-util"] }
tokio-rustls = { workspace = true }
aws-sdk-s3 = { workspace = true }
tokio-stream.workspace = true
tokio-util.workspace = true
tonic = { workspace = true }
@@ -99,6 +100,7 @@ tower-http = { workspace = true, features = ["trace", "compression-full", "cors"
bytes = { workspace = true }
flatbuffers.workspace = true
rmp-serde.workspace = true
rustfs-signer.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_urlencoded = { workspace = true }
@@ -167,7 +169,6 @@ pprof = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
serial_test = { workspace = true }
tempfile = { workspace = true }
aws-sdk-s3 = { workspace = true }
aws-config = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
+39 -26
View File
@@ -23,7 +23,7 @@ use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_credentials::Credentials;
use rustfs_ecstore::bucket::bucket_target_sys::BucketTargetSys;
use rustfs_ecstore::bucket::bucket_target_sys::{BucketTargetError, BucketTargetSys};
use rustfs_ecstore::bucket::metadata::BUCKET_TARGETS_FILE;
use rustfs_ecstore::bucket::metadata_sys;
use rustfs_ecstore::bucket::metadata_sys::get_replication_config;
@@ -53,6 +53,22 @@ fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
params
}
fn map_bucket_target_error(err: BucketTargetError) -> S3Error {
match err {
BucketTargetError::BucketRemoteTargetNotFound { .. }
| BucketTargetError::BucketRemoteArnTypeInvalid { .. }
| BucketTargetError::BucketRemoteAlreadyExists { .. }
| BucketTargetError::BucketRemoteArnInvalid { .. }
| BucketTargetError::RemoteTargetConnectionErr { .. }
| BucketTargetError::BucketReplicationSourceNotVersioned { .. }
| BucketTargetError::BucketRemoteTargetNotVersioned { .. }
| BucketTargetError::BucketRemoteRemoveDisallowed { .. } => {
S3Error::with_message(S3ErrorCode::InvalidRequest, err.to_string())
}
BucketTargetError::Io(io_err) => S3Error::with_message(S3ErrorCode::InternalError, io_err.to_string()),
}
}
pub fn register_replication_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
Method::GET,
@@ -200,7 +216,7 @@ impl Operation for SetRemoteTargetHandler {
})?;
let Ok(target_url) = remote_target.url() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Invalid target url".to_string()));
return Err(s3_error!(InvalidRequest, "invalid target url"));
};
let same_target = rustfs_utils::net::is_local_host(
@@ -232,7 +248,7 @@ impl Operation for SetRemoteTargetHandler {
}
if remote_target.arn.is_empty() {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "ARN is empty".to_string()));
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "ARN is empty".to_string()));
}
if update {
@@ -240,7 +256,7 @@ impl Operation for SetRemoteTargetHandler {
.get_remote_bucket_target_by_arn(bucket, &remote_target.arn)
.await
else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Target not found".to_string()));
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Target not found".to_string()));
};
target.credentials = remote_target.credentials;
@@ -262,7 +278,7 @@ impl Operation for SetRemoteTargetHandler {
bucket_target_sys
.set_target(bucket, &remote_target, update)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
.map_err(map_bucket_target_error)?;
let targets = bucket_target_sys.list_bucket_targets(bucket).await.map_err(|e| {
error!("Failed to list bucket targets: {}", e);
@@ -302,20 +318,17 @@ impl Operation for ListRemoteTargetHandler {
if let Some(bucket) = queries.get("bucket") {
if bucket.is_empty() {
error!("bucket parameter is empty");
return Ok(S3Response::new((
StatusCode::BAD_REQUEST,
Body::from("Bucket parameter is required".to_string()),
)));
return Err(s3_error!(InvalidRequest, "bucket is required"));
}
let Some(store) = new_object_layer_fn() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
};
if let Err(err) = store.get_bucket_info(bucket, &BucketOptions::default()).await {
error!("Error fetching bucket info: {:?}", err);
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("Invalid bucket".to_string()))));
}
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let sys = BucketTargetSys::get();
let targets = sys.list_targets(bucket, "").await;
@@ -355,31 +368,31 @@ impl Operation for RemoveRemoteTargetHandler {
debug!("remove remote target called");
let queries = extract_query_params(&req.uri);
let Some(bucket) = queries.get("bucket") else {
return Ok(S3Response::new((
StatusCode::BAD_REQUEST,
Body::from("Bucket parameter is required".to_string()),
)));
return Err(s3_error!(InvalidRequest, "bucket is required"));
};
if bucket.is_empty() {
return Err(s3_error!(InvalidRequest, "bucket is required"));
}
let Some(arn_str) = queries.get("arn") else {
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("ARN is required".to_string()))));
return Err(s3_error!(InvalidRequest, "arn is required"));
};
if arn_str.is_empty() {
return Err(s3_error!(InvalidRequest, "arn is required"));
};
let Some(store) = new_object_layer_fn() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
};
if let Err(err) = store.get_bucket_info(bucket, &BucketOptions::default()).await {
error!("Error fetching bucket info: {:?}", err);
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("Invalid bucket".to_string()))));
}
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let sys = BucketTargetSys::get();
sys.remove_target(bucket, arn_str).await.map_err(|e| {
error!("Failed to remove target: {}", e);
S3Error::with_message(S3ErrorCode::InternalError, "Failed to remove target".to_string())
})?;
sys.remove_target(bucket, arn_str).await.map_err(map_bucket_target_error)?;
let targets = sys.list_bucket_targets(bucket).await.map_err(|e| {
error!("Failed to list bucket targets: {}", e);
File diff suppressed because it is too large Load Diff
+37 -13
View File
@@ -34,6 +34,7 @@ use rustfs_ecstore::bucket::{
metadata_sys,
quota::QuotaOperation,
replication::{get_must_replicate_options, must_replicate, schedule_replication},
versioning_sys::BucketVersioningSys,
};
use rustfs_ecstore::client::object_api_utils::to_s3s_etag;
use rustfs_ecstore::compress::is_compressible;
@@ -315,10 +316,13 @@ impl DefaultMultipartUsecase {
server_side_encryption
);
let ssekms_key_id = multipart_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.cloned();
let ssekms_key_id = match server_side_encryption.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => multipart_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.cloned(),
_ => None,
};
info!(
"TDD: Extracted encryption info - SSE: {:?}, KMS Key: {:?}",
@@ -367,7 +371,12 @@ impl DefaultMultipartUsecase {
let manager = get_concurrency_manager();
let mpu_bucket = bucket.clone();
let mpu_key = key.clone();
let mpu_version = obj_info.version_id.map(|v| v.to_string());
let raw_mpu_version = obj_info.version_id.map(|v| v.to_string());
let mpu_version = if BucketVersioningSys::prefix_enabled(&bucket, &key).await {
raw_mpu_version.clone()
} else {
None
};
let mpu_version_clone = mpu_version.clone();
let mpu_version_for_event = mpu_version.clone();
tokio::spawn(async move {
@@ -495,6 +504,14 @@ impl DefaultMultipartUsecase {
..
} = req.input.clone();
let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?);
let ssekms_key_id = ssekms_key_id.or_else(|| {
req.headers
.get("x-amz-server-side-encryption-aws-kms-key-id")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
});
// Validate storage class if provided
if let Some(ref storage_class) = storage_class
&& !is_valid_storage_class(storage_class.as_str())
@@ -708,10 +725,13 @@ impl DefaultMultipartUsecase {
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
})
.transpose()?;
let key_id = fi
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string());
let key_id = match sse.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => fi
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string()),
_ => None,
};
(sse, key_id)
};
let part_key = fi.user_defined.get("x-rustfs-encryption-key").cloned();
@@ -1045,6 +1065,7 @@ impl DefaultMultipartUsecase {
sse_customer_key_md5: copy_source_sse_customer_key_md5.as_ref(),
part_number: None,
parts: &src_info.parts,
etag: src_info.etag.as_deref(),
};
if let Some(material) = sse_decryption(src_decryption_request).await? {
@@ -1073,10 +1094,13 @@ impl DefaultMultipartUsecase {
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
})
.transpose()?;
let ssekms_key_id = mp_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string());
let ssekms_key_id = match server_side_encryption.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => mp_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string()),
_ => None,
};
let part_key = mp_info.user_defined.get("x-rustfs-encryption-key").cloned();
let part_nonce = mp_info.user_defined.get("x-rustfs-encryption-iv").cloned();
let encryption_request = EncryptionRequest {
File diff suppressed because it is too large Load Diff
+43
View File
@@ -13,12 +13,54 @@
// limitations under the License.
use crate::app::context::resolve_server_config;
use rustfs_ecstore::event_notification::{EventArgs as EcstoreEventArgs, register_event_dispatch_hook};
use rustfs_notify::EventArgs as NotifyEventArgs;
use rustfs_s3_common::EventName;
use tokio::spawn;
use tracing::{error, info, instrument, warn};
fn server_config_from_context() -> Option<rustfs_ecstore::config::Config> {
resolve_server_config()
}
fn convert_ecstore_event_args(args: EcstoreEventArgs) -> NotifyEventArgs {
let version_id = args.object.version_id.map(|v| v.to_string()).unwrap_or_default();
let (host, port) = match args.host.rsplit_once(':') {
Some((host, port)) => match port.parse::<u16>() {
Ok(port) => (host.to_string(), port),
Err(_) => (args.host, 0),
},
None => (args.host, 0),
};
let req_params = args.req_params.into_iter().collect();
let resp_elements = args.resp_elements.into_iter().collect();
NotifyEventArgs {
event_name: EventName::from(args.event_name.as_str()),
bucket_name: args.bucket_name,
object: args.object,
req_params,
resp_elements,
version_id,
host,
port,
user_agent: args.user_agent,
}
}
fn install_ecstore_event_dispatch_hook() {
let installed = register_event_dispatch_hook(|args| {
let notify_args = convert_ecstore_event_args(args);
spawn(async move {
rustfs_notify::notifier_global::notify(notify_args).await;
});
});
if !installed {
warn!("ECStore event dispatch hook was already registered");
}
}
/// Shuts down the event notifier system gracefully
pub(crate) async fn shutdown_event_notifier() {
info!("Shutting down event notifier system...");
@@ -67,6 +109,7 @@ pub(crate) async fn init_event_notifier() {
if let Err(e) = rustfs_notify::initialize(server_config).await {
error!("Failed to initialize event notifier system: {}", e);
} else {
install_ecstore_event_dispatch_hook();
info!(
target: "rustfs::main::init_event_notifier",
"Event notifier system initialized successfully."
+227 -22
View File
@@ -29,6 +29,7 @@ use rustfs_utils::http::AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE;
use s3s::access::{S3Access, S3AccessContext};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error};
use std::collections::HashMap;
use url::Url;
#[derive(Default, Clone, Debug)]
pub(crate) struct ReqInfo {
@@ -41,6 +42,9 @@ pub(crate) struct ReqInfo {
pub region: Option<s3s::region::Region>,
}
#[derive(Clone, Debug)]
pub(crate) struct PostObjectRequestMarker;
pub(crate) fn req_info_ref<T>(req: &S3Request<T>) -> S3Result<&ReqInfo> {
req.extensions
.get::<ReqInfo>()
@@ -359,6 +363,35 @@ fn put_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::PutBucketPolicyAction)
}
fn post_object_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn complete_multipart_upload_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn list_parts_authorize_action() -> Action {
Action::S3Action(S3Action::ListMultipartUploadPartsAction)
}
fn validate_post_object_success_controls(input: &PostObjectInput) -> S3Result<()> {
if let Some(status) = input.success_action_status
&& !matches!(status, 200 | 201 | 204)
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_status must be one of 200, 201, or 204"));
}
if let Some(redirect) = input.success_action_redirect.as_deref().map(str::trim)
&& !redirect.is_empty()
&& Url::parse(redirect).is_err()
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_redirect must be a valid absolute URL"));
}
Ok(())
}
#[async_trait::async_trait]
impl S3Access for FS {
// /// Checks whether the current request has accesses to the resources.
@@ -437,15 +470,23 @@ impl S3Access for FS {
/// Checks whether the AbortMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn abort_multipart_upload(&self, _req: &mut S3Request<AbortMultipartUploadInput>) -> S3Result<()> {
Ok(())
async fn abort_multipart_upload(&self, req: &mut S3Request<AbortMultipartUploadInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, Action::S3Action(S3Action::AbortMultipartUploadAction)).await
}
/// Checks whether the CompleteMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn complete_multipart_upload(&self, _req: &mut S3Request<CompleteMultipartUploadInput>) -> S3Result<()> {
Ok(())
async fn complete_multipart_upload(&self, req: &mut S3Request<CompleteMultipartUploadInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, complete_multipart_upload_authorize_action()).await
}
/// Checks whether the CopyObject request has accesses to the resources.
@@ -617,8 +658,11 @@ impl S3Access for FS {
/// Checks whether the DeleteBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_website(&self, _req: &mut S3Request<DeleteBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn delete_bucket_website(&self, req: &mut S3Request<DeleteBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the DeleteObject request has accesses to the resources.
@@ -708,9 +752,12 @@ impl S3Access for FS {
/// This method returns `Ok(())` by default.
async fn get_bucket_accelerate_configuration(
&self,
_req: &mut S3Request<GetBucketAccelerateConfigurationInput>,
req: &mut S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
Ok(())
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketAcl request has accesses to the resources.
@@ -866,8 +913,11 @@ impl S3Access for FS {
/// Checks whether the GetBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_request_payment(&self, _req: &mut S3Request<GetBucketRequestPaymentInput>) -> S3Result<()> {
Ok(())
async fn get_bucket_request_payment(&self, req: &mut S3Request<GetBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketTagging request has accesses to the resources.
@@ -893,8 +943,11 @@ impl S3Access for FS {
/// Checks whether the GetBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_website(&self, _req: &mut S3Request<GetBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn get_bucket_website(&self, req: &mut S3Request<GetBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetObject request has accesses to the resources.
@@ -1142,8 +1195,25 @@ impl S3Access for FS {
/// Checks whether the ListParts request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_parts(&self, _req: &mut S3Request<ListPartsInput>) -> S3Result<()> {
Ok(())
async fn list_parts(&self, req: &mut S3Request<ListPartsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, list_parts_authorize_action()).await
}
/// Checks whether the PostObject request has accesses to the resources.
async fn post_object(&self, req: &mut S3Request<PostObjectInput>) -> S3Result<()> {
validate_post_object_success_controls(&req.input)?;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
req.extensions.insert(PostObjectRequestMarker);
authorize_request(req, post_object_authorize_action()).await
}
/// Checks whether the PutBucketAccelerateConfiguration request has accesses to the resources.
@@ -1151,9 +1221,12 @@ impl S3Access for FS {
/// This method returns `Ok(())` by default.
async fn put_bucket_accelerate_configuration(
&self,
_req: &mut S3Request<PutBucketAccelerateConfigurationInput>,
req: &mut S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
Ok(())
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketAcl request has accesses to the resources.
@@ -1286,8 +1359,11 @@ impl S3Access for FS {
/// Checks whether the PutBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_request_payment(&self, _req: &mut S3Request<PutBucketRequestPaymentInput>) -> S3Result<()> {
Ok(())
async fn put_bucket_request_payment(&self, req: &mut S3Request<PutBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketTagging request has accesses to the resources.
@@ -1313,8 +1389,11 @@ impl S3Access for FS {
/// Checks whether the PutBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_website(&self, _req: &mut S3Request<PutBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn put_bucket_website(&self, req: &mut S3Request<PutBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutObject request has accesses to the resources.
@@ -1444,8 +1523,35 @@ impl S3Access for FS {
/// Checks whether the UploadPartCopy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn upload_part_copy(&self, _req: &mut S3Request<UploadPartCopyInput>) -> S3Result<()> {
Ok(())
async fn upload_part_copy(&self, req: &mut S3Request<UploadPartCopyInput>) -> S3Result<()> {
{
let (src_bucket, src_key, version_id) = match &req.input.copy_source {
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Bucket { bucket, key, version_id } => {
(bucket.to_string(), key.to_string(), version_id.as_ref().map(|v| v.to_string()))
}
};
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(src_bucket.clone());
req_info.object = Some(src_key.clone());
req_info.version_id = version_id.clone();
let tag_conds = self
.fetch_tag_conditions(&src_bucket, &src_key, version_id.as_deref(), "upload_part_copy_src")
.await?;
req.extensions.insert(tag_conds);
authorize_request(req, Action::S3Action(S3Action::GetObjectAction)).await?;
}
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = None;
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await
}
/// Checks whether the WriteGetObjectResponse request has accesses to the resources.
@@ -1459,6 +1565,7 @@ impl S3Access for FS {
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, Method, Uri};
use std::collections::HashMap;
#[test]
@@ -1471,6 +1578,74 @@ mod tests {
assert_eq!(put_bucket_policy_authorize_action(), Action::S3Action(S3Action::PutBucketPolicyAction));
}
#[test]
fn post_object_uses_put_object_action() {
assert_eq!(post_object_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn complete_multipart_upload_uses_put_object_action() {
assert_eq!(complete_multipart_upload_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn list_parts_uses_list_multipart_upload_parts_action() {
assert_eq!(list_parts_authorize_action(), Action::S3Action(S3Action::ListMultipartUploadPartsAction));
}
#[test]
fn validate_post_object_success_controls_accepts_supported_status_codes() {
for status in [200, 201, 204] {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(status))
.build()
.expect("post object input should build");
assert!(
validate_post_object_success_controls(&input).is_ok(),
"status {status} should be accepted"
);
}
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_status_code() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(202))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("status 202 should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
#[test]
fn validate_post_object_success_controls_accepts_empty_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("".to_string()))
.build()
.expect("post object input should build");
assert!(validate_post_object_success_controls(&input).is_ok());
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("://invalid-url".to_string()))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("invalid redirect should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
/// Object tag conditions must use keys like ExistingObjectTag/<tag-key> so that
/// bucket policy conditions (e.g. s3:ExistingObjectTag/security) are evaluated correctly.
#[test]
@@ -1515,4 +1690,34 @@ mod tests {
&Action::S3Action(S3Action::DeleteBucketPolicyAction)
));
}
#[tokio::test]
async fn post_object_marks_request_extensions() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.expect("post object input should build");
let mut req = S3Request {
input,
method: Method::POST,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.extensions.insert(ReqInfo::default());
let fs = FS::new();
let _ = fs.post_object(&mut req).await;
assert!(
req.extensions.get::<PostObjectRequestMarker>().is_some(),
"post object request should carry the marker for downstream handling"
);
}
}
+172 -4
View File
@@ -16,8 +16,13 @@ use crate::app::bucket_usecase::DefaultBucketUsecase;
use crate::app::multipart_usecase::DefaultMultipartUsecase;
use crate::app::object_usecase::DefaultObjectUsecase;
use rustfs_ecstore::{
bucket::tagging::decode_tags_to_map,
error::{is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found},
bucket::{
metadata::{BUCKET_ACCELERATE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG, BUCKET_WEBSITE_CONFIG},
metadata_sys,
tagging::decode_tags_to_map,
utils::serialize,
},
error::{StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found},
new_object_layer_fn,
store_api::{BucketOperations, BucketOptions, ObjectOperations, ObjectOptions},
};
@@ -265,6 +270,26 @@ impl S3 for FS {
usecase.execute_delete_bucket_tagging(req).await
}
async fn delete_bucket_website(
&self,
req: S3Request<DeleteBucketWebsiteInput>,
) -> S3Result<S3Response<DeleteBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
metadata_sys::delete(&req.input.bucket, BUCKET_WEBSITE_CONFIG)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(DeleteBucketWebsiteOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn delete_public_access_block(
&self,
@@ -303,6 +328,29 @@ impl S3 for FS {
usecase.execute_get_bucket_acl(req).await
}
async fn get_bucket_accelerate_configuration(
&self,
req: S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<GetBucketAccelerateConfigurationOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_accelerate_config(&req.input.bucket).await {
Ok((accelerate, _)) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput {
status: accelerate.status,
..Default::default()
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
#[instrument(level = "debug", skip(self))]
async fn get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<S3Response<GetBucketCorsOutput>> {
record_s3_op(S3Operation::GetBucketCors, &req.input.bucket);
@@ -370,6 +418,30 @@ impl S3 for FS {
usecase.execute_get_bucket_replication(req).await
}
async fn get_bucket_request_payment(
&self,
req: S3Request<GetBucketRequestPaymentInput>,
) -> S3Result<S3Response<GetBucketRequestPaymentOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_request_payment_config(&req.input.bucket).await {
Ok((payment, _)) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(payment.payer),
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(Payer::from_static(Payer::BUCKET_OWNER)),
})),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
#[instrument(level = "debug", skip(self))]
async fn get_bucket_tagging(&self, req: S3Request<GetBucketTaggingInput>) -> S3Result<S3Response<GetBucketTaggingOutput>> {
record_s3_op(S3Operation::GetBucketTagging, &req.input.bucket);
@@ -397,6 +469,28 @@ impl S3 for FS {
usecase.execute_get_bucket_versioning(req).await
}
async fn get_bucket_website(&self, req: S3Request<GetBucketWebsiteInput>) -> S3Result<S3Response<GetBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_website_config(&req.input.bucket).await {
Ok((website, _)) => Ok(S3Response::new(GetBucketWebsiteOutput {
error_document: website.error_document,
index_document: website.index_document,
redirect_all_requests_to: website.redirect_all_requests_to,
routing_rules: website.routing_rules,
})),
Err(StorageError::ConfigNotFound) => Err(s3_error!(NoSuchWebsiteConfiguration)),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
/// Get bucket notification
#[instrument(
level = "debug",
@@ -528,6 +622,27 @@ impl S3 for FS {
usecase.execute_put_bucket_acl(req).await
}
async fn put_bucket_accelerate_configuration(
&self,
req: S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<PutBucketAccelerateConfigurationOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let accelerate_config = serialize(&req.input.accelerate_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_ACCELERATE_CONFIG, accelerate_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketAccelerateConfigurationOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn put_bucket_cors(&self, req: S3Request<PutBucketCorsInput>) -> S3Result<S3Response<PutBucketCorsOutput>> {
let usecase = DefaultBucketUsecase::from_global();
@@ -543,7 +658,14 @@ impl S3 for FS {
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
Err(s3_error!(NotImplemented, "GetBucketLogging is not implemented yet"))
match metadata_sys::get_logging_config(&req.input.bucket).await {
Ok((logging, _)) => Ok(S3Response::new(GetBucketLoggingOutput {
logging_enabled: logging.logging_enabled,
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketLoggingOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
async fn put_bucket_logging(&self, req: S3Request<PutBucketLoggingInput>) -> S3Result<S3Response<PutBucketLoggingOutput>> {
@@ -555,7 +677,14 @@ impl S3 for FS {
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
Err(s3_error!(NotImplemented, "PutBucketLogging is not implemented yet"))
let logging_config = serialize(&req.input.bucket_logging_status)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_LOGGING_CONFIG, logging_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketLoggingOutput::default()))
}
async fn put_bucket_encryption(
@@ -596,6 +725,27 @@ impl S3 for FS {
usecase.execute_put_bucket_replication(req).await
}
async fn put_bucket_request_payment(
&self,
req: S3Request<PutBucketRequestPaymentInput>,
) -> S3Result<S3Response<PutBucketRequestPaymentOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let payment_config = serialize(&req.input.request_payment_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_REQUEST_PAYMENT_CONFIG, payment_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketRequestPaymentOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn put_public_access_block(
&self,
@@ -620,6 +770,24 @@ impl S3 for FS {
usecase.execute_put_bucket_versioning(req).await
}
async fn put_bucket_website(&self, req: S3Request<PutBucketWebsiteInput>) -> S3Result<S3Response<PutBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let website_config = serialize(&req.input.website_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_WEBSITE_CONFIG, website_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketWebsiteOutput::default()))
}
#[instrument(level = "debug", skip(self, req))]
async fn put_object(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
let usecase = DefaultObjectUsecase::from_global();
+1 -1
View File
@@ -334,7 +334,7 @@ pub fn extract_metadata_from_mime(headers: &HeaderMap<HeaderValue>, metadata: &m
/// request-side transfer encoding for SigV4 streaming and must not be stored or returned.
/// If the only value is "aws-chunked", returns None (do not persist). Otherwise returns
/// the value with "aws-chunked" stripped, or None if nothing remains.
fn normalize_content_encoding_for_storage(value: &str) -> Option<String> {
pub(crate) fn normalize_content_encoding_for_storage(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use rustfs_notify::notification_system;
impl NodeService {
pub(super) async fn handle_get_live_events(
&self,
request: Request<GetLiveEventsRequest>,
) -> Result<Response<GetLiveEventsResponse>, Status> {
let request = request.into_inner();
let limit = usize::try_from(request.limit).unwrap_or(0).clamp(1, 256);
let Some(system) = notification_system() else {
return Ok(Response::new(GetLiveEventsResponse {
success: true,
events: Bytes::new(),
next_sequence: request.after_sequence,
truncated: false,
error_info: None,
}));
};
let batch = system.recent_live_events_since(request.after_sequence, limit).await;
let events = batch.events.into_iter().map(|event| (*event).clone()).collect::<Vec<_>>();
let payload = match serde_json::to_vec(&events) {
Ok(payload) => payload,
Err(err) => {
return Ok(Response::new(GetLiveEventsResponse {
success: false,
events: Bytes::new(),
next_sequence: request.after_sequence,
truncated: false,
error_info: Some(format!("failed to serialize live events: {err}")),
}));
}
};
Ok(Response::new(GetLiveEventsResponse {
success: true,
events: payload.into(),
next_sequence: batch.next_sequence,
truncated: batch.truncated,
error_info: None,
}))
}
}
+6
View File
@@ -56,6 +56,8 @@ type ResponseStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send>>;
mod bucket;
#[path = "disk.rs"]
mod disk;
#[path = "event.rs"]
mod event;
#[path = "health.rs"]
mod health;
#[path = "lock.rs"]
@@ -429,6 +431,10 @@ impl Node for NodeService {
self.handle_get_metrics(request).await
}
async fn get_live_events(&self, request: Request<GetLiveEventsRequest>) -> Result<Response<GetLiveEventsResponse>, Status> {
self.handle_get_live_events(request).await
}
async fn get_proc_info(&self, _request: Request<GetProcInfoRequest>) -> Result<Response<GetProcInfoResponse>, Status> {
self.handle_get_proc_info(_request).await
}
+161 -16
View File
@@ -96,6 +96,8 @@ use std::sync::{Arc, OnceLock};
use tokio::io::AsyncRead;
use tracing::{debug, error};
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
use crate::error::ApiError;
use crate::storage::readers::InMemoryAsyncReader;
use rustfs_ecstore::bucket::metadata_sys;
@@ -159,6 +161,15 @@ async fn prepare_sse_configuration(
server_side_encryption: Option<ServerSideEncryption>,
ssekms_key_id: Option<SSEKMSKeyId>,
) -> Result<Option<SseConfiguration>, ApiError> {
if let Some(server_side_encryption) = server_side_encryption.clone()
&& server_side_encryption.as_str() == ServerSideEncryption::AES256
{
return Ok(Some(SseConfiguration {
effective_sse: server_side_encryption,
effective_kms_key_id: None,
}));
}
if let Some(server_side_encryption) = server_side_encryption.clone()
&& let Some(ssekms_key_id) = ssekms_key_id
{
@@ -192,7 +203,7 @@ async fn prepare_sse_configuration(
debug!("effective_sse={:?} (original={:?})", effective_sse, server_side_encryption);
let effective_kms_key_id = ssekms_key_id.or_else(|| {
let effective_kms_key_id = resolve_effective_kms_key_id(effective_sse.as_ref(), ssekms_key_id, || {
bucket_sse_config.rules.first().and_then(|rule| {
rule.apply_server_side_encryption_by_default
.as_ref()
@@ -226,6 +237,21 @@ async fn prepare_sse_configuration(
}
}
fn resolve_effective_kms_key_id<F>(
effective_sse: Option<&ServerSideEncryption>,
requested_kms_key_id: Option<SSEKMSKeyId>,
bucket_default_kms_key_id: F,
) -> Option<SSEKMSKeyId>
where
F: FnOnce() -> Option<SSEKMSKeyId>,
{
if effective_sse.is_none_or(|sse| sse.as_str() != ServerSideEncryption::AWS_KMS) {
return requested_kms_key_id;
}
requested_kms_key_id.or_else(bucket_default_kms_key_id)
}
#[derive(Debug, Clone)]
pub enum SseTypeV2 {
SseS3(ServerSideEncryption),
@@ -524,6 +550,8 @@ pub struct DecryptionRequest<'a> {
pub part_number: Option<usize>,
/// Parts information for multipart objects
pub parts: &'a [ObjectPartInfo],
/// Object-level ETag, used to distinguish multipart objects from single-part objects.
pub etag: Option<&'a str>,
}
/// Unified encryption material returned by `apply_encryption()`
@@ -568,6 +596,14 @@ pub struct DecryptionMaterial {
pub parts: Vec<ObjectPartInfo>,
}
fn is_multipart_object(etag: Option<&str>, parts: &[ObjectPartInfo]) -> bool {
if parts.len() > 1 {
return true;
}
etag.map(|etag| etag.trim_matches('"').len() != 32).unwrap_or(false)
}
/// Type of encryption used
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SSEType {
@@ -815,7 +851,7 @@ pub async fn sse_prepare_encryption(request: PrepareEncryptionRequest<'_>) -> Re
/// }
/// ```
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
let is_multipart = request.parts.len() > 1;
let is_multipart = is_multipart_object(request.etag, request.parts);
// Check for SSE-C encryption
if request
@@ -1021,7 +1057,7 @@ async fn apply_managed_encryption_material(
context = context.with_size(content_size as u64);
}
// Determine KMS key ID to use
// Determine KMS key ID to use for internal key wrapping.
let mut kms_key_candidate = kms_key_id.clone().map(|s| s.to_string());
if kms_key_candidate.is_none() {
// Try to get default key from KMS service (if available)
@@ -1030,11 +1066,17 @@ async fn apply_managed_encryption_material(
}
}
let kms_key_to_use = kms_key_candidate.clone().ok_or_else(|| {
ApiError::from(StorageError::other(
"No KMS key available for managed server-side encryption (required for SSE-KMS)",
))
})?;
let kms_key_to_use = match (encryption_type, kms_key_candidate.clone()) {
(SSEType::SseS3, Some(kms_key_id)) => kms_key_id,
(SSEType::SseS3, None) => "default".to_string(),
(SSEType::SseKms, Some(kms_key_id)) => kms_key_id,
(SSEType::SseKms, None) => {
return Err(ApiError::from(StorageError::other(
"No KMS key available for managed server-side encryption (required for SSE-KMS)",
)));
}
_ => unreachable!("managed SSE branch only supports SSE-S3 or SSE-KMS"),
};
let provider = get_sse_dek_provider().await?;
@@ -1074,7 +1116,7 @@ async fn apply_managed_encryption_material(
(data_key, encrypted_data_key)
};
let algorithm = DEFAULT_SSE_ALGORITHM.to_string();
let algorithm = server_side_encryption.as_str().to_string();
let encryption_metadata = EncryptionMetadata {
algorithm: algorithm.clone(),
@@ -1103,13 +1145,15 @@ async fn apply_managed_encryption_material(
metadata.insert("x-rustfs-encryption-iv".to_string(), BASE64_STANDARD.encode(&encryption_metadata.iv));
metadata.insert("x-rustfs-encryption-algorithm".to_string(), encryption_metadata.algorithm.clone());
metadata.insert("x-amz-server-side-encryption".to_string(), server_side_encryption.as_str().to_string());
// if kms_key is changed, we need to update the metadata
if kms_key_id.is_none() {
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), kms_key_to_use.clone());
}
}
if matches!(encryption_type, SSEType::SseKms) {
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), kms_key_to_use.clone());
} else {
metadata.remove("x-amz-server-side-encryption-aws-kms-key-id");
}
metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), kms_key_to_use.clone());
metadata.insert(
"x-rustfs-encryption-original-size".to_string(),
encryption_metadata.original_size.to_string(),
@@ -1118,7 +1162,7 @@ async fn apply_managed_encryption_material(
Ok(EncryptionMaterial {
sse_type: encryption_type,
server_side_encryption,
kms_key_id: Some(kms_key_to_use),
kms_key_id: matches!(encryption_type, SSEType::SseKms).then_some(kms_key_to_use),
algorithm,
key_bytes: data_key.plaintext_key,
@@ -1182,7 +1226,8 @@ async fn apply_managed_decryption_material(
// Extract KMS key ID from metadata (optional, used for provider context)
let kms_key_id = metadata
.get("x-amz-server-side-encryption-aws-kms-key-id")
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned()
.unwrap_or_else(|| "default".to_string());
@@ -2179,6 +2224,80 @@ mod tests {
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
}
#[test]
fn test_resolve_effective_kms_key_id_ignores_bucket_default_for_explicit_sse_s3() {
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AES256);
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
assert_eq!(kms_key_id, None);
}
#[test]
fn test_resolve_effective_kms_key_id_uses_bucket_default_for_sse_kms() {
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS);
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
assert_eq!(kms_key_id.as_deref(), Some("bucket-default"));
}
#[tokio::test]
async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() {
let request = EncryptionRequest {
bucket: "test-bucket",
key: "test-key",
server_side_encryption: Some("aws:kms".to_string().into()),
ssekms_key_id: Some("test-key".to_string()),
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
content_size: 1024,
part_number: None,
part_key: None,
part_nonce: None,
};
let material = sse_encryption(request).await.expect("kms encryption should succeed");
let metadata = material.expect("managed kms encryption should return material").metadata;
assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("aws:kms"));
assert_eq!(
metadata
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(String::as_str),
Some("test-key")
);
}
#[tokio::test]
async fn test_sse_encryption_omits_kms_header_for_sse_s3_objects() {
let request = EncryptionRequest {
bucket: "test-bucket",
key: "test-key",
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
ssekms_key_id: None,
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
content_size: 1024,
part_number: None,
part_key: None,
part_nonce: None,
};
let material = sse_encryption(request).await.expect("sse-s3 encryption should succeed");
let material = material.expect("managed sse-s3 encryption should return material");
assert_eq!(material.kms_key_id, None);
assert_eq!(material.metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256"));
assert!(!material.metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id"));
assert_eq!(
material.metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str),
Some("default")
);
}
#[test]
fn test_strip_managed_encryption_metadata() {
let mut metadata = HashMap::new();
@@ -2193,6 +2312,32 @@ mod tests {
assert!(metadata.contains_key("content-type"));
}
#[test]
fn test_is_multipart_object_treats_single_part_multipart_etag_as_multipart() {
let metadata = HashMap::from([("etag".to_string(), "0123456789abcdef0123456789abcdef-1".to_string())]);
let parts = vec![ObjectPartInfo {
number: 1,
size: 128,
actual_size: 64,
..Default::default()
}];
assert!(is_multipart_object(metadata.get("etag").map(String::as_str), &parts));
}
#[test]
fn test_is_multipart_object_keeps_regular_single_part_object_as_non_multipart() {
let metadata = HashMap::from([("etag".to_string(), "0123456789abcdef0123456789abcdef".to_string())]);
let parts = vec![ObjectPartInfo {
number: 1,
size: 128,
actual_size: 64,
..Default::default()
}];
assert!(!is_multipart_object(metadata.get("etag").map(String::as_str), &parts));
}
#[test]
fn test_verify_ssec_key_match_success() {
let md5 = "test_md5".to_string();