mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
feat(s3): advance parity coverage (#2278)
This commit is contained in:
+2
-1
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3574
-1
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
+874
-115
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user