Files
rustfs/rustfs/src/admin/router.rs
T

4128 lines
162 KiB
Rust

// 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::storage_api::bucket::bandwidth::monitor::BandwidthDetails;
use super::storage_api::bucket::metadata::BUCKET_TARGETS_FILE;
use super::storage_api::bucket::metadata_sys;
use super::storage_api::bucket::replication::{BucketReplicationResyncStatus, BucketStats, ObjectOpts, ResyncOpts};
use super::storage_api::bucket::target::{BucketTarget, BucketTargetType, BucketTargets};
use super::storage_api::bucket::target_sys::{
BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError, TargetClient,
};
use super::storage_api::bucket::versioning_sys::BucketVersioningSys;
use super::storage_api::bucket::{AdminReplicationConfigExt as _, AdminVersioningConfigExt as _};
use super::storage_api::config::read_admin_config_without_migrate;
use super::storage_api::error::StorageError;
use super::storage_api::runtime::PeerRestClient;
use crate::admin::console::{is_console_path, make_console_server};
use crate::admin::handlers::oidc::is_oidc_path;
use crate::admin::runtime_sources::{
current_boot_time, current_bucket_monitor_handle, current_deployment_id, current_notification_system,
current_object_store_handle, current_region, current_replication_pool_handle, current_replication_stats_handle,
current_server_config, default_object_usecase,
};
use crate::admin::storage_api::access::{ReqInfo, authorize_request, spawn_traced};
use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions};
use crate::auth::{check_key_valid, get_session_token};
use crate::error::ApiError;
use crate::license::license_check;
use crate::server::{
ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, MINIO_ADMIN_PREFIX, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH, is_admin_path,
};
use aws_sdk_s3::primitives::ByteStream as AwsByteStream;
use bytes::Bytes;
use futures::{Stream, StreamExt};
use http::HeaderValue;
use http::header::HeaderName;
use hyper::HeaderMap;
use hyper::Method;
use hyper::StatusCode;
use hyper::Uri;
use hyper::http::Extensions;
use matchit::Params;
use matchit::Router;
use reqwest::Url;
use rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS;
use rustfs_config::server_config::Config;
use rustfs_config::{
ENABLE_KEY, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT,
WEBHOOK_SKIP_TLS_VERIFY,
};
use rustfs_filemeta::{ReplicationStatusType, ReplicationType};
use rustfs_madmin::utils::parse_duration;
use rustfs_notify::{Event as NotificationEvent, notification_system};
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_s3_types::EventName;
use rustfs_signer::pre_sign_v4;
use rustfs_utils::egress::validate_outbound_url;
use rustfs_utils::http::{
SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_CHECK, SUFFIX_SOURCE_REPLICATION_REQUEST,
SUFFIX_SOURCE_VERSION_ID, get_source_scheme, insert_header,
};
use s3s::Body;
use s3s::S3Error;
use s3s::S3ErrorCode;
use s3s::S3Request;
use s3s::S3Response;
use s3s::S3Result;
use s3s::StdError;
use s3s::dto::{GetObjectInput, GetObjectOutput, IfMatch, IfNoneMatch, Range, StreamingBlob, Timestamp, TimestampFormat};
use s3s::header;
use s3s::route::S3Route;
use s3s::s3_error;
use s3s::stream::{ByteStream, DynByteStream};
use std::collections::{HashMap, HashSet};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::SystemTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::{broadcast, mpsc};
use tokio::time::Duration;
use tokio_stream::wrappers::ReceiverStream;
use tower::Service;
use tracing::{error, warn};
use url::form_urlencoded;
use uuid::Uuid;
pub const ADMIN_OBJECT_ZIP_DOWNLOADS_PATH: &str = "/v3/object-zip-downloads";
const LOG_COMPONENT_ADMIN: &str = "admin";
const LOG_SUBSYSTEM_OBJECT_LAMBDA: &str = "object_lambda";
const LOG_SUBSYSTEM_LIVE_EVENTS: &str = "live_events";
const EVENT_ADMIN_ROUTER_STATE: &str = "admin_router_state";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplicationExtRoute {
MetricsV1,
MetricsV2,
Check,
ResetStart,
ResetStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReplicationExtRequest {
bucket: String,
route: ReplicationExtRoute,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MiscExtRoute {
ObjectLambda { bucket: String, object: String },
ListenNotification { bucket: Option<String> },
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct ReplicationResetResponse {
#[serde(rename = "Targets")]
targets: Vec<ReplicationResetTarget>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct ReplicationResetTarget {
#[serde(rename = "Arn")]
arn: String,
#[serde(rename = "ResetID")]
reset_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReplicationResetStartRequest {
arn: String,
reset_id: String,
reset_before: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct ReplicationResetStatusRequest {
arn: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct ReplicationResetStatusResponse {
#[serde(rename = "Targets")]
targets: Vec<ReplicationResetStatusTarget>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct ReplicationResetStatusTarget {
#[serde(rename = "Arn")]
arn: String,
#[serde(rename = "ResetID")]
reset_id: String,
#[serde(
rename = "ResetBeforeDate",
with = "time::serde::rfc3339::option",
skip_serializing_if = "Option::is_none"
)]
reset_before_date: Option<OffsetDateTime>,
#[serde(
rename = "StartTime",
with = "time::serde::rfc3339::option",
skip_serializing_if = "Option::is_none"
)]
start_time: Option<OffsetDateTime>,
#[serde(
rename = "EndTime",
with = "time::serde::rfc3339::option",
skip_serializing_if = "Option::is_none"
)]
end_time: Option<OffsetDateTime>,
#[serde(rename = "Status")]
status: String,
#[serde(rename = "ReplicatedCount")]
replicated_count: i64,
#[serde(rename = "ReplicatedSize")]
replicated_size: i64,
#[serde(rename = "FailedCount")]
failed_count: i64,
#[serde(rename = "FailedSize")]
failed_size: i64,
#[serde(rename = "Bucket", skip_serializing_if = "String::is_empty")]
bucket: String,
#[serde(rename = "Object", skip_serializing_if = "String::is_empty")]
object: String,
#[serde(rename = "Error", skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct ReplicationCheckTargetStatus {
#[serde(rename = "Arn")]
arn: String,
#[serde(rename = "Endpoint")]
endpoint: String,
#[serde(rename = "Bucket")]
bucket: String,
#[serde(rename = "Status")]
status: String,
#[serde(rename = "Error", skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplicationCheckFailureContext {
BucketCheck,
VersioningCheck,
ReplicateObject,
ReplicateDeleteMarker,
DeleteObjectVersion,
ObjectLockCheck,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ListenNotificationFilter {
bucket: Option<String>,
event_mask: u64,
prefix: Option<String>,
suffix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ObjectLambdaWebhookConfig {
endpoint: Url,
auth_token: String,
client_cert: String,
client_key: String,
client_ca: String,
skip_tls_verify: bool,
response_header_timeout: Option<Duration>,
}
const LAMBDA_WEBHOOK_SUB_SYS: &str = "lambda_webhook";
const WEBHOOK_RESPONSE_HEADER_TIMEOUT: &str = "response_header_timeout";
const OBJECT_LAMBDA_PRESIGN_EXPIRES_SECS: i64 = 3600;
fn parse_query_pairs(uri: &Uri) -> Vec<(String, String)> {
uri.query()
.map(|query| {
form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
})
.unwrap_or_default()
}
fn query_value_exact(uri: &Uri, key: &str) -> Option<String> {
parse_query_pairs(uri)
.into_iter()
.find_map(|(k, v)| if k == key { Some(v) } else { None })
}
fn query_values_exact(uri: &Uri, key: &str) -> Vec<String> {
parse_query_pairs(uri)
.into_iter()
.filter_map(|(k, v)| if k == key { Some(v) } else { None })
.collect()
}
fn is_valid_filter_rule_value(value: &str) -> bool {
if value.len() > 1024 || value.contains('\\') {
return false;
}
!value.split('/').any(|segment| segment == "." || segment == "..")
}
fn extract_bucket_for_bucket_level_path(path: &str) -> Option<String> {
let bucket = path.strip_prefix('/')?;
if bucket.is_empty() || bucket.contains('/') {
return None;
}
Some(bucket.to_string())
}
fn extract_bucket_object_path(path: &str) -> Option<(String, String)> {
let path = path.strip_prefix('/')?;
let (bucket, object) = path.split_once('/')?;
if bucket.is_empty() || object.is_empty() {
return None;
}
Some((bucket.to_string(), object.to_string()))
}
fn parse_replication_extension_request(method: &Method, uri: &Uri) -> Option<ReplicationExtRequest> {
let bucket = extract_bucket_for_bucket_level_path(uri.path())?;
if method == Method::PUT && query_value_exact(uri, "replication-reset").as_deref() == Some("") {
return Some(ReplicationExtRequest {
bucket,
route: ReplicationExtRoute::ResetStart,
});
}
if method == Method::GET {
if query_value_exact(uri, "replication-reset-status").as_deref() == Some("") {
return Some(ReplicationExtRequest {
bucket,
route: ReplicationExtRoute::ResetStatus,
});
}
if let Some(value) = query_value_exact(uri, "replication-metrics") {
if value == "2" {
return Some(ReplicationExtRequest {
bucket,
route: ReplicationExtRoute::MetricsV2,
});
}
if value.is_empty() {
return Some(ReplicationExtRequest {
bucket,
route: ReplicationExtRoute::MetricsV1,
});
}
}
if query_value_exact(uri, "replication-check").as_deref() == Some("") {
return Some(ReplicationExtRequest {
bucket,
route: ReplicationExtRoute::Check,
});
}
}
None
}
fn parse_misc_extension_request(method: &Method, uri: &Uri) -> Option<MiscExtRoute> {
if method != Method::GET {
return None;
}
if query_value_exact(uri, "lambdaArn").is_some()
&& let Some((bucket, object)) = extract_bucket_object_path(uri.path())
{
return Some(MiscExtRoute::ObjectLambda { bucket, object });
}
if query_value_exact(uri, "events").is_some() {
if uri.path() == "/" {
return Some(MiscExtRoute::ListenNotification { bucket: None });
}
let path = uri.path().strip_suffix('/').unwrap_or(uri.path());
if let Some(bucket) = extract_bucket_for_bucket_level_path(path) {
return Some(MiscExtRoute::ListenNotification { bucket: Some(bucket) });
}
}
None
}
fn validate_object_lambda_query(uri: &Uri) -> S3Result<()> {
let lambda_arns = query_values_exact(uri, "lambdaArn");
if lambda_arns.len() != 1 || lambda_arns[0].trim().is_empty() {
return Err(s3_error!(InvalidRequest, "lambdaArn query parameter must be provided exactly once"));
}
let lambda_arn = lambda_arns[0].trim();
let arn_parts = lambda_arn.split(':').collect::<Vec<_>>();
let is_valid_arn = arn_parts.len() >= 6 && arn_parts[0] == "arn" && !arn_parts[1].is_empty() && !arn_parts[2].is_empty();
if !is_valid_arn {
return Err(s3_error!(InvalidRequest, "lambdaArn query parameter must be a valid ARN string"));
}
Ok(())
}
fn validate_listen_notification_query(uri: &Uri) -> S3Result<()> {
let events = query_values_exact(uri, "events");
if events.is_empty() {
return Err(s3_error!(InvalidArgument, "events query parameter is required"));
}
for event in events {
EventName::parse(&event).map_err(|_| s3_error!(InvalidArgument, "invalid event in events query parameter"))?;
}
let prefixes = query_values_exact(uri, "prefix");
if prefixes.len() > 1 {
return Err(s3_error!(InvalidArgument, "prefix query parameter must not be repeated"));
}
if let Some(prefix) = prefixes.first()
&& !is_valid_filter_rule_value(prefix)
{
return Err(s3_error!(InvalidArgument, "invalid prefix filter value"));
}
let suffixes = query_values_exact(uri, "suffix");
if suffixes.len() > 1 {
return Err(s3_error!(InvalidArgument, "suffix query parameter must not be repeated"));
}
if let Some(suffix) = suffixes.first()
&& !is_valid_filter_rule_value(suffix)
{
return Err(s3_error!(InvalidArgument, "invalid suffix filter value"));
}
let pings = query_values_exact(uri, "ping");
if pings.len() > 1 {
return Err(s3_error!(InvalidArgument, "ping query parameter must not be repeated"));
}
if let Some(ping) = pings.first() {
let ping_interval = ping
.parse::<u64>()
.map_err(|_| s3_error!(InvalidArgument, "ping query parameter must be a positive integer"))?;
if ping_interval == 0 {
return Err(s3_error!(InvalidArgument, "ping query parameter must be greater than zero"));
}
}
Ok(())
}
fn parse_listen_notification_filter(uri: &Uri, bucket: Option<&str>) -> S3Result<ListenNotificationFilter> {
let mut event_mask = 0_u64;
for event in query_values_exact(uri, "events") {
event_mask |= EventName::parse(&event)
.map_err(|_| s3_error!(InvalidArgument, "invalid event in events query parameter"))?
.mask();
}
Ok(ListenNotificationFilter {
bucket: bucket.map(str::to_string),
event_mask,
prefix: query_value_exact(uri, "prefix").filter(|value| !value.is_empty()),
suffix: query_value_exact(uri, "suffix").filter(|value| !value.is_empty()),
})
}
fn validate_misc_extension_request(uri: &Uri, route: &MiscExtRoute) -> S3Result<()> {
match route {
MiscExtRoute::ObjectLambda { .. } => validate_object_lambda_query(uri),
MiscExtRoute::ListenNotification { .. } => validate_listen_notification_query(uri),
}
}
fn query_pairs_without_key(uri: &Uri, excluded_key: &str) -> Vec<(String, String)> {
parse_query_pairs(uri)
.into_iter()
.filter(|(key, _)| key != excluded_key)
.collect()
}
fn uri_without_query_key(uri: &Uri, excluded_key: &str) -> S3Result<Uri> {
let filtered = query_pairs_without_key(uri, excluded_key);
let mut parts = uri.clone().into_parts();
parts.path_and_query = if filtered.is_empty() {
Some(
uri.path()
.parse()
.map_err(|_| s3_error!(InvalidRequest, "failed to rebuild request URI"))?,
)
} else {
let query = form_urlencoded::Serializer::new(String::new())
.extend_pairs(filtered.iter().map(|(key, value)| (key.as_str(), value.as_str())))
.finish();
Some(
format!("{}?{}", uri.path(), query)
.parse()
.map_err(|_| s3_error!(InvalidRequest, "failed to rebuild request URI"))?,
)
};
Uri::from_parts(parts).map_err(|_| s3_error!(InvalidRequest, "failed to rebuild request URI"))
}
fn parse_optional_header(headers: &HeaderMap, name: HeaderName) -> S3Result<Option<String>> {
headers
.get(name)
.map(|value| {
value
.to_str()
.map(|parsed| parsed.to_string())
.map_err(|_| s3_error!(InvalidRequest, "request header contains invalid utf-8"))
})
.transpose()
}
fn parse_optional_timestamp_header(headers: &HeaderMap, name: HeaderName) -> S3Result<Option<Timestamp>> {
parse_optional_header(headers, name)?
.map(|value| {
Timestamp::parse(TimestampFormat::HttpDate, &value)
.map_err(|_| s3_error!(InvalidRequest, "request timestamp header is invalid"))
})
.transpose()
}
fn parse_optional_etag_condition_header<T>(headers: &HeaderMap, name: HeaderName) -> S3Result<Option<T>>
where
T: std::str::FromStr,
{
parse_optional_header(headers, name)?
.map(|value| {
value
.parse::<T>()
.map_err(|_| s3_error!(InvalidRequest, "request etag condition header is invalid"))
})
.transpose()
}
fn build_object_lambda_get_request(req: &S3Request<Body>, bucket: &str, object: &str) -> S3Result<S3Request<GetObjectInput>> {
let filtered_uri = uri_without_query_key(&req.uri, "lambdaArn")?;
let part_number = query_value_exact(&filtered_uri, "partNumber")
.filter(|value| !value.is_empty())
.map(|value| {
value
.parse::<i32>()
.map_err(|_| s3_error!(InvalidArgument, "partNumber query parameter must be a positive integer"))
})
.transpose()?;
let version_id = query_value_exact(&filtered_uri, "versionId").filter(|value| !value.is_empty());
let range = parse_optional_header(&req.headers, header::RANGE)?
.map(|value| Range::parse(&value).map_err(|_| s3_error!(InvalidArgument, "Range header is invalid")))
.transpose()?;
let mut builder = GetObjectInput::builder()
.bucket(bucket.to_string())
.key(object.to_string())
.part_number(part_number)
.version_id(version_id)
.range(range)
.if_match(parse_optional_etag_condition_header::<IfMatch>(&req.headers, header::IF_MATCH)?)
.if_none_match(parse_optional_etag_condition_header::<IfNoneMatch>(&req.headers, header::IF_NONE_MATCH)?)
.if_modified_since(parse_optional_timestamp_header(&req.headers, header::IF_MODIFIED_SINCE)?)
.if_unmodified_since(parse_optional_timestamp_header(&req.headers, header::IF_UNMODIFIED_SINCE)?);
builder = builder.sse_customer_algorithm(parse_optional_header(
&req.headers,
HeaderName::from_static("x-amz-server-side-encryption-customer-algorithm"),
)?);
builder = builder.sse_customer_key(parse_optional_header(
&req.headers,
HeaderName::from_static("x-amz-server-side-encryption-customer-key"),
)?);
builder = builder.sse_customer_key_md5(parse_optional_header(
&req.headers,
HeaderName::from_static("x-amz-server-side-encryption-customer-key-md5"),
)?);
let input = builder
.build()
.map_err(|err| s3_error!(InvalidRequest, "failed to build object lambda get request: {err}"))?;
Ok(S3Request {
input,
method: req.method.clone(),
uri: filtered_uri,
headers: req.headers.clone(),
extensions: req.extensions.clone(),
credentials: req.credentials.clone(),
region: req.region.clone(),
service: req.service.clone(),
trailing_headers: req.trailing_headers.clone(),
})
}
fn parse_object_lambda_arn(uri: &Uri) -> S3Result<rustfs_targets::arn::ARN> {
let lambda_arn = query_value_exact(uri, "lambdaArn")
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| s3_error!(InvalidRequest, "lambdaArn query parameter must be provided exactly once"))?;
lambda_arn
.parse::<rustfs_targets::arn::ARN>()
.map_err(|_| s3_error!(InvalidRequest, "lambdaArn query parameter must reference a supported target ARN"))
}
fn config_enable_is_on(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
}
fn resolve_object_lambda_webhook_config_from_server_config(
config: &Config,
arn: &rustfs_targets::arn::ARN,
) -> S3Result<ObjectLambdaWebhookConfig> {
let target_name = arn.target_id.name.to_ascii_lowercase();
if target_name != "webhook" && !target_name.starts_with("webhook-") {
return Err(s3_error!(NotImplemented, "object lambda target type is not supported"));
}
let subsystem = config
.0
.get(LAMBDA_WEBHOOK_SUB_SYS)
.or_else(|| config.0.get(NOTIFY_WEBHOOK_SUB_SYS))
.ok_or_else(|| s3_error!(InvalidRequest, "object lambda webhook subsystem is not configured"))?;
let kvs = subsystem
.get(&arn.target_id.id)
.ok_or_else(|| s3_error!(InvalidRequest, "object lambda target is not configured"))?;
if !config_enable_is_on(&kvs.get(ENABLE_KEY)) {
return Err(s3_error!(InvalidRequest, "object lambda target is disabled"));
}
let endpoint = kvs.lookup(WEBHOOK_ENDPOINT).unwrap_or_default();
if endpoint.trim().is_empty() {
return Err(s3_error!(InvalidRequest, "object lambda target endpoint is empty"));
}
let response_header_timeout = match kvs.lookup(WEBHOOK_RESPONSE_HEADER_TIMEOUT) {
Some(value) if value.trim().is_empty() => None,
Some(value) => Some(
parse_duration(&value)
.map_err(|_| s3_error!(InvalidRequest, "object lambda target response_header_timeout is invalid"))?,
),
None => None,
};
let parsed_endpoint =
Url::parse(&endpoint).map_err(|_| s3_error!(InvalidRequest, "object lambda target endpoint is invalid"))?;
validate_outbound_url(&parsed_endpoint)
.map_err(|err| s3_error!(InvalidRequest, "object lambda target endpoint is not allowed: {}", err))?;
Ok(ObjectLambdaWebhookConfig {
endpoint: parsed_endpoint,
auth_token: kvs.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
client_cert: kvs.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
client_key: kvs.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
client_ca: kvs.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(),
skip_tls_verify: config_enable_is_on(&kvs.lookup(WEBHOOK_SKIP_TLS_VERIFY).unwrap_or_default()),
response_header_timeout,
})
}
async fn load_current_server_config() -> S3Result<Config> {
if let Some(system) = notification_system() {
return Ok(system.config.read().await.clone());
}
if let Some(store) = current_object_store_handle() {
match read_admin_config_without_migrate(store).await {
Ok(config) => return Ok(config),
Err(err) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_OBJECT_LAMBDA,
result = "config_reload_failed",
error = %err,
"admin router state"
);
}
}
}
let config = current_server_config().ok_or_else(|| s3_error!(InternalError, "server config is not initialized"))?;
Ok(config)
}
async fn resolve_object_lambda_webhook_config(uri: &Uri) -> S3Result<ObjectLambdaWebhookConfig> {
let config = load_current_server_config().await?;
let arn = parse_object_lambda_arn(uri)?;
resolve_object_lambda_webhook_config_from_server_config(&config, &arn)
}
fn build_object_lambda_http_client(config: &ObjectLambdaWebhookConfig) -> S3Result<reqwest::Client> {
validate_outbound_url(&config.endpoint)
.map_err(|err| s3_error!(InvalidRequest, "object lambda target endpoint is not allowed: {}", err))?;
let mut builder = reqwest::Client::builder().user_agent(rustfs_targets::get_user_agent(rustfs_targets::ServiceType::Basis));
if let Some(timeout) = config.response_header_timeout {
builder = builder.timeout(timeout);
}
if config.skip_tls_verify {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_OBJECT_LAMBDA,
result = "tls_verification_disabled",
endpoint = %config.endpoint,
"admin router state"
);
builder = builder.danger_accept_invalid_certs(true);
} else if !config.client_ca.is_empty() {
let ca_pem = std::fs::read(&config.client_ca)
.map_err(|e| s3_error!(InternalError, "failed to read object lambda client_ca: {e}"))?;
let ca = reqwest::Certificate::from_pem(&ca_pem)
.map_err(|e| s3_error!(InternalError, "failed to parse object lambda client_ca: {e}"))?;
builder = builder.add_root_certificate(ca);
}
if !config.client_cert.is_empty() || !config.client_key.is_empty() {
if config.client_cert.is_empty() || config.client_key.is_empty() {
return Err(s3_error!(
InvalidRequest,
"object lambda client_cert and client_key must be configured together"
));
}
let cert = std::fs::read(&config.client_cert)
.map_err(|e| s3_error!(InternalError, "failed to read object lambda client_cert: {e}"))?;
let key = std::fs::read(&config.client_key)
.map_err(|e| s3_error!(InternalError, "failed to read object lambda client_key: {e}"))?;
let identity = reqwest::Identity::from_pem(&[cert, key].concat())
.map_err(|e| s3_error!(InternalError, "failed to build object lambda client identity: {e}"))?;
builder = builder.identity(identity);
}
builder
.build()
.map_err(|e| s3_error!(InternalError, "failed to build object lambda http client: {e}"))
}
fn extract_request_scheme(headers: &HeaderMap, uri: &Uri) -> String {
get_source_scheme(headers)
.and_then(|value| {
value
.split(',')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| uri.scheme_str().map(str::to_owned))
.unwrap_or_else(|| "http".to_string())
.to_ascii_lowercase()
}
fn extract_request_host(headers: &HeaderMap, uri: &Uri) -> Option<String> {
headers
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| uri.authority().map(|authority| authority.as_str().to_string()))
}
fn build_object_lambda_source_url(req: &S3Request<Body>) -> S3Result<String> {
let credentials = req
.credentials
.as_ref()
.ok_or_else(|| s3_error!(AccessDenied, "object lambda source URL requires authenticated credentials"))?;
let host = extract_request_host(&req.headers, &req.uri)
.ok_or_else(|| s3_error!(InvalidRequest, "object lambda source URL requires a valid host header"))?;
let scheme = extract_request_scheme(&req.headers, &req.uri);
let filtered_uri = uri_without_query_key(&req.uri, "lambdaArn")?;
let path_and_query = filtered_uri
.path_and_query()
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| filtered_uri.path().to_string());
let source_uri = format!("{scheme}://{host}{path_and_query}")
.parse::<Uri>()
.map_err(|e| s3_error!(InvalidRequest, "failed to construct object lambda source URL: {e}"))?;
let region = req
.region
.clone()
.or_else(current_region)
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "us-east-1".to_string());
let session_token = get_session_token(&req.uri, &req.headers).unwrap_or_default().to_string();
let presigned = pre_sign_v4(
http::Request::builder()
.method(Method::GET)
.uri(source_uri)
.header(http::header::HOST, host)
.body(Body::default())
.map_err(|e| s3_error!(InvalidRequest, "failed to build object lambda source request: {e}"))?,
&credentials.access_key,
credentials.secret_key.expose(),
&session_token,
&region,
OBJECT_LAMBDA_PRESIGN_EXPIRES_SECS,
OffsetDateTime::now_utc(),
);
Ok(presigned.uri().to_string())
}
fn build_object_lambda_event_payload(
req: &S3Request<Body>,
lambda_arn: &str,
input_s3_url: &str,
output_route: &str,
output_token: &str,
) -> S3Result<Vec<u8>> {
let request_headers = req
.headers
.iter()
.filter_map(|(name, value)| value.to_str().ok().map(|value| (name.to_string(), value.to_string())))
.collect::<std::collections::BTreeMap<_, _>>();
serde_json::to_vec(&serde_json::json!({
"getObjectContext": {
"inputS3Url": input_s3_url,
"outputRoute": output_route,
"outputToken": output_token,
},
"configuration": {
"accessPointArn": lambda_arn,
},
"userRequest": {
"url": req.uri.to_string(),
"headers": request_headers,
},
"protocolVersion": "rustfs-object-lambda-1.0",
}))
.map_err(|e| s3_error!(InternalError, "failed to serialize object lambda payload: {e}"))
}
fn validate_object_lambda_response_auth_headers(headers: &HeaderMap, output_route: &str, output_token: &str) -> S3Result<()> {
let route = headers
.get("x-amz-request-route")
.and_then(|value| value.to_str().ok())
.map(str::trim);
let token = headers
.get("x-amz-request-token")
.and_then(|value| value.to_str().ok())
.map(str::trim);
if route == Some(output_route) && token == Some(output_token) {
return Ok(());
}
Err(s3_error!(
InvalidRequest,
"object lambda target response is missing or contains invalid authorization headers"
))
}
fn format_timestamp_http_date(value: &Timestamp) -> S3Result<String> {
let mut buf = Vec::new();
value
.format(TimestampFormat::HttpDate, &mut buf)
.map_err(|_| s3_error!(InternalError, "failed to format timestamp header"))?;
String::from_utf8(buf).map_err(|_| s3_error!(InternalError, "failed to format timestamp header"))
}
fn insert_string_header(headers: &mut HeaderMap, name: HeaderName, value: String) -> S3Result<()> {
let header_value =
HeaderValue::from_str(&value).map_err(|_| s3_error!(InternalError, "failed to build response header value"))?;
headers.insert(name, header_value);
Ok(())
}
fn build_get_object_response_headers(output: &GetObjectOutput, base_headers: &HeaderMap) -> S3Result<HeaderMap> {
let mut headers = base_headers.clone();
if let Some(accept_ranges) = &output.accept_ranges {
insert_string_header(&mut headers, http::header::ACCEPT_RANGES, accept_ranges.clone())?;
}
if let Some(cache_control) = &output.cache_control {
insert_string_header(&mut headers, http::header::CACHE_CONTROL, cache_control.clone())?;
}
if let Some(content_disposition) = &output.content_disposition {
insert_string_header(&mut headers, http::header::CONTENT_DISPOSITION, content_disposition.clone())?;
}
if let Some(content_encoding) = &output.content_encoding {
insert_string_header(&mut headers, http::header::CONTENT_ENCODING, content_encoding.clone())?;
}
if let Some(content_language) = &output.content_language {
insert_string_header(&mut headers, http::header::CONTENT_LANGUAGE, content_language.clone())?;
}
if let Some(content_length) = output.content_length {
insert_string_header(&mut headers, http::header::CONTENT_LENGTH, content_length.to_string())?;
}
if let Some(content_range) = &output.content_range {
insert_string_header(&mut headers, http::header::CONTENT_RANGE, content_range.clone())?;
}
if let Some(content_type) = &output.content_type {
insert_string_header(&mut headers, http::header::CONTENT_TYPE, content_type.to_string())?;
}
if let Some(etag) = &output.e_tag {
headers.insert(
http::header::ETAG,
etag.to_http_header().map_err(|_| s3_error!(InternalError, "invalid etag"))?,
);
}
if let Some(last_modified) = &output.last_modified {
insert_string_header(&mut headers, http::header::LAST_MODIFIED, format_timestamp_http_date(last_modified)?)?;
}
if let Some(expires) = &output.expires {
insert_string_header(&mut headers, http::header::EXPIRES, format_timestamp_http_date(expires)?)?;
}
if let Some(version_id) = &output.version_id {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-version-id"), version_id.clone())?;
}
if let Some(server_side_encryption) = &output.server_side_encryption {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-server-side-encryption"),
server_side_encryption.as_str().to_string(),
)?;
}
if let Some(sse_customer_algorithm) = &output.sse_customer_algorithm {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-server-side-encryption-customer-algorithm"),
sse_customer_algorithm.clone(),
)?;
}
if let Some(sse_customer_key_md5) = &output.sse_customer_key_md5 {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-server-side-encryption-customer-key-md5"),
sse_customer_key_md5.clone(),
)?;
}
if let Some(sse_kms_key_id) = &output.ssekms_key_id {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-server-side-encryption-aws-kms-key-id"),
sse_kms_key_id.clone(),
)?;
}
if let Some(checksum_crc32) = &output.checksum_crc32 {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-checksum-crc32"), checksum_crc32.clone())?;
}
if let Some(checksum_crc32c) = &output.checksum_crc32c {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-checksum-crc32c"), checksum_crc32c.clone())?;
}
if let Some(checksum_crc64nvme) = &output.checksum_crc64nvme {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-checksum-crc64nvme"),
checksum_crc64nvme.clone(),
)?;
}
if let Some(checksum_sha1) = &output.checksum_sha1 {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-checksum-sha1"), checksum_sha1.clone())?;
}
if let Some(checksum_sha256) = &output.checksum_sha256 {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-checksum-sha256"), checksum_sha256.clone())?;
}
if let Some(checksum_type) = &output.checksum_type {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-checksum-type"),
checksum_type.as_str().to_string(),
)?;
}
if let Some(storage_class) = &output.storage_class {
insert_string_header(
&mut headers,
HeaderName::from_static("x-amz-storage-class"),
storage_class.as_str().to_string(),
)?;
}
if let Some(tag_count) = output.tag_count {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-tagging-count"), tag_count.to_string())?;
}
if let Some(expiration) = &output.expiration {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-expiration"), expiration.clone())?;
}
if let Some(restore) = &output.restore {
insert_string_header(&mut headers, HeaderName::from_static("x-amz-restore"), restore.clone())?;
}
if let Some(metadata) = &output.metadata {
for (key, value) in metadata {
let header_name = format!("x-amz-meta-{key}");
if let Ok(parsed_name) = HeaderName::from_bytes(header_name.as_bytes()) {
let parsed_value = HeaderValue::from_str(value)
.map_err(|_| s3_error!(InternalError, "failed to build metadata response header"))?;
headers.insert(parsed_name, parsed_value);
}
}
}
Ok(headers)
}
#[cfg_attr(not(test), allow(dead_code))]
fn convert_get_object_response(resp: S3Response<GetObjectOutput>) -> S3Result<S3Response<Body>> {
let headers = build_get_object_response_headers(&resp.output, &resp.headers)?;
let body = resp.output.body.map(Body::from).unwrap_or_else(|| Body::from(String::new()));
Ok(S3Response {
output: body,
status: resp.status,
headers,
extensions: resp.extensions,
})
}
fn clear_object_lambda_variant_headers(headers: &mut HeaderMap) {
for name in [
http::header::ACCEPT_RANGES,
http::header::CACHE_CONTROL,
http::header::CONTENT_DISPOSITION,
http::header::CONTENT_ENCODING,
http::header::CONTENT_LANGUAGE,
http::header::CONTENT_LENGTH,
http::header::CONTENT_RANGE,
http::header::CONTENT_TYPE,
http::header::ETAG,
http::header::LAST_MODIFIED,
http::header::EXPIRES,
HeaderName::from_static("x-amz-checksum-crc32"),
HeaderName::from_static("x-amz-checksum-crc32c"),
HeaderName::from_static("x-amz-checksum-crc64nvme"),
HeaderName::from_static("x-amz-checksum-sha1"),
HeaderName::from_static("x-amz-checksum-sha256"),
HeaderName::from_static("x-amz-checksum-type"),
HeaderName::from_static("x-amz-tagging-count"),
HeaderName::from_static("x-amz-request-route"),
HeaderName::from_static("x-amz-request-token"),
] {
headers.remove(name);
}
let metadata_headers = headers
.keys()
.filter(|name| name.as_str().starts_with("x-amz-meta-"))
.cloned()
.collect::<Vec<_>>();
for name in metadata_headers {
headers.remove(name);
}
}
fn is_disallowed_object_lambda_response_header(name: &HeaderName) -> bool {
matches!(
name.as_str(),
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
)
}
fn build_object_lambda_passthrough_response(
mut response_headers: HeaderMap,
lambda_headers: &HeaderMap,
status: StatusCode,
body: Body,
) -> S3Response<Body> {
clear_object_lambda_variant_headers(&mut response_headers);
for (name, value) in lambda_headers {
if !is_disallowed_object_lambda_response_header(name) && name != "x-amz-request-route" && name != "x-amz-request-token" {
response_headers.insert(name.clone(), value.clone());
}
}
S3Response {
output: body,
status: Some(status),
headers: response_headers,
extensions: Extensions::new(),
}
}
async fn invoke_object_lambda_target(
req: &S3Request<Body>,
bucket: &str,
object: &str,
get_resp: S3Response<GetObjectOutput>,
) -> S3Result<S3Response<Body>> {
let lambda_config = resolve_object_lambda_webhook_config(&req.uri).await?;
let client = build_object_lambda_http_client(&lambda_config)?;
let lambda_arn = query_value_exact(&req.uri, "lambdaArn")
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| s3_error!(InvalidRequest, "lambdaArn query parameter must be provided exactly once"))?;
let input_s3_url = build_object_lambda_source_url(req)?;
let output_route = Uuid::new_v4().to_string();
let output_token = Uuid::new_v4().to_string();
let event_payload = build_object_lambda_event_payload(req, &lambda_arn, &input_s3_url, &output_route, &output_token)?;
let S3Response {
output,
headers: upstream_headers,
..
} = get_resp;
let response_headers = build_get_object_response_headers(&output, &upstream_headers)?;
let mut request_builder = client
.post(lambda_config.endpoint)
.header("x-rustfs-object-lambda-bucket", bucket)
.header("x-rustfs-object-lambda-key", object)
.header("x-rustfs-object-lambda-request-uri", req.uri.to_string())
.header(http::header::CONTENT_TYPE, "application/json")
.body(event_payload);
if !lambda_config.auth_token.is_empty() {
let tokens = lambda_config.auth_token.split_whitespace().collect::<Vec<_>>();
request_builder = match tokens.as_slice() {
[scheme, token] if !scheme.is_empty() && !token.is_empty() => {
request_builder.header(reqwest::header::AUTHORIZATION, lambda_config.auth_token)
}
[token] if !token.is_empty() => request_builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")),
_ => request_builder,
};
}
if let Some(version_id) = output.version_id.as_deref() {
request_builder = request_builder.header("x-rustfs-object-lambda-version-id", version_id);
}
let lambda_response = request_builder
.send()
.await
.map_err(|e| s3_error!(InternalError, "object lambda target request failed: {e}"))?;
let status = lambda_response.status();
let lambda_headers = lambda_response.headers().clone();
if status.is_success() {
validate_object_lambda_response_auth_headers(&lambda_headers, &output_route, &output_token)?;
}
let body = Body::from(StreamingBlob::wrap(lambda_response.bytes_stream()));
Ok(build_object_lambda_passthrough_response(response_headers, &lambda_headers, status, body))
}
struct ListenNotificationStream {
inner: ReceiverStream<Result<Bytes, StdError>>,
}
struct PeerLiveEventCursor {
client: PeerRestClient,
next_sequence: u64,
}
const LISTEN_NOTIFICATION_PEER_BATCH_LIMIT: u32 = 128;
const LISTEN_NOTIFICATION_PEER_POLL_INTERVAL: Duration = Duration::from_millis(250);
impl Stream for ListenNotificationStream {
type Item = Result<Bytes, StdError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = Pin::into_inner(self);
this.inner.poll_next_unpin(cx)
}
}
impl ByteStream for ListenNotificationStream {}
fn listen_notification_keepalive_plan(uri: &Uri) -> (Duration, Bytes) {
if let Some(ping_seconds) = query_value_exact(uri, "ping").and_then(|v| v.parse::<u64>().ok()) {
return (Duration::from_secs(ping_seconds), Bytes::from_static(b"{\"Records\":[]}\n"));
}
(Duration::from_millis(500), Bytes::from_static(b" "))
}
fn event_matches_listen_notification(event: &NotificationEvent, filter: &ListenNotificationFilter) -> bool {
if let Some(bucket) = &filter.bucket
&& event.s3.bucket.name != *bucket
{
return false;
}
if filter.event_mask != 0 && event.event_name.mask() & filter.event_mask == 0 {
return false;
}
let object_key = urlencoding::decode(&event.s3.object.key)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| event.s3.object.key.clone());
if let Some(prefix) = &filter.prefix
&& !object_key.starts_with(prefix)
{
return false;
}
if let Some(suffix) = &filter.suffix
&& !object_key.ends_with(suffix)
{
return false;
}
true
}
fn serialize_listen_notification_event(event: &NotificationEvent) -> S3Result<Bytes> {
#[derive(serde::Serialize)]
struct ListenNotificationEnvelope<'a> {
#[serde(rename = "Records")]
records: [&'a NotificationEvent; 1],
}
serde_json::to_vec(&ListenNotificationEnvelope { records: [event] })
.map(|mut payload| {
payload.push(b'\n');
Bytes::from(payload)
})
.map_err(|e| s3_error!(InternalError, "failed to serialize notification event: {e}"))
}
fn list_remote_live_event_peers() -> Vec<PeerLiveEventCursor> {
current_notification_system()
.map(|system| {
system
.peer_clients
.iter()
.flatten()
.cloned()
.map(|client| PeerLiveEventCursor {
client,
next_sequence: 0,
})
.collect()
})
.unwrap_or_default()
}
fn deserialize_peer_live_events(payload: &[u8]) -> Result<Vec<NotificationEvent>, serde_json::Error> {
serde_json::from_slice(payload)
}
async fn fan_in_remote_live_events(
peers: &mut [PeerLiveEventCursor],
filter: &ListenNotificationFilter,
tx: &mpsc::Sender<Result<Bytes, StdError>>,
) -> bool {
for peer in peers.iter_mut() {
loop {
let batch = match tokio::time::timeout(
Duration::from_secs(2),
peer.client
.get_live_events(peer.next_sequence, LISTEN_NOTIFICATION_PEER_BATCH_LIMIT),
)
.await
{
Ok(Ok(batch)) => batch,
Ok(Err(err)) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
peer = %peer.client.host,
result = "peer_fetch_failed",
error = %err,
"admin router state"
);
break;
}
Err(_) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
peer = %peer.client.host,
result = "peer_fetch_timeout",
error = "timeout",
"admin router state"
);
break;
}
};
peer.next_sequence = batch.next_sequence.max(peer.next_sequence);
if !batch.events.is_empty() {
match deserialize_peer_live_events(&batch.events) {
Ok(events) => {
for event in events {
if !event_matches_listen_notification(&event, filter) {
continue;
}
match serialize_listen_notification_event(&event) {
Ok(serialized) => {
if tx.send(Ok(serialized)).await.is_err() {
return false;
}
}
Err(err) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
source = "remote_peer",
peer = %peer.client.host,
result = "event_serialize_failed",
error = %err,
"admin router state"
);
}
}
}
}
Err(err) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
peer = %peer.client.host,
result = "peer_decode_failed",
error = %err,
"admin router state"
);
}
}
}
if !batch.truncated {
break;
}
}
}
true
}
fn build_listen_notification_response(uri: &Uri, bucket: Option<&str>) -> S3Result<S3Response<Body>> {
let (interval_duration, payload) = listen_notification_keepalive_plan(uri);
let filter = parse_listen_notification_filter(uri, bucket)?;
let mut live_events = notification_system().map(|system| system.subscribe_live_events());
let mut peer_live_events = list_remote_live_event_peers();
let (tx, rx) = mpsc::channel(16);
let stream: DynByteStream = Box::pin(ListenNotificationStream {
inner: ReceiverStream::new(rx),
});
spawn_traced(async move {
let mut ticker = tokio::time::interval(interval_duration);
let mut peer_ticker = tokio::time::interval(LISTEN_NOTIFICATION_PEER_POLL_INTERVAL);
// Skip the immediate first tick so behavior starts after interval duration.
ticker.tick().await;
peer_ticker.tick().await;
loop {
if let Some(events_rx) = live_events.as_mut() {
tokio::select! {
_ = tx.closed() => break,
_ = ticker.tick() => {
if tx.send(Ok(payload.clone())).await.is_err() {
break;
}
}
event = events_rx.recv() => {
match event {
Ok(event) => {
if !event_matches_listen_notification(&event, &filter) {
continue;
}
match serialize_listen_notification_event(&event) {
Ok(serialized) => {
if tx.send(Ok(serialized)).await.is_err() {
break;
}
}
Err(err) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
source = "local_stream",
result = "event_serialize_failed",
error = %err,
"admin router state"
);
}
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_LIVE_EVENTS,
result = "stream_lagged",
skipped,
"admin router state"
);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = peer_ticker.tick(), if !peer_live_events.is_empty() => {
if !fan_in_remote_live_events(&mut peer_live_events, &filter, &tx).await {
break;
}
}
}
} else {
tokio::select! {
_ = tx.closed() => break,
_ = ticker.tick() => {
if tx.send(Ok(payload.clone())).await.is_err() {
break;
}
}
_ = peer_ticker.tick(), if !peer_live_events.is_empty() => {
if !fan_in_remote_live_events(&mut peer_live_events, &filter, &tx).await {
break;
}
}
}
}
}
});
let mut resp = S3Response::with_status(Body::from(stream), StatusCode::OK);
resp.headers
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
resp.headers
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
resp.headers.insert("x-accel-buffering", HeaderValue::from_static("no"));
Ok(resp)
}
async fn ensure_replication_bucket_exists(bucket: &str) -> S3Result<()> {
let Some(store) = current_object_store_handle() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init"));
};
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
Ok(())
}
async fn ensure_replication_config_exists(bucket: &str) -> S3Result<()> {
match metadata_sys::get_replication_config(bucket).await {
Ok(_) => Ok(()),
Err(StorageError::ConfigNotFound) => Err(s3_error!(ReplicationConfigurationNotFoundError)),
Err(err) => Err(ApiError::from(err).into()),
}
}
async fn build_replication_metrics_response(bucket: &str, route: ReplicationExtRoute) -> S3Result<S3Response<Body>> {
let bucket_stats = match current_replication_stats_handle() {
Some(stats) => stats.get_latest_replication_stats(bucket).await,
None => BucketStats::default(),
};
let bucket_stats = apply_replication_metrics_bandwidth_report(bucket_stats, collect_replication_metrics_bandwidth(bucket));
let bucket_stats = apply_replication_metrics_runtime_fields(bucket_stats, route, replication_metrics_uptime_seconds());
let body = serialize_replication_metrics_body(&bucket_stats, route)?;
let mut resp = S3Response::with_status(Body::from(body), StatusCode::OK);
resp.headers
.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(resp)
}
fn replication_metrics_uptime_seconds() -> i64 {
current_boot_time()
.and_then(|boot_time| SystemTime::now().duration_since(boot_time).ok())
.map(|uptime| uptime.as_secs() as i64)
.unwrap_or_default()
}
fn collect_replication_metrics_bandwidth(bucket: &str) -> HashMap<String, BandwidthDetails> {
current_bucket_monitor_handle()
.map(|monitor| {
monitor
.get_report(|name| name == bucket)
.bucket_stats
.into_iter()
.filter_map(|(opts, details)| {
if opts.replication_arn.is_empty() {
None
} else {
Some((opts.replication_arn, details))
}
})
.collect()
})
.unwrap_or_default()
}
fn apply_replication_metrics_bandwidth_report(
mut bucket_stats: BucketStats,
bandwidth_report: HashMap<String, BandwidthDetails>,
) -> BucketStats {
for (arn, details) in bandwidth_report {
let stat = bucket_stats.replication_stats.stats.entry(arn).or_default();
stat.bandwidth_limit_bytes_per_sec = details.limit_bytes_per_sec;
stat.current_bandwidth_bytes_per_sec = details.current_bandwidth_bytes_per_sec;
}
bucket_stats
}
fn apply_replication_metrics_runtime_fields(
mut bucket_stats: BucketStats,
route: ReplicationExtRoute,
uptime_seconds: i64,
) -> BucketStats {
if route == ReplicationExtRoute::MetricsV2 {
bucket_stats.uptime = uptime_seconds;
}
bucket_stats
}
fn serialize_replication_metrics_body(bucket_stats: &BucketStats, route: ReplicationExtRoute) -> S3Result<Vec<u8>> {
match route {
ReplicationExtRoute::MetricsV1 => {
serde_json::to_vec(&bucket_stats.replication_stats).map_err(|e| s3_error!(InternalError, "{e}"))
}
ReplicationExtRoute::MetricsV2 => serde_json::to_vec(bucket_stats).map_err(|e| s3_error!(InternalError, "{e}")),
ReplicationExtRoute::Check | ReplicationExtRoute::ResetStart | ReplicationExtRoute::ResetStatus => {
Err(s3_error!(InternalError, "invalid route for metrics response"))
}
}
}
async fn authorize_replication_extension_request(req: &mut S3Request<Body>, ext_req: &ReplicationExtRequest) -> S3Result<()> {
let Some(input_cred) = req.credentials.as_ref() else {
return Err(s3_error!(AccessDenied, "Signature is required"));
};
let (cred, is_owner) =
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
req.extensions.insert(ReqInfo {
cred: Some(cred),
is_owner,
bucket: Some(ext_req.bucket.clone()),
object: None,
version_id: None,
region: current_region(),
..Default::default()
});
license_check().map_err(|er| match er.kind() {
std::io::ErrorKind::PermissionDenied => s3_error!(AccessDenied, "{er}"),
_ => {
error!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_OBJECT_LAMBDA,
result = "license_check_failed",
error = %er,
"admin router state"
);
s3_error!(InternalError, "License validation failed")
}
})?;
let action = match ext_req.route {
ReplicationExtRoute::MetricsV1 | ReplicationExtRoute::MetricsV2 | ReplicationExtRoute::Check => {
Action::S3Action(S3Action::GetReplicationConfigurationAction)
}
ReplicationExtRoute::ResetStart | ReplicationExtRoute::ResetStatus => {
Action::S3Action(S3Action::ResetBucketReplicationStateAction)
}
};
authorize_request(req, action).await
}
fn parse_reset_start_target(uri: &Uri) -> S3Result<ReplicationResetStartRequest> {
let arn = query_value_exact(uri, "arn").filter(|v| !v.is_empty()).unwrap_or_default();
let now = OffsetDateTime::now_utc();
let reset_before = match query_value_exact(uri, "older-than").filter(|v| !v.is_empty()) {
Some(older_than) => {
let duration = parse_duration(&older_than)
.map_err(|err| s3_error!(InvalidRequest, "invalid older-than query parameter: {err}"))?;
let duration = time::Duration::try_from(duration)
.map_err(|err| s3_error!(InvalidRequest, "invalid older-than query parameter: {err}"))?;
Some(now - duration)
}
None => Some(now),
};
let reset_id = query_value_exact(uri, "reset-id")
.filter(|v| !v.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string());
Ok(ReplicationResetStartRequest {
arn,
reset_id,
reset_before,
})
}
fn collect_resettable_replication_target_arns(config: &s3s::dto::ReplicationConfiguration) -> Vec<String> {
let mut arns = Vec::new();
let mut seen = HashSet::new();
for rule in &config.rules {
if rule.status == s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::DISABLED) {
continue;
}
let existing_object_enabled = rule.existing_object_replication.as_ref().is_some_and(|status| {
status.status
== s3s::dto::ExistingObjectReplicationStatus::from_static(s3s::dto::ExistingObjectReplicationStatus::ENABLED)
});
if !existing_object_enabled {
continue;
}
let arn = if config.role.is_empty() {
rule.destination.bucket.clone()
} else {
config.role.clone()
};
if seen.insert(arn.clone()) {
arns.push(arn);
}
if !config.role.is_empty() {
break;
}
}
arns
}
fn resolve_replication_reset_target_arn(config: &s3s::dto::ReplicationConfiguration, requested_arn: &str) -> S3Result<String> {
let resettable_arns = collect_resettable_replication_target_arns(config);
if requested_arn.is_empty() {
return match resettable_arns.as_slice() {
[] => Err(s3_error!(
InvalidRequest,
"replication reset requires a target with existing object replication enabled"
)),
[arn] => Ok(arn.clone()),
_ => Err(s3_error!(
InvalidRequest,
"arn query parameter is required when multiple replication targets are configured"
)),
};
}
let (has_arn, existing_object_enabled) = config.has_existing_object_replication(requested_arn);
if !has_arn {
return Err(s3_error!(InvalidRequest, "replication reset arn is not configured for this bucket"));
}
if !existing_object_enabled {
return Err(s3_error!(
InvalidRequest,
"replication reset requires existing object replication to be enabled for the target"
));
}
Ok(requested_arn.to_string())
}
fn build_replication_reset_response(targets: Vec<ReplicationResetTarget>) -> S3Result<S3Response<Body>> {
let data = serde_json::to_vec(&ReplicationResetResponse { targets }).map_err(|e| s3_error!(InternalError, "{e}"))?;
let mut resp = S3Response::with_status(Body::from(data), StatusCode::OK);
resp.headers
.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(resp)
}
fn apply_replication_reset_to_targets(targets: &mut BucketTargets, reset: &ReplicationResetStartRequest) -> S3Result<()> {
let Some(target) = targets.targets.iter_mut().find(|target| target.arn == reset.arn) else {
return Err(s3_error!(InvalidRequest, "replication reset arn is not configured for this bucket"));
};
target.reset_id = reset.reset_id.clone();
target.reset_before_date = reset.reset_before;
Ok(())
}
fn parse_reset_status_target(uri: &Uri) -> ReplicationResetStatusRequest {
ReplicationResetStatusRequest {
arn: query_value_exact(uri, "arn").filter(|v| !v.is_empty()),
}
}
fn build_replication_reset_status_targets(
status: &BucketReplicationResyncStatus,
arn_filter: Option<&str>,
) -> Vec<ReplicationResetStatusTarget> {
let mut targets = status
.targets_map
.iter()
.filter(|(arn, _)| arn_filter.is_none_or(|filter| *arn == filter))
.map(|(arn, target)| ReplicationResetStatusTarget {
arn: arn.clone(),
reset_id: target.resync_id.clone(),
reset_before_date: target.resync_before_date,
start_time: target.start_time,
end_time: target.last_update,
status: target.resync_status.to_string(),
replicated_count: target.replicated_count,
replicated_size: target.replicated_size,
failed_count: target.failed_count,
failed_size: target.failed_size,
bucket: target.bucket.clone(),
object: target.object.clone(),
error: target.error.clone(),
})
.collect::<Vec<_>>();
targets.sort_by(|left, right| left.arn.cmp(&right.arn));
targets
}
fn build_replication_reset_status_response(
status: BucketReplicationResyncStatus,
arn_filter: Option<&str>,
) -> S3Result<S3Response<Body>> {
let data = serde_json::to_vec(&ReplicationResetStatusResponse {
targets: build_replication_reset_status_targets(&status, arn_filter),
})
.map_err(|e| s3_error!(InternalError, "{e}"))?;
let mut resp = S3Response::with_status(Body::from(data), StatusCode::OK);
resp.headers
.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(resp)
}
fn build_replication_check_response(mut targets: Vec<ReplicationCheckTargetStatus>) -> S3Result<S3Response<Body>> {
targets.sort_by(|left, right| left.arn.cmp(&right.arn));
if let Some(target) = targets.into_iter().find(|target| target.status != "OK") {
let detail = target.error.unwrap_or_else(|| target.status.to_lowercase());
return Err(s3_error!(
InvalidRequest,
"replication check failed for target {} (bucket {}): {}",
target.arn,
target.bucket,
detail
));
}
Ok(S3Response::with_status(Body::empty(), StatusCode::OK))
}
fn format_replication_check_client_error(err: &S3ClientError, context: ReplicationCheckFailureContext) -> String {
if err.code.as_deref() == Some("AccessDenied") {
return match context {
ReplicationCheckFailureContext::ReplicateObject => {
"s3:ReplicateObject permissions missing for replication user".to_string()
}
ReplicationCheckFailureContext::ReplicateDeleteMarker => {
"s3:ReplicateDelete permissions missing for replication user".to_string()
}
ReplicationCheckFailureContext::DeleteObjectVersion => {
"s3:ReplicateDelete/s3:DeleteObject permissions missing for replication user".to_string()
}
ReplicationCheckFailureContext::BucketCheck => "target bucket check failed: access denied".to_string(),
ReplicationCheckFailureContext::VersioningCheck => "target bucket versioning check failed: access denied".to_string(),
ReplicationCheckFailureContext::ObjectLockCheck => "target object lock check failed: access denied".to_string(),
};
}
let context = match context {
ReplicationCheckFailureContext::BucketCheck => "target bucket check failed",
ReplicationCheckFailureContext::VersioningCheck => "target bucket versioning check failed",
ReplicationCheckFailureContext::ReplicateObject => "target replicate object check failed",
ReplicationCheckFailureContext::ReplicateDeleteMarker => "target replicate delete-marker check failed",
ReplicationCheckFailureContext::DeleteObjectVersion => "target delete object version check failed",
ReplicationCheckFailureContext::ObjectLockCheck => "target object lock check failed",
};
match (err.code.as_deref(), err.message.as_deref()) {
(Some("NoSuchBucket" | "NotFound"), _) => format!("{context}: target bucket does not exist"),
(Some(code), Some(message)) if !message.is_empty() => format!("{context}: {code}: {message}"),
(Some(code), _) => format!("{context}: {code}"),
(None, Some(message)) if !message.is_empty() => format!("{context}: {message}"),
_ => format!("{context}: {}", err.error),
}
}
fn is_object_lock_not_enabled_error(err: &S3ClientError) -> bool {
matches!(
err.code.as_deref(),
Some("ObjectLockConfigurationNotFoundError" | "ObjectLockConfigurationNotFound")
) || err.message.as_deref().is_some_and(|message| {
message.contains("Object Lock configuration does not exist")
|| message.contains("Object Lock is not enabled for this bucket")
})
}
fn validate_replication_check_config_targets(
targets: &BucketTargets,
config: &s3s::dto::ReplicationConfiguration,
) -> S3Result<()> {
let configured_arns = targets
.targets
.iter()
.filter(|target| target.target_type == BucketTargetType::ReplicationService)
.map(|target| target.arn.as_str())
.collect::<HashSet<_>>();
for rule in &config.rules {
if rule.status == s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::DISABLED) {
continue;
}
let configured_arn = if config.role.is_empty() {
rule.destination.bucket.as_str()
} else {
config.role.as_str()
};
if configured_arns.contains(configured_arn) {
continue;
}
return Err(s3_error!(
InvalidRequest,
"replication config with rule ID {} has a stale target",
rule.id.clone().unwrap_or_default()
));
}
Ok(())
}
fn filter_replication_check_targets(targets: BucketTargets, config: &s3s::dto::ReplicationConfiguration) -> Vec<BucketTarget> {
let referenced_arns = config
.filter_target_arns(&ObjectOpts {
op_type: ReplicationType::All,
..Default::default()
})
.into_iter()
.collect::<HashSet<_>>();
targets
.targets
.into_iter()
.filter(|target| target.target_type == BucketTargetType::ReplicationService)
.filter(|target| referenced_arns.is_empty() || referenced_arns.contains(&target.arn))
.collect()
}
async fn check_replication_target(bucket: &str, target: &BucketTarget) -> ReplicationCheckTargetStatus {
let mut result = ReplicationCheckTargetStatus {
arn: target.arn.clone(),
endpoint: target.endpoint.clone(),
bucket: target.target_bucket.clone(),
status: "OK".to_string(),
error: None,
};
if target.target_bucket == bucket
&& !target.deployment_id.is_empty()
&& current_deployment_id().as_deref() == Some(target.deployment_id.as_str())
{
result.status = "FAILED".to_string();
result.error = Some("target bucket must not match source bucket on the same deployment".to_string());
return result;
}
let target_client = match resolve_replication_target_client(bucket, target).await {
Ok(client) => client,
Err(err) => {
result.status = "FAILED".to_string();
result.error = Some(err);
return result;
}
};
match target_client.bucket_exists(&target.target_bucket).await {
Ok(true) => {}
Ok(false) => {
result.status = "FAILED".to_string();
result.error = Some("target bucket does not exist".to_string());
return result;
}
Err(err) => {
result.status = "FAILED".to_string();
result.error = Some(format_replication_check_client_error(&err, ReplicationCheckFailureContext::BucketCheck));
return result;
}
}
match target_client.get_bucket_versioning(&target.target_bucket).await {
Ok(Some(_)) => {}
Ok(None) => {
result.status = "FAILED".to_string();
result.error = Some(format!("target bucket {} is not versioned", target.target_bucket));
return result;
}
Err(err) => {
result.status = "FAILED".to_string();
result.error = Some(format_replication_check_client_error(
&err,
ReplicationCheckFailureContext::VersioningCheck,
));
return result;
}
}
let probe_key = format!(".rustfs-replication-check-{}", Uuid::new_v4());
let (probe_version_id, probe_time) =
match put_replication_probe_object(&target_client, &target.target_bucket, &probe_key).await {
Ok(output) => output,
Err(err) => {
result.status = "FAILED".to_string();
result.error = Some(format_replication_check_client_error(
&err,
ReplicationCheckFailureContext::ReplicateObject,
));
return result;
}
};
if let Err(err) = delete_replication_probe_object(
&target_client,
&target.target_bucket,
&probe_key,
probe_version_id.as_deref(),
build_replication_probe_remove_options(probe_time, true),
)
.await
{
result.status = "FAILED".to_string();
result.error = Some(format_replication_check_client_error(
&err,
ReplicationCheckFailureContext::ReplicateDeleteMarker,
));
return result;
}
if let Err(err) = delete_replication_probe_object(
&target_client,
&target.target_bucket,
&probe_key,
probe_version_id.as_deref(),
build_replication_probe_remove_options(probe_time, false),
)
.await
{
result.status = "FAILED".to_string();
result.error = Some(format_replication_check_client_error(
&err,
ReplicationCheckFailureContext::DeleteObjectVersion,
));
return result;
}
result
}
async fn resolve_replication_target_client(bucket: &str, target: &BucketTarget) -> Result<Arc<TargetClient>, String> {
let target_sys = BucketTargetSys::get();
match target_sys.get_remote_target_client(bucket, &target.arn).await {
Some(client) => Ok(client),
None => target_sys
.get_remote_target_client_internal(target)
.await
.map(Arc::new)
.map_err(|err| err.to_string()),
}
}
fn build_replication_probe_put_options(now: OffsetDateTime) -> PutObjectOptions {
PutObjectOptions {
internal: super::storage_api::bucket::target_sys::AdvancedPutOptions {
source_version_id: Uuid::new_v4().to_string(),
replication_status: ReplicationStatusType::Replica,
source_mtime: now,
replication_request: true,
replication_validity_check: true,
..Default::default()
},
..Default::default()
}
}
fn build_replication_probe_remove_options(now: OffsetDateTime, replication_delete_marker: bool) -> RemoveObjectOptions {
RemoveObjectOptions {
force_delete: false,
governance_bypass: false,
replication_delete_marker,
replication_mtime: Some(now),
replication_status: ReplicationStatusType::Replica,
replication_request: true,
replication_validity_check: true,
}
}
async fn put_replication_probe_object(
target_client: &TargetClient,
target_bucket: &str,
probe_key: &str,
) -> Result<(Option<String>, OffsetDateTime), S3ClientError> {
let now = OffsetDateTime::now_utc();
let options = build_replication_probe_put_options(now);
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_VERSION_ID, &options.internal.source_version_id);
insert_header(
&mut headers,
SUFFIX_SOURCE_MTIME,
options.internal.source_mtime.format(&Rfc3339).unwrap_or_default(),
);
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_CHECK, "true");
headers.insert(
HeaderName::from_static("x-amz-replication-status"),
HeaderValue::from_static(ReplicationStatusType::Replica.as_str()),
);
target_client
.client
.put_object()
.bucket(target_bucket)
.key(probe_key)
.content_length(8)
.body(AwsByteStream::from_static(b"aaaaaaaa"))
.customize()
.map_request(move |mut req| {
for (key, value) in headers.clone() {
req.headers_mut().insert(key.unwrap(), value);
}
Result::<_, std::io::Error>::Ok(req)
})
.send()
.await
.map(|output| (output.version_id().map(ToOwned::to_owned), now))
.map_err(S3ClientError::from)
}
async fn delete_replication_probe_object(
target_client: &TargetClient,
target_bucket: &str,
probe_key: &str,
version_id: Option<&str>,
options: RemoveObjectOptions,
) -> Result<(), S3ClientError> {
let mut headers = HeaderMap::new();
if options.replication_delete_marker {
insert_header(&mut headers, SUFFIX_SOURCE_DELETEMARKER, "true");
}
if let Some(replication_mtime) = options.replication_mtime {
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, replication_mtime.format(&Rfc3339).unwrap_or_default());
}
headers.insert(
HeaderName::from_static("x-amz-replication-status"),
HeaderValue::from_static(options.replication_status.as_str()),
);
if options.replication_request {
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
}
if options.replication_validity_check {
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_CHECK, "true");
}
target_client
.client
.delete_object()
.bucket(target_bucket)
.key(probe_key)
.set_version_id(version_id.map(ToOwned::to_owned))
.customize()
.map_request(move |mut req| {
for (key, value) in headers.clone() {
req.headers_mut().insert(key.unwrap(), value);
}
Result::<_, std::io::Error>::Ok(req)
})
.send()
.await
.map(|_| ())
.map_err(S3ClientError::from)
}
async fn source_bucket_requires_object_lock(bucket: &str) -> S3Result<bool> {
match metadata_sys::get_object_lock_config(bucket).await {
Ok((config, _)) => Ok(config
.object_lock_enabled
.as_ref()
.is_some_and(|state| state.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)),
Err(StorageError::ConfigNotFound) => Ok(false),
Err(err) => Err(ApiError::from(err).into()),
}
}
async fn run_replication_check(bucket: &str) -> S3Result<S3Response<Body>> {
if !BucketVersioningSys::enabled(bucket).await {
return Err(s3_error!(
InvalidRequest,
"replication validation requires bucket versioning to be enabled"
));
}
let source_requires_object_lock = source_bucket_requires_object_lock(bucket).await?;
let (config, _) = metadata_sys::get_replication_config(bucket).await.map_err(ApiError::from)?;
let targets = metadata_sys::list_bucket_targets(bucket).await.map_err(ApiError::from)?;
validate_replication_check_config_targets(&targets, &config)?;
let replication_targets = filter_replication_check_targets(targets, &config);
if replication_targets.is_empty() {
return Err(s3_error!(
InvalidRequest,
"replication check requires at least one configured replication target"
));
}
let mut statuses = Vec::with_capacity(replication_targets.len());
for target in &replication_targets {
let mut status = check_replication_target(bucket, target).await;
if status.status == "OK" && source_requires_object_lock {
let target_lock_enabled = match target_client_object_lock_enabled(bucket, target).await {
Ok(enabled) => enabled,
Err(err) => {
status.status = "FAILED".to_string();
status.error = Some(format_replication_check_client_error(
&err,
ReplicationCheckFailureContext::ObjectLockCheck,
));
false
}
};
if status.status == "OK" && !target_lock_enabled {
status.status = "FAILED".to_string();
status.error = Some(format!("target bucket {} is not object lock enabled", target.target_bucket));
}
}
statuses.push(status);
}
build_replication_check_response(statuses)
}
async fn target_client_object_lock_enabled(bucket: &str, target: &BucketTarget) -> Result<bool, S3ClientError> {
let target_client = resolve_replication_target_client(bucket, target)
.await
.map_err(S3ClientError::new)?;
match target_client
.client
.get_object_lock_configuration()
.bucket(&target.target_bucket)
.send()
.await
{
Ok(res) => Ok(res
.object_lock_configuration()
.and_then(|cfg| cfg.object_lock_enabled())
.is_some_and(|state| state.as_str() == "Enabled")),
Err(err) => {
let err = S3ClientError::from(err);
if is_object_lock_not_enabled_error(&err) {
Ok(false)
} else {
Err(err)
}
}
}
}
async fn start_replication_resync(bucket: &str, reset: &ReplicationResetStartRequest) -> S3Result<ReplicationResetTarget> {
let (config, _) = metadata_sys::get_replication_config(bucket).await.map_err(ApiError::from)?;
let resolved_arn = resolve_replication_reset_target_arn(&config, &reset.arn)?;
let mut resolved_reset = reset.clone();
resolved_reset.arn = resolved_arn.clone();
let mut targets = metadata_sys::list_bucket_targets(bucket).await.map_err(ApiError::from)?;
apply_replication_reset_to_targets(&mut targets, &resolved_reset)?;
let json_targets = serde_json::to_vec(&targets).map_err(|e| s3_error!(InternalError, "{e}"))?;
metadata_sys::update(bucket, BUCKET_TARGETS_FILE, json_targets)
.await
.map_err(ApiError::from)?;
BucketTargetSys::get().update_all_targets(bucket, Some(&targets)).await;
let Some(pool) = current_replication_pool_handle() else {
return Err(s3_error!(InternalError, "replication pool is not initialized"));
};
pool.start_bucket_resync(ResyncOpts {
bucket: bucket.to_string(),
arn: resolved_arn.clone(),
resync_id: reset.reset_id.clone(),
resync_before: reset.reset_before,
})
.await
.map_err(|e| s3_error!(InternalError, "{e}"))?;
Ok(ReplicationResetTarget {
arn: resolved_arn,
reset_id: reset.reset_id.clone(),
})
}
async fn load_replication_resync_status(bucket: &str) -> S3Result<BucketReplicationResyncStatus> {
let Some(pool) = current_replication_pool_handle() else {
return Err(s3_error!(InternalError, "replication pool is not initialized"));
};
pool.get_bucket_resync_status(bucket)
.await
.map_err(|e| s3_error!(InternalError, "{e}"))
}
async fn handle_replication_extension_request(
req: &mut S3Request<Body>,
ext_req: &ReplicationExtRequest,
) -> S3Result<S3Response<Body>> {
authorize_replication_extension_request(req, ext_req).await?;
ensure_replication_bucket_exists(&ext_req.bucket).await?;
match ext_req.route {
ReplicationExtRoute::MetricsV1 | ReplicationExtRoute::MetricsV2 => {
ensure_replication_config_exists(&ext_req.bucket).await?;
build_replication_metrics_response(&ext_req.bucket, ext_req.route).await
}
ReplicationExtRoute::Check => {
let (versioning, _) = metadata_sys::get_versioning_config(&ext_req.bucket)
.await
.map_err(ApiError::from)?;
if !versioning.enabled() && !BucketVersioningSys::enabled(&ext_req.bucket).await {
return Err(s3_error!(
InvalidRequest,
"replication validation requires bucket versioning to be enabled"
));
}
ensure_replication_config_exists(&ext_req.bucket).await?;
run_replication_check(&ext_req.bucket).await
}
ReplicationExtRoute::ResetStatus => {
ensure_replication_config_exists(&ext_req.bucket).await?;
let status_req = parse_reset_status_target(&req.uri);
let status = load_replication_resync_status(&ext_req.bucket).await?;
build_replication_reset_status_response(status, status_req.arn.as_deref())
}
ReplicationExtRoute::ResetStart => {
ensure_replication_config_exists(&ext_req.bucket).await?;
let target = parse_reset_start_target(&req.uri)?;
let target = start_replication_resync(&ext_req.bucket, &target).await?;
build_replication_reset_response(vec![target])
}
}
}
async fn authorize_misc_extension_request(req: &mut S3Request<Body>, route: &MiscExtRoute) -> S3Result<()> {
let Some(input_cred) = req.credentials.as_ref() else {
return Err(s3_error!(AccessDenied, "Signature is required"));
};
let (cred, is_owner) =
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
let (bucket, object, action) = match route {
MiscExtRoute::ObjectLambda { bucket, object } => {
(Some(bucket.clone()), Some(object.clone()), Action::S3Action(S3Action::GetObjectAction))
}
MiscExtRoute::ListenNotification { bucket: Some(bucket) } => {
(Some(bucket.clone()), None, Action::S3Action(S3Action::ListenBucketNotificationAction))
}
MiscExtRoute::ListenNotification { bucket: None } => (None, None, Action::S3Action(S3Action::ListenNotificationAction)),
};
req.extensions.insert(ReqInfo {
cred: Some(cred),
is_owner,
bucket,
object,
version_id: None,
region: current_region(),
..Default::default()
});
license_check().map_err(|er| match er.kind() {
std::io::ErrorKind::PermissionDenied => s3_error!(AccessDenied, "{er}"),
_ => {
error!(
event = EVENT_ADMIN_ROUTER_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_OBJECT_LAMBDA,
result = "license_check_failed",
error = %er,
"admin router state"
);
s3_error!(InternalError, "License validation failed")
}
})?;
authorize_request(req, action).await
}
async fn handle_misc_extension_request(req: &mut S3Request<Body>, route: &MiscExtRoute) -> S3Result<S3Response<Body>> {
authorize_misc_extension_request(req, route).await?;
validate_misc_extension_request(&req.uri, route)?;
match route {
MiscExtRoute::ObjectLambda { bucket, object } => {
let get_req = build_object_lambda_get_request(req, bucket, object)?;
let usecase = default_object_usecase();
let get_resp = Box::pin(usecase.execute_get_object(get_req)).await?;
invoke_object_lambda_target(req, bucket, object, get_resp).await
}
MiscExtRoute::ListenNotification { bucket } => {
if let Some(bucket_name) = bucket {
let Some(store) = current_object_store_handle() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init"));
};
store
.get_bucket_info(bucket_name, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
}
build_listen_notification_response(&req.uri, bucket.as_deref())
}
}
}
pub struct S3Router<T> {
router: Router<T>,
console_enabled: bool,
console_router: Option<axum::routing::RouterIntoService<Body>>,
#[cfg(test)]
registered_routes: Vec<String>,
}
fn is_public_health_path(path: &str) -> bool {
path == HEALTH_PREFIX || path == HEALTH_READY_PATH
}
fn is_object_zip_download_token_path(method: &Method, uri: &Uri) -> bool {
if method != Method::GET {
return false;
}
let path = canonicalize_admin_path(uri.path());
path.starts_with(&format!("{ADMIN_PREFIX}{ADMIN_OBJECT_ZIP_DOWNLOADS_PATH}/"))
&& path.ends_with(".zip")
&& query_value_exact(uri, "token").is_some_and(|token| !token.is_empty())
}
fn canonicalize_admin_path(path: &str) -> std::borrow::Cow<'_, str> {
if is_admin_path(path)
&& let Some(suffix) = path.strip_prefix(MINIO_ADMIN_PREFIX)
{
return std::borrow::Cow::Owned(format!("{ADMIN_PREFIX}{suffix}"));
}
std::borrow::Cow::Borrowed(path)
}
impl<T: Operation> S3Router<T> {
pub fn new(console_enabled: bool) -> Self {
let router = Router::new();
let console_router = if console_enabled {
Some(make_console_server().into_service::<Body>())
} else {
None
};
Self {
router,
console_enabled,
console_router,
#[cfg(test)]
registered_routes: Vec::new(),
}
}
pub fn insert(&mut self, method: Method, path: &str, operation: T) -> std::io::Result<()> {
let path = Self::make_route_str(method, path);
// warn!("set uri {}", &path);
#[cfg(test)]
{
self.router.insert(path.clone(), operation).map_err(std::io::Error::other)?;
self.registered_routes.push(path);
}
#[cfg(not(test))]
self.router.insert(path, operation).map_err(std::io::Error::other)?;
Ok(())
}
fn make_route_str(method: Method, path: &str) -> String {
format!("{}|{}", method.as_str(), path)
}
}
#[cfg(test)]
impl<T: Operation> S3Router<T> {
pub(crate) fn contains_route(&self, method: Method, path: &str) -> bool {
let route = Self::make_route_str(method, path);
self.router.at(&route).is_ok()
}
pub(crate) fn contains_compatible_route(&self, method: Method, path: &str) -> bool {
let canonical_path = canonicalize_admin_path(path);
let route = Self::make_route_str(method, canonical_path.as_ref());
self.router.at(&route).is_ok()
}
#[cfg(test)]
pub(crate) fn registered_routes(&self) -> &[String] {
&self.registered_routes
}
}
impl<T: Operation> Default for S3Router<T> {
fn default() -> Self {
Self::new(false)
}
}
#[async_trait::async_trait]
impl<T> S3Route for S3Router<T>
where
T: Operation,
{
fn is_match(&self, method: &Method, uri: &Uri, headers: &HeaderMap, _: &mut Extensions) -> bool {
if parse_replication_extension_request(method, uri).is_some() || parse_misc_extension_request(method, uri).is_some() {
return true;
}
let path = uri.path();
// Profiling endpoints
if method == Method::GET && (path == PROFILE_CPU_PATH || path == PROFILE_MEMORY_PATH) {
return true;
}
// Health check
if (method == Method::HEAD || method == Method::GET) && is_public_health_path(path) {
return true;
}
// AssumeRole
if method == Method::POST
&& path == "/"
&& headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or("").trim().to_lowercase())
.map(|ct| ct == "application/x-www-form-urlencoded")
.unwrap_or(false)
{
return true;
}
is_admin_path(path) || is_console_path(path)
}
// check_access before call
async fn check_access(&self, req: &mut S3Request<Body>) -> S3Result<()> {
if parse_replication_extension_request(&req.method, &req.uri).is_some()
|| parse_misc_extension_request(&req.method, &req.uri).is_some()
{
return match req.credentials {
Some(_) => Ok(()),
None => Err(s3_error!(AccessDenied, "Signature is required")),
};
}
// Allow unauthenticated access to health check
let path = req.uri.path();
// Health check
if (req.method == Method::HEAD || req.method == Method::GET) && is_public_health_path(path) {
return Ok(());
}
// Allow unauthenticated access to console static files if console is enabled
if self.console_enabled && is_console_path(path) {
return Ok(());
}
// Allow unauthenticated access to OIDC endpoints (user not yet authenticated)
if is_oidc_path(path) {
return Ok(());
}
// Object ZIP downloads are browser-navigated with a short-lived token;
// the handler validates the token before returning any bytes.
if is_object_zip_download_token_path(&req.method, &req.uri) {
return Ok(());
}
// Allow unauthenticated STS requests to POST / (AssumeRoleWithWebIdentity
// doesn't use SigV4 — the JWT token in the request body is the authentication).
// The handler dispatches on the Action parameter: AssumeRole will reject if
// credentials are missing, AssumeRoleWithWebIdentity will validate the JWT.
// Require application/x-www-form-urlencoded Content-Type to narrow the bypass.
if req.method == Method::POST
&& path == "/"
&& req.credentials.is_none()
&& req
.headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| {
ct.split(';')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("application/x-www-form-urlencoded")
})
.unwrap_or(false)
{
return Ok(());
}
// For non-RPC admin requests, check credentials
match req.credentials {
Some(_) => Ok(()),
None => Err(s3_error!(AccessDenied, "Signature is required")),
}
}
async fn call(&self, mut req: S3Request<Body>) -> S3Result<S3Response<Body>> {
if let Some(ext_req) = parse_replication_extension_request(&req.method, &req.uri) {
return handle_replication_extension_request(&mut req, &ext_req).await;
}
if let Some(ext_req) = parse_misc_extension_request(&req.method, &req.uri) {
return Box::pin(handle_misc_extension_request(&mut req, &ext_req)).await;
}
// Console requests should be handled by console router first (including OPTIONS)
// Console has its own CORS layer configured
if self.console_enabled && is_console_path(req.uri.path()) {
if let Some(console_router) = &self.console_router {
let mut console_router = console_router.clone();
let req = convert_request(req);
let result = console_router.call(req).await;
return match result {
Ok(resp) => Ok(convert_response(resp)),
Err(e) => Err(s3_error!(InternalError, "{}", e)),
};
}
return Err(s3_error!(InternalError, "console is not enabled"));
}
let canonical_path = canonicalize_admin_path(req.uri.path());
let uri = format!("{}|{}", &req.method, canonical_path.as_ref());
if let Ok(mat) = self.router.at(&uri) {
let op: &T = mat.value;
let mut resp = op.call(req, mat.params).await?;
resp.status = Some(resp.output.0);
let response = resp.map_output(|x| x.1);
return Ok(response);
}
Err(s3_error!(NotImplemented))
}
}
#[async_trait::async_trait]
pub trait Operation: Send + Sync + 'static {
// fn method() -> Method;
// fn uri() -> &'static str;
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>>;
}
pub struct AdminOperation(pub &'static dyn Operation);
#[async_trait::async_trait]
impl Operation for AdminOperation {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
self.0.call(req, params).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::HeaderMap;
use http::Method;
use http::Uri;
use s3s::S3Request;
use time::macros::datetime;
#[test]
fn canonicalize_admin_path_maps_compat_prefix_to_rustfs_prefix() {
assert_eq!(canonicalize_admin_path("/minio/admin/v3/info").as_ref(), "/rustfs/admin/v3/info");
assert_eq!(canonicalize_admin_path("/minio/admin").as_ref(), "/rustfs/admin");
assert_eq!(canonicalize_admin_path("/rustfs/admin/v3/info").as_ref(), "/rustfs/admin/v3/info");
assert_eq!(
canonicalize_admin_path("/minio/administrator/object").as_ref(),
"/minio/administrator/object"
);
assert_eq!(canonicalize_admin_path("/minio/adminx/object").as_ref(), "/minio/adminx/object");
}
#[test]
fn is_admin_path_accepts_rustfs_and_compat_prefixes() {
assert!(is_admin_path("/rustfs/admin/v3/info"));
assert!(is_admin_path("/minio/admin/v3/info"));
assert!(is_admin_path(&format!("{}/config", crate::server::TABLE_CATALOG_PREFIX)));
assert!(is_admin_path("/_iceberg/v1/config"));
assert!(!is_admin_path("/bucket/object"));
assert!(!is_admin_path("/rustfs/administrator/object"));
assert!(!is_admin_path("/minio/administrator/object"));
assert!(!is_admin_path("/rustfs/adminx/object"));
assert!(!is_admin_path("/minio/adminx/object"));
}
#[test]
fn parse_replication_extension_request_matches_metrics_and_check() {
let metrics: Uri = "/demo-bucket?replication-metrics".parse().expect("uri should parse");
let metrics_v2: Uri = "/demo-bucket?replication-metrics=2".parse().expect("uri should parse");
let check: Uri = "/demo-bucket?replication-check".parse().expect("uri should parse");
let reset_status: Uri = "/demo-bucket?replication-reset-status".parse().expect("uri should parse");
let reset_start: Uri = "/demo-bucket?replication-reset".parse().expect("uri should parse");
let m = parse_replication_extension_request(&Method::GET, &metrics).expect("metrics route should parse");
assert_eq!(m.bucket, "demo-bucket");
assert_eq!(m.route, ReplicationExtRoute::MetricsV1);
let v2 = parse_replication_extension_request(&Method::GET, &metrics_v2).expect("metrics v2 route should parse");
assert_eq!(v2.bucket, "demo-bucket");
assert_eq!(v2.route, ReplicationExtRoute::MetricsV2);
let c = parse_replication_extension_request(&Method::GET, &check).expect("check route should parse");
assert_eq!(c.bucket, "demo-bucket");
assert_eq!(c.route, ReplicationExtRoute::Check);
let rs = parse_replication_extension_request(&Method::GET, &reset_status).expect("reset status route should parse");
assert_eq!(rs.bucket, "demo-bucket");
assert_eq!(rs.route, ReplicationExtRoute::ResetStatus);
let r = parse_replication_extension_request(&Method::PUT, &reset_start).expect("reset start route should parse");
assert_eq!(r.bucket, "demo-bucket");
assert_eq!(r.route, ReplicationExtRoute::ResetStart);
}
#[test]
fn parse_replication_extension_request_rejects_object_level_and_invalid_query_values() {
let object_level: Uri = "/demo-bucket/path/file?replication-metrics"
.parse()
.expect("uri should parse");
let bucket_trailing_slash: Uri = "/demo-bucket/?replication-metrics".parse().expect("uri should parse");
let invalid_value: Uri = "/demo-bucket?replication-metrics=1".parse().expect("uri should parse");
let wrong_method: Uri = "/demo-bucket?replication-check".parse().expect("uri should parse");
let wrong_method_reset: Uri = "/demo-bucket?replication-reset".parse().expect("uri should parse");
let wrong_method_status: Uri = "/demo-bucket?replication-reset-status".parse().expect("uri should parse");
assert!(parse_replication_extension_request(&Method::GET, &object_level).is_none());
assert!(parse_replication_extension_request(&Method::GET, &bucket_trailing_slash).is_none());
assert!(parse_replication_extension_request(&Method::GET, &invalid_value).is_none());
assert!(parse_replication_extension_request(&Method::PUT, &wrong_method).is_none());
assert!(parse_replication_extension_request(&Method::GET, &wrong_method_reset).is_none());
assert!(parse_replication_extension_request(&Method::PUT, &wrong_method_status).is_none());
}
#[test]
fn parse_reset_start_target_defaults_reset_before_and_supports_older_than() {
let no_window: Uri = "/demo-bucket?replication-reset".parse().expect("uri should parse");
let before_default = OffsetDateTime::now_utc();
let parsed_default = parse_reset_start_target(&no_window).expect("default reset request should parse");
let after_default = OffsetDateTime::now_utc();
assert!(parsed_default.arn.is_empty());
assert!(!parsed_default.reset_id.is_empty());
let reset_before = parsed_default.reset_before.expect("default reset window should be set");
assert!(reset_before >= before_default && reset_before <= after_default);
let older_than: Uri = "/demo-bucket?replication-reset&arn=arn:target&reset-id=rid-1&older-than=1h"
.parse()
.expect("uri should parse");
let before_window = OffsetDateTime::now_utc();
let parsed_window = parse_reset_start_target(&older_than).expect("older-than reset request should parse");
let after_window = OffsetDateTime::now_utc();
assert_eq!(parsed_window.reset_id, "rid-1");
let reset_before = parsed_window.reset_before.expect("older-than reset window should be set");
assert!(reset_before <= after_window - time::Duration::minutes(59));
assert!(reset_before >= before_window - time::Duration::hours(1) - time::Duration::seconds(1));
}
#[test]
fn resolve_replication_reset_target_arn_uses_single_existing_object_target_by_default() {
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:a".to_string(),
..Default::default()
},
existing_object_replication: Some(s3s::dto::ExistingObjectReplication {
status: s3s::dto::ExistingObjectReplicationStatus::from_static(
s3s::dto::ExistingObjectReplicationStatus::ENABLED,
),
}),
filter: None,
id: Some("rule-a".to_string()),
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
}],
};
let resolved = resolve_replication_reset_target_arn(&config, "").expect("single target should resolve");
assert_eq!(resolved, "arn:replication:a");
}
#[test]
fn resolve_replication_reset_target_arn_requires_arn_for_multiple_targets() {
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![
s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:a".to_string(),
..Default::default()
},
existing_object_replication: Some(s3s::dto::ExistingObjectReplication {
status: s3s::dto::ExistingObjectReplicationStatus::from_static(
s3s::dto::ExistingObjectReplicationStatus::ENABLED,
),
}),
filter: None,
id: Some("rule-a".to_string()),
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
},
s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:b".to_string(),
..Default::default()
},
existing_object_replication: Some(s3s::dto::ExistingObjectReplication {
status: s3s::dto::ExistingObjectReplicationStatus::from_static(
s3s::dto::ExistingObjectReplicationStatus::ENABLED,
),
}),
filter: None,
id: Some("rule-b".to_string()),
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
},
],
};
let err = resolve_replication_reset_target_arn(&config, "").expect_err("multiple targets should require arn");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.message().unwrap_or_default().contains("arn query parameter is required"));
}
#[test]
fn resolve_replication_reset_target_arn_rejects_target_without_existing_object_replication() {
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:a".to_string(),
..Default::default()
},
existing_object_replication: Some(s3s::dto::ExistingObjectReplication {
status: s3s::dto::ExistingObjectReplicationStatus::from_static(
s3s::dto::ExistingObjectReplicationStatus::DISABLED,
),
}),
filter: None,
id: Some("rule-a".to_string()),
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
}],
};
let err = resolve_replication_reset_target_arn(&config, "arn:replication:a")
.expect_err("target without existing object replication should fail");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.message().unwrap_or_default().contains("existing object replication"));
}
#[test]
fn apply_replication_reset_to_targets_updates_matching_target() {
let mut targets = BucketTargets {
targets: vec![crate::admin::storage_api::bucket::target::BucketTarget {
arn: "arn:target".to_string(),
..Default::default()
}],
};
let reset = ReplicationResetStartRequest {
arn: "arn:target".to_string(),
reset_id: "rid-1".to_string(),
reset_before: Some(OffsetDateTime::now_utc()),
};
apply_replication_reset_to_targets(&mut targets, &reset).expect("target update should succeed");
assert_eq!(targets.targets[0].reset_id, "rid-1");
assert_eq!(targets.targets[0].reset_before_date, reset.reset_before);
}
#[test]
fn build_replication_reset_status_response_serializes_sorted_targets() {
let mut status = BucketReplicationResyncStatus::new();
status.targets_map.insert(
"arn:z".to_string(),
crate::admin::storage_api::bucket::replication::TargetReplicationResyncStatus {
resync_id: "rid-z".to_string(),
last_update: Some(datetime!(2025-01-03 00:00 UTC)),
resync_status: crate::admin::storage_api::bucket::replication::ResyncStatusType::ResyncFailed,
failed_count: 2,
failed_size: 4,
bucket: "bucket-z".to_string(),
error: Some("boom".to_string()),
..Default::default()
},
);
status.targets_map.insert(
"arn:a".to_string(),
crate::admin::storage_api::bucket::replication::TargetReplicationResyncStatus {
resync_id: "rid-a".to_string(),
last_update: Some(datetime!(2025-01-02 00:00 UTC)),
resync_status: crate::admin::storage_api::bucket::replication::ResyncStatusType::ResyncCompleted,
replicated_count: 3,
replicated_size: 9,
bucket: "bucket-a".to_string(),
..Default::default()
},
);
let response = build_replication_reset_status_response(status, None).expect("status response should build");
let bytes = futures::executor::block_on(http_body_util::BodyExt::collect(response.output))
.expect("body should read")
.to_bytes();
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("response must be json");
assert_eq!(payload["Targets"][0]["Arn"], "arn:a");
assert_eq!(payload["Targets"][0]["Bucket"], "bucket-a");
assert_eq!(payload["Targets"][0]["Status"], "Completed");
assert_eq!(payload["Targets"][0]["EndTime"], "2025-01-02T00:00:00Z");
assert_eq!(payload["Targets"][1]["Arn"], "arn:z");
assert_eq!(payload["Targets"][1]["Bucket"], "bucket-z");
assert_eq!(payload["Targets"][1]["Status"], "Failed");
assert_eq!(payload["Targets"][1]["EndTime"], "2025-01-03T00:00:00Z");
assert_eq!(payload["Targets"][1]["Error"], "boom");
}
#[test]
fn build_replication_reset_status_response_filters_targets_by_arn() {
let mut status = BucketReplicationResyncStatus::new();
status.targets_map.insert(
"arn:z".to_string(),
crate::admin::storage_api::bucket::replication::TargetReplicationResyncStatus {
resync_id: "rid-z".to_string(),
last_update: Some(datetime!(2025-02-03 00:00 UTC)),
resync_status: crate::admin::storage_api::bucket::replication::ResyncStatusType::ResyncFailed,
failed_count: 2,
failed_size: 4,
bucket: "bucket-z".to_string(),
error: Some("boom".to_string()),
..Default::default()
},
);
status.targets_map.insert(
"arn:a".to_string(),
crate::admin::storage_api::bucket::replication::TargetReplicationResyncStatus {
resync_id: "rid-a".to_string(),
last_update: Some(datetime!(2025-02-02 00:00 UTC)),
resync_status: crate::admin::storage_api::bucket::replication::ResyncStatusType::ResyncCompleted,
replicated_count: 3,
replicated_size: 9,
bucket: "bucket-a".to_string(),
..Default::default()
},
);
let response = build_replication_reset_status_response(status, Some("arn:z")).expect("status response should build");
let bytes = futures::executor::block_on(http_body_util::BodyExt::collect(response.output))
.expect("body should read")
.to_bytes();
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("response must be json");
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
assert_eq!(payload["Targets"][0]["Arn"], "arn:z");
assert_eq!(payload["Targets"][0]["Bucket"], "bucket-z");
assert_eq!(payload["Targets"][0]["Status"], "Failed");
assert_eq!(payload["Targets"][0]["EndTime"], "2025-02-03T00:00:00Z");
assert_eq!(payload["Targets"][0]["Error"], "boom");
}
#[test]
fn build_replication_check_response_returns_empty_body_on_success() {
let response = build_replication_check_response(vec![
ReplicationCheckTargetStatus {
arn: "arn:a".to_string(),
endpoint: "remote-a:9000".to_string(),
bucket: "bucket-a".to_string(),
status: "OK".to_string(),
error: None,
},
ReplicationCheckTargetStatus {
arn: "arn:z".to_string(),
endpoint: "remote-z:9000".to_string(),
bucket: "bucket-z".to_string(),
status: "OK".to_string(),
error: None,
},
])
.expect("response should build");
let bytes = futures::executor::block_on(http_body_util::BodyExt::collect(response.output))
.expect("body should read")
.to_bytes();
assert!(bytes.is_empty());
}
#[test]
fn build_replication_check_response_surfaces_first_failure() {
let err = build_replication_check_response(vec![
ReplicationCheckTargetStatus {
arn: "arn:z".to_string(),
endpoint: "remote-z:9000".to_string(),
bucket: "bucket-z".to_string(),
status: "FAILED".to_string(),
error: Some("boom".to_string()),
},
ReplicationCheckTargetStatus {
arn: "arn:a".to_string(),
endpoint: "remote-a:9000".to_string(),
bucket: "bucket-a".to_string(),
status: "OK".to_string(),
error: None,
},
])
.expect_err("failed target should surface as request error");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.message().unwrap_or_default().contains("arn:z"));
}
#[test]
fn build_replication_check_response_rejects_empty_target_list_at_runtime_boundary() {
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![],
};
let replication_targets = filter_replication_check_targets(BucketTargets::default(), &config);
assert!(replication_targets.is_empty());
}
#[test]
fn format_replication_check_client_error_prefers_structured_access_denied() {
let err = S3ClientError::with_metadata(
"AccessDenied: denied",
None,
Some("AccessDenied".to_string()),
Some("denied".to_string()),
);
let formatted = format_replication_check_client_error(&err, ReplicationCheckFailureContext::BucketCheck);
assert_eq!(formatted, "target bucket check failed: access denied");
}
#[test]
fn format_replication_check_client_error_uses_remote_code_and_message() {
let err = S3ClientError::with_metadata(
"InvalidRequest: bucket versioning is suspended",
None,
Some("InvalidRequest".to_string()),
Some("bucket versioning is suspended".to_string()),
);
let formatted = format_replication_check_client_error(&err, ReplicationCheckFailureContext::VersioningCheck);
assert_eq!(
formatted,
"target bucket versioning check failed: InvalidRequest: bucket versioning is suspended"
);
}
#[test]
fn format_replication_check_client_error_maps_replicate_permission_failures() {
let err = S3ClientError::with_metadata(
"AccessDenied: denied",
None,
Some("AccessDenied".to_string()),
Some("denied".to_string()),
);
let replicate_object = format_replication_check_client_error(&err, ReplicationCheckFailureContext::ReplicateObject);
assert_eq!(replicate_object, "s3:ReplicateObject permissions missing for replication user");
let replicate_delete = format_replication_check_client_error(&err, ReplicationCheckFailureContext::ReplicateDeleteMarker);
assert_eq!(replicate_delete, "s3:ReplicateDelete permissions missing for replication user");
let delete_object = format_replication_check_client_error(&err, ReplicationCheckFailureContext::DeleteObjectVersion);
assert_eq!(
delete_object,
"s3:ReplicateDelete/s3:DeleteObject permissions missing for replication user"
);
}
#[test]
fn is_object_lock_not_enabled_error_recognizes_missing_configuration() {
let code_only = S3ClientError::with_metadata(
"ObjectLockConfigurationNotFoundError: missing",
None,
Some("ObjectLockConfigurationNotFoundError".to_string()),
Some("missing".to_string()),
);
assert!(is_object_lock_not_enabled_error(&code_only));
let message_only = S3ClientError::with_metadata(
"Object Lock is not enabled for this bucket",
None,
None,
Some("Object Lock is not enabled for this bucket".to_string()),
);
assert!(is_object_lock_not_enabled_error(&message_only));
let access_denied = S3ClientError::with_metadata(
"AccessDenied: denied",
None,
Some("AccessDenied".to_string()),
Some("denied".to_string()),
);
assert!(!is_object_lock_not_enabled_error(&access_denied));
}
#[test]
fn filter_replication_check_targets_only_keeps_configured_replication_targets() {
let targets = BucketTargets {
targets: vec![
BucketTarget {
arn: "arn:replication:a".to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
},
BucketTarget {
arn: "arn:replication:b".to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
},
BucketTarget {
arn: "arn:ilm:c".to_string(),
target_type: BucketTargetType::IlmService,
..Default::default()
},
],
};
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:b".to_string(),
..Default::default()
},
existing_object_replication: None,
filter: None,
id: None,
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
}],
};
let filtered = filter_replication_check_targets(targets, &config);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].arn, "arn:replication:b");
}
#[test]
fn validate_replication_check_config_targets_rejects_stale_enabled_rule_target() {
let targets = BucketTargets {
targets: vec![BucketTarget {
arn: "arn:replication:a".to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
}],
};
let config = s3s::dto::ReplicationConfiguration {
role: String::new(),
rules: vec![s3s::dto::ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: s3s::dto::Destination {
bucket: "arn:replication:missing".to_string(),
..Default::default()
},
existing_object_replication: None,
filter: None,
id: Some("rule-stale".to_string()),
prefix: Some(String::new()),
priority: None,
source_selection_criteria: None,
status: s3s::dto::ReplicationRuleStatus::from_static(s3s::dto::ReplicationRuleStatus::ENABLED),
}],
};
let err = validate_replication_check_config_targets(&targets, &config).expect_err("stale target should be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.message().unwrap_or_default().contains("rule-stale"));
}
#[test]
fn serialize_replication_metrics_body_v1_returns_replication_stats_only() {
let mut stats = BucketStats {
uptime: 99,
..Default::default()
};
stats.replication_stats.replica_count = 7;
stats.proxy_stats.put_total = 3;
let body =
serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV1).expect("metrics v1 body should serialize");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("body should be json");
assert_eq!(payload["replica_count"], 7);
assert!(payload.get("uptime").is_none());
assert!(payload.get("proxy_stats").is_none());
}
#[test]
fn apply_replication_metrics_bandwidth_report_updates_existing_target_stats() {
let mut stats = BucketStats::default();
stats
.replication_stats
.stats
.entry("arn:replication:a".to_string())
.or_default()
.replicated_count = 3;
let bandwidth_report = HashMap::from([(
"arn:replication:a".to_string(),
BandwidthDetails {
limit_bytes_per_sec: 2048,
current_bandwidth_bytes_per_sec: 1536.5,
},
)]);
let updated = apply_replication_metrics_bandwidth_report(stats, bandwidth_report);
let stat = updated
.replication_stats
.stats
.get("arn:replication:a")
.expect("target stats should exist");
assert_eq!(stat.replicated_count, 3);
assert_eq!(stat.bandwidth_limit_bytes_per_sec, 2048);
assert_eq!(stat.current_bandwidth_bytes_per_sec, 1536.5);
}
#[test]
fn apply_replication_metrics_bandwidth_report_creates_missing_target_stats() {
let bandwidth_report = HashMap::from([(
"arn:replication:b".to_string(),
BandwidthDetails {
limit_bytes_per_sec: 4096,
current_bandwidth_bytes_per_sec: 1024.25,
},
)]);
let updated = apply_replication_metrics_bandwidth_report(BucketStats::default(), bandwidth_report);
let stat = updated
.replication_stats
.stats
.get("arn:replication:b")
.expect("target stats should be created from bandwidth report");
assert_eq!(stat.bandwidth_limit_bytes_per_sec, 4096);
assert_eq!(stat.current_bandwidth_bytes_per_sec, 1024.25);
}
#[test]
fn serialize_replication_metrics_body_v2_returns_full_bucket_stats() {
let mut stats = BucketStats {
uptime: 99,
..Default::default()
};
stats.replication_stats.replica_count = 7;
stats.proxy_stats.put_total = 3;
let body =
serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV2).expect("metrics v2 body should serialize");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("body should be json");
assert_eq!(payload["uptime"], 99);
assert_eq!(payload["replication_stats"]["replica_count"], 7);
assert_eq!(payload["proxy_stats"]["put_total"], 3);
}
#[test]
fn apply_replication_metrics_runtime_fields_only_overrides_v2_uptime() {
let stats = BucketStats {
uptime: 99,
..Default::default()
};
let v1 = apply_replication_metrics_runtime_fields(stats.clone(), ReplicationExtRoute::MetricsV1, 42);
let v2 = apply_replication_metrics_runtime_fields(stats, ReplicationExtRoute::MetricsV2, 42);
assert_eq!(v1.uptime, 99);
assert_eq!(v2.uptime, 42);
}
#[test]
fn build_replication_probe_put_options_sets_replication_flags() {
let now = OffsetDateTime::from_unix_timestamp(42).expect("timestamp should build");
let options = build_replication_probe_put_options(now);
assert_eq!(options.internal.replication_status, ReplicationStatusType::Replica);
assert!(options.internal.replication_request);
assert!(options.internal.replication_validity_check);
assert_eq!(options.internal.source_mtime, now);
assert!(!options.internal.source_version_id.is_empty());
}
#[test]
fn build_replication_probe_remove_options_sets_replication_flags() {
let now = OffsetDateTime::from_unix_timestamp(42).expect("timestamp should build");
let options = build_replication_probe_remove_options(now, true);
assert!(options.replication_delete_marker);
assert_eq!(options.replication_status, ReplicationStatusType::Replica);
assert!(options.replication_request);
assert!(options.replication_validity_check);
assert_eq!(options.replication_mtime, Some(now));
}
#[test]
fn parse_misc_extension_request_matches_object_lambda_and_listen_notification() {
let object_lambda: Uri = "/demo-bucket/path/to/object.txt?lambdaArn=arn%3Atarget"
.parse()
.expect("uri should parse");
let listen_bucket: Uri = "/demo-bucket?events=s3:ObjectCreated:*".parse().expect("uri should parse");
let listen_bucket_trailing_slash: Uri = "/demo-bucket/?events=s3:ObjectCreated:*".parse().expect("uri should parse");
let listen_root: Uri = "/?events=s3:ObjectRemoved:*".parse().expect("uri should parse");
let object_route = parse_misc_extension_request(&Method::GET, &object_lambda).expect("object lambda route should parse");
assert_eq!(
object_route,
MiscExtRoute::ObjectLambda {
bucket: "demo-bucket".to_string(),
object: "path/to/object.txt".to_string()
}
);
let listen_bucket_route =
parse_misc_extension_request(&Method::GET, &listen_bucket).expect("bucket listen route should parse");
assert_eq!(
listen_bucket_route,
MiscExtRoute::ListenNotification {
bucket: Some("demo-bucket".to_string())
}
);
let listen_bucket_trailing_slash_route = parse_misc_extension_request(&Method::GET, &listen_bucket_trailing_slash)
.expect("bucket listen route with trailing slash should parse");
assert_eq!(
listen_bucket_trailing_slash_route,
MiscExtRoute::ListenNotification {
bucket: Some("demo-bucket".to_string())
}
);
let listen_root_route = parse_misc_extension_request(&Method::GET, &listen_root).expect("root listen route should parse");
assert_eq!(listen_root_route, MiscExtRoute::ListenNotification { bucket: None });
}
#[test]
fn parse_misc_extension_request_rejects_invalid_paths_or_methods() {
let bucket_without_object: Uri = "/demo-bucket?lambdaArn=arn%3Atarget".parse().expect("uri should parse");
let wrong_method_lambda: Uri = "/demo-bucket/object?lambdaArn=arn%3Atarget"
.parse()
.expect("uri should parse");
let object_level_listen: Uri = "/demo-bucket/object?events=s3:ObjectCreated:*"
.parse()
.expect("uri should parse");
assert!(parse_misc_extension_request(&Method::GET, &bucket_without_object).is_none());
assert!(parse_misc_extension_request(&Method::PUT, &wrong_method_lambda).is_none());
assert!(parse_misc_extension_request(&Method::GET, &object_level_listen).is_none());
}
#[test]
fn validate_listen_notification_query_accepts_valid_values() {
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:*&prefix=logs/&suffix=.json&ping=3"
.parse()
.expect("uri should parse");
assert!(validate_listen_notification_query(&uri).is_ok());
}
#[test]
fn validate_listen_notification_query_rejects_invalid_event_or_duplicate_filters() {
let invalid_event: Uri = "/demo-bucket?events=invalid-event".parse().expect("uri should parse");
let duplicate_prefix: Uri = "/demo-bucket?events=s3:ObjectCreated:*&prefix=a&prefix=b"
.parse()
.expect("uri should parse");
let invalid_ping: Uri = "/demo-bucket?events=s3:ObjectCreated:*&ping=0"
.parse()
.expect("uri should parse");
assert_eq!(
validate_listen_notification_query(&invalid_event)
.expect_err("invalid event should fail")
.code(),
&S3ErrorCode::InvalidArgument
);
assert_eq!(
validate_listen_notification_query(&duplicate_prefix)
.expect_err("duplicate prefix should fail")
.code(),
&S3ErrorCode::InvalidArgument
);
assert_eq!(
validate_listen_notification_query(&invalid_ping)
.expect_err("invalid ping should fail")
.code(),
&S3ErrorCode::InvalidArgument
);
}
#[test]
fn validate_object_lambda_query_rejects_missing_empty_or_invalid_arn() {
let missing: Uri = "/demo-bucket/object.txt".parse().expect("uri should parse");
let empty: Uri = "/demo-bucket/object.txt?lambdaArn=".parse().expect("uri should parse");
let duplicated: Uri = "/demo-bucket/object.txt?lambdaArn=a&lambdaArn=b"
.parse()
.expect("uri should parse");
let invalid_format: Uri = "/demo-bucket/object.txt?lambdaArn=not-an-arn"
.parse()
.expect("uri should parse");
assert_eq!(
validate_object_lambda_query(&missing)
.expect_err("missing lambdaArn should fail")
.code(),
&S3ErrorCode::InvalidRequest
);
assert_eq!(
validate_object_lambda_query(&empty)
.expect_err("empty lambdaArn should fail")
.code(),
&S3ErrorCode::InvalidRequest
);
assert_eq!(
validate_object_lambda_query(&duplicated)
.expect_err("duplicated lambdaArn should fail")
.code(),
&S3ErrorCode::InvalidRequest
);
assert_eq!(
validate_object_lambda_query(&invalid_format)
.expect_err("invalid lambdaArn should fail")
.code(),
&S3ErrorCode::InvalidRequest
);
}
#[test]
fn validate_object_lambda_query_accepts_arn() {
let valid: Uri = "/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook"
.parse()
.expect("uri should parse");
assert!(validate_object_lambda_query(&valid).is_ok());
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_accepts_enabled_webhook_target() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/transform".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_AUTH_TOKEN.to_string(),
value: "secret-token".to_string(),
hidden_if_empty: true,
},
]),
)]),
)]));
let resolved = resolve_object_lambda_webhook_config_from_server_config(&config, &arn).expect("config should resolve");
assert_eq!(resolved.endpoint.as_str(), "https://example.com/transform");
assert_eq!(resolved.auth_token, "secret-token");
assert!(!resolved.skip_tls_verify);
assert!(resolved.response_header_timeout.is_none());
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_accepts_named_webhook_target() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook-csv"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/transform-csv".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let resolved = resolve_object_lambda_webhook_config_from_server_config(&config, &arn).expect("config should resolve");
assert_eq!(resolved.endpoint.as_str(), "https://example.com/transform-csv");
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_parses_response_header_timeout() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/transform".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_RESPONSE_HEADER_TIMEOUT.to_string(),
value: "2s".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let resolved = resolve_object_lambda_webhook_config_from_server_config(&config, &arn).expect("config should resolve");
assert_eq!(resolved.response_header_timeout, Some(Duration::from_secs(2)));
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_accepts_notify_webhook_fallback() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
NOTIFY_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/notify-transform".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let resolved = resolve_object_lambda_webhook_config_from_server_config(&config, &arn).expect("config should resolve");
assert_eq!(resolved.endpoint.as_str(), "https://example.com/notify-transform");
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_rejects_invalid_response_header_timeout() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/transform".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_RESPONSE_HEADER_TIMEOUT.to_string(),
value: "definitely-not-a-duration".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let err =
resolve_object_lambda_webhook_config_from_server_config(&config, &arn).expect_err("invalid timeout should fail");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_rejects_unsupported_or_disabled_targets() {
let unsupported = "arn:acme:s3-object-lambda::transformer:mqtt"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let empty_config = rustfs_config::server_config::Config(std::collections::HashMap::new());
let unsupported_err = resolve_object_lambda_webhook_config_from_server_config(&empty_config, &unsupported)
.expect_err("unsupported target type should fail");
assert_eq!(unsupported_err.code(), &S3ErrorCode::NotImplemented);
let webhook = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let disabled_config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "off".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://example.com/transform".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let disabled_err = resolve_object_lambda_webhook_config_from_server_config(&disabled_config, &webhook)
.expect_err("disabled target should fail");
assert_eq!(disabled_err.code(), &S3ErrorCode::InvalidRequest);
}
#[test]
fn resolve_object_lambda_webhook_config_from_server_config_rejects_loopback_endpoint() {
let arn = "arn:acme:s3-object-lambda::transformer:webhook"
.parse::<rustfs_targets::arn::ARN>()
.expect("arn should parse");
let config = rustfs_config::server_config::Config(std::collections::HashMap::from([(
LAMBDA_WEBHOOK_SUB_SYS.to_string(),
std::collections::HashMap::from([(
"transformer".to_string(),
rustfs_config::server_config::KVS(vec![
rustfs_config::server_config::KV {
key: ENABLE_KEY.to_string(),
value: "on".to_string(),
hidden_if_empty: false,
},
rustfs_config::server_config::KV {
key: WEBHOOK_ENDPOINT.to_string(),
value: "https://127.0.0.1/transform".to_string(),
hidden_if_empty: false,
},
]),
)]),
)]));
let err = resolve_object_lambda_webhook_config_from_server_config(&config, &arn)
.expect_err("loopback endpoint should be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.message().unwrap_or_default().contains("not allowed"));
}
#[test]
fn clear_object_lambda_variant_headers_removes_original_object_payload_headers() {
let mut headers = HeaderMap::new();
headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("7"));
headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
headers.insert("x-amz-meta-demo", HeaderValue::from_static("value"));
headers.insert("x-amz-version-id", HeaderValue::from_static("v1"));
clear_object_lambda_variant_headers(&mut headers);
assert!(headers.get(http::header::CONTENT_LENGTH).is_none());
assert!(headers.get(http::header::CONTENT_TYPE).is_none());
assert!(headers.get("x-amz-meta-demo").is_none());
assert_eq!(headers.get("x-amz-version-id").and_then(|value| value.to_str().ok()), Some("v1"));
}
#[test]
fn build_object_lambda_passthrough_response_preserves_target_status_and_filters_headers() {
let mut upstream_headers = HeaderMap::new();
upstream_headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("7"));
upstream_headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
upstream_headers.insert("x-amz-meta-demo", HeaderValue::from_static("value"));
upstream_headers.insert("x-amz-version-id", HeaderValue::from_static("v1"));
let mut lambda_headers = HeaderMap::new();
lambda_headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
lambda_headers.insert("x-rustfs-lambda-error", HeaderValue::from_static("upstream"));
lambda_headers.insert(http::header::CONNECTION, HeaderValue::from_static("keep-alive"));
lambda_headers.insert("x-amz-request-route", HeaderValue::from_static("route-token"));
lambda_headers.insert("x-amz-request-token", HeaderValue::from_static("request-token"));
let response = build_object_lambda_passthrough_response(
upstream_headers,
&lambda_headers,
StatusCode::BAD_GATEWAY,
Body::from("lambda failed".to_string()),
);
assert_eq!(response.status, Some(StatusCode::BAD_GATEWAY));
assert_eq!(
response
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(
response
.headers
.get("x-rustfs-lambda-error")
.and_then(|value| value.to_str().ok()),
Some("upstream")
);
assert!(response.headers.get(http::header::CONTENT_LENGTH).is_none());
assert!(response.headers.get("x-amz-meta-demo").is_none());
assert!(response.headers.get(http::header::CONNECTION).is_none());
assert!(response.headers.get("x-amz-request-route").is_none());
assert!(response.headers.get("x-amz-request-token").is_none());
assert_eq!(response.headers.get("x-amz-version-id").and_then(|value| value.to_str().ok()), Some("v1"));
}
#[test]
fn build_object_lambda_source_url_presigns_request_without_lambda_arn() {
let req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook&versionId=v1"
.parse()
.expect("uri should parse"),
headers: HeaderMap::from_iter([(http::header::HOST, HeaderValue::from_static("localhost:9000"))]),
extensions: http::Extensions::new(),
credentials: Some(s3s::auth::Credentials {
access_key: "rustfsadmin".to_string(),
secret_key: s3s::auth::SecretKey::from("rustfssecret"),
}),
region: current_region(),
service: None,
trailing_headers: None,
};
let source_url = build_object_lambda_source_url(&req).expect("source url should build");
let source_url = Url::parse(&source_url).expect("source url should parse");
let query_pairs = source_url.query_pairs().collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(source_url.scheme(), "http");
assert_eq!(source_url.host_str(), Some("localhost"));
assert_eq!(source_url.port_or_known_default(), Some(9000));
assert_eq!(source_url.path(), "/demo-bucket/object.txt");
assert_eq!(query_pairs.get("versionId").map(|value| value.as_ref()), Some("v1"));
assert!(!query_pairs.contains_key("lambdaArn"));
let expires = query_pairs.get("X-Amz-Expires").and_then(|value| value.parse::<u64>().ok());
assert_eq!(expires, Some(3600));
assert_eq!(query_pairs.get("X-Amz-Algorithm").map(|value| value.as_ref()), Some("AWS4-HMAC-SHA256"));
assert!(query_pairs.contains_key("X-Amz-Signature"));
}
#[test]
fn build_object_lambda_event_payload_contains_required_context() {
let req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook"
.parse()
.expect("uri should parse"),
headers: HeaderMap::from_iter([(http::header::HOST, HeaderValue::from_static("localhost:9000"))]),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let payload = build_object_lambda_event_payload(
&req,
"arn:acme:s3-object-lambda::transformer:webhook",
"https://example.com/source",
"route-123",
"token-456",
)
.expect("payload should serialize");
let payload: serde_json::Value = serde_json::from_slice(&payload).expect("payload should be json");
assert_eq!(payload["getObjectContext"]["inputS3Url"], "https://example.com/source");
assert_eq!(payload["getObjectContext"]["outputRoute"], "route-123");
assert_eq!(payload["getObjectContext"]["outputToken"], "token-456");
assert_eq!(
payload["configuration"]["accessPointArn"],
"arn:acme:s3-object-lambda::transformer:webhook"
);
assert_eq!(
payload["userRequest"]["url"],
"/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook"
);
}
#[test]
fn validate_object_lambda_response_auth_headers_rejects_missing_or_mismatched_values() {
let mut matching = HeaderMap::new();
matching.insert("x-amz-request-route", HeaderValue::from_static("route-123"));
matching.insert("x-amz-request-token", HeaderValue::from_static("token-456"));
assert!(validate_object_lambda_response_auth_headers(&matching, "route-123", "token-456").is_ok());
let missing = HeaderMap::new();
let err = validate_object_lambda_response_auth_headers(&missing, "route-123", "token-456")
.expect_err("missing auth headers should fail");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
let mut mismatched = HeaderMap::new();
mismatched.insert("x-amz-request-route", HeaderValue::from_static("route-123"));
mismatched.insert("x-amz-request-token", HeaderValue::from_static("wrong-token"));
let err = validate_object_lambda_response_auth_headers(&mismatched, "route-123", "token-456")
.expect_err("mismatched auth headers should fail");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
}
#[test]
fn build_object_lambda_get_request_removes_lambda_arn_and_preserves_request_inputs() {
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook&versionId=v1&partNumber=7"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.headers
.insert(http::header::RANGE, HeaderValue::from_static("bytes=5-10"));
req.headers
.insert(http::header::IF_MATCH, HeaderValue::from_static("\"abc\""));
let bridged = build_object_lambda_get_request(&req, "demo-bucket", "object.txt").expect("bridge request should build");
assert_eq!(bridged.uri.path(), "/demo-bucket/object.txt");
assert_eq!(bridged.uri.query(), Some("versionId=v1&partNumber=7"));
assert_eq!(bridged.input.bucket, "demo-bucket");
assert_eq!(bridged.input.key, "object.txt");
assert_eq!(bridged.input.version_id.as_deref(), Some("v1"));
assert_eq!(bridged.input.part_number, Some(7));
assert_eq!(
bridged.input.range,
Some(Range::Int {
first: 5,
last: Some(10)
})
);
assert!(bridged.input.if_match.is_some());
}
#[test]
fn build_object_lambda_get_request_rejects_invalid_range_header() {
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket/object.txt?lambdaArn=arn%3Aacme%3As3-object-lambda%3A%3Atransformer%3Awebhook"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.headers
.insert(http::header::RANGE, HeaderValue::from_static("bytes=10-5"));
let err = build_object_lambda_get_request(&req, "demo-bucket", "object.txt").expect_err("invalid range must fail");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn convert_get_object_response_maps_core_headers() {
let mut resp = S3Response::new(GetObjectOutput {
body: Some(Body::from("payload".to_string()).into()),
content_length: Some(7),
content_type: Some("text/plain".to_string()),
accept_ranges: Some("bytes".to_string()),
version_id: Some("v1".to_string()),
metadata: Some(std::collections::HashMap::from([("custom-key".to_string(), "custom-value".to_string())])),
..Default::default()
});
resp.status = Some(StatusCode::OK);
let converted = convert_get_object_response(resp).expect("response conversion should succeed");
assert_eq!(converted.status, Some(StatusCode::OK));
assert_eq!(
converted
.headers
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()),
Some("7")
);
assert_eq!(
converted
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/plain")
);
assert_eq!(
converted
.headers
.get(http::header::ACCEPT_RANGES)
.and_then(|value| value.to_str().ok()),
Some("bytes")
);
assert_eq!(
converted
.headers
.get("x-amz-version-id")
.and_then(|value| value.to_str().ok()),
Some("v1")
);
assert_eq!(
converted
.headers
.get("x-amz-meta-custom-key")
.and_then(|value| value.to_str().ok()),
Some("custom-value")
);
}
#[tokio::test]
async fn check_access_rejects_anonymous_replication_extension_request() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket?replication-metrics".parse().expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let err = router
.check_access(&mut req)
.await
.expect_err("anonymous extension request must be denied");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
}
#[tokio::test]
async fn check_access_rejects_anonymous_misc_extension_request() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/demo-bucket/path/object.txt?lambdaArn=arn%3Atarget"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let err = router
.check_access(&mut req)
.await
.expect_err("anonymous extension request must be denied");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
}
#[tokio::test]
async fn check_access_rejects_anonymous_profile_request() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: PROFILE_CPU_PATH.parse().expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let err = router
.check_access(&mut req)
.await
.expect_err("anonymous profile request must be denied");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
}
#[tokio::test]
async fn check_access_allows_object_zip_download_token_navigation() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/rustfs/admin/v3/object-zip-downloads/example.zip?token=abc"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
router
.check_access(&mut req)
.await
.expect("token download navigation should reach the handler");
}
#[tokio::test]
async fn check_access_rejects_object_zip_download_without_token() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::GET,
uri: "/rustfs/admin/v3/object-zip-downloads/example.zip"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let err = router
.check_access(&mut req)
.await
.expect_err("token download without token must be denied before handler");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
}
#[tokio::test]
async fn check_access_rejects_anonymous_object_zip_download_post() {
let router: S3Router<AdminOperation> = S3Router::new(false);
let mut req = S3Request {
input: Body::from(String::new()),
method: Method::POST,
uri: "/rustfs/admin/v3/object-zip-downloads?token=abc"
.parse()
.expect("uri should parse"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let err = router
.check_access(&mut req)
.await
.expect_err("token exception must not apply to POST");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
}
#[test]
fn listen_notification_keepalive_plan_defaults_to_space_keepalive() {
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:Put".parse().expect("uri should parse");
let (interval, payload) = listen_notification_keepalive_plan(&uri);
assert_eq!(interval, Duration::from_millis(500));
assert_eq!(payload, Bytes::from_static(b" "));
}
#[test]
fn listen_notification_keepalive_plan_uses_empty_record_payload_when_ping_is_present() {
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:Put&ping=3"
.parse()
.expect("uri should parse");
let (interval, payload) = listen_notification_keepalive_plan(&uri);
assert_eq!(interval, Duration::from_secs(3));
assert_eq!(payload, Bytes::from_static(b"{\"Records\":[]}\n"));
}
#[test]
fn parse_listen_notification_filter_expands_event_mask_and_filters() {
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:*&events=s3:ObjectRemoved:Delete&prefix=logs/&suffix=.json"
.parse()
.expect("uri should parse");
let filter = parse_listen_notification_filter(&uri, Some("demo-bucket")).expect("filter should parse");
assert_eq!(filter.bucket.as_deref(), Some("demo-bucket"));
assert_eq!(filter.prefix.as_deref(), Some("logs/"));
assert_eq!(filter.suffix.as_deref(), Some(".json"));
assert_ne!(filter.event_mask & EventName::ObjectCreatedPut.mask(), 0);
assert_ne!(filter.event_mask & EventName::ObjectRemovedDelete.mask(), 0);
assert_eq!(filter.event_mask & EventName::ObjectAccessedGet.mask(), 0);
}
#[test]
fn event_matches_listen_notification_respects_bucket_event_and_object_filters() {
let filter = ListenNotificationFilter {
bucket: Some("demo-bucket".to_string()),
event_mask: rustfs_s3_ops::put_object_created_event_mask(),
prefix: Some("logs/".to_string()),
suffix: Some(".json".to_string()),
};
let matched = NotificationEvent::new_test_event("demo-bucket", "logs/app.json", EventName::ObjectCreatedPut);
assert!(event_matches_listen_notification(&matched, &filter));
let wrong_bucket = NotificationEvent::new_test_event("other-bucket", "logs/app.json", EventName::ObjectCreatedPut);
assert!(!event_matches_listen_notification(&wrong_bucket, &filter));
let wrong_event = NotificationEvent::new_test_event("demo-bucket", "logs/app.json", EventName::ObjectRemovedDelete);
assert!(!event_matches_listen_notification(&wrong_event, &filter));
let wrong_prefix = NotificationEvent::new_test_event("demo-bucket", "archive/app.json", EventName::ObjectCreatedPut);
assert!(!event_matches_listen_notification(&wrong_prefix, &filter));
let wrong_suffix = NotificationEvent::new_test_event("demo-bucket", "logs/app.txt", EventName::ObjectCreatedPut);
assert!(!event_matches_listen_notification(&wrong_suffix, &filter));
}
#[test]
fn event_matches_listen_notification_decodes_object_key_before_filtering() {
let filter = ListenNotificationFilter {
bucket: Some("demo-bucket".to_string()),
event_mask: EventName::ObjectCreatedPut.mask(),
prefix: Some("logs/".to_string()),
suffix: Some(".json".to_string()),
};
let encoded = NotificationEvent::new_test_event("demo-bucket", "logs%2Fapp.json", EventName::ObjectCreatedPut);
assert!(event_matches_listen_notification(&encoded, &filter));
}
#[test]
fn serialize_listen_notification_event_wraps_records_payload() {
let event = NotificationEvent::new_test_event("demo-bucket", "logs/app.json", EventName::ObjectCreatedPut);
let payload = serialize_listen_notification_event(&event).expect("payload should serialize");
let body = std::str::from_utf8(payload.as_ref()).expect("payload should be utf-8");
assert!(body.contains("\"Records\":["));
assert!(body.contains("\"name\":\"demo-bucket\""));
assert!(body.contains("\"eventName\":\"ObjectCreatedPut\"") || body.contains("s3:ObjectCreated:Put"));
assert!(body.ends_with('\n'));
}
#[tokio::test]
async fn build_listen_notification_response_sets_event_stream_headers() {
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:Put&ping=1"
.parse()
.expect("uri should parse");
let resp = build_listen_notification_response(&uri, Some("demo-bucket")).expect("response should build");
assert_eq!(
resp.headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()),
Some("text/event-stream")
);
assert_eq!(resp.headers.get(header::CACHE_CONTROL).and_then(|v| v.to_str().ok()), Some("no-cache"));
assert_eq!(resp.headers.get("x-accel-buffering").and_then(|v| v.to_str().ok()), Some("no"));
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct Extra {
pub credentials: Option<s3s::auth::Credentials>,
pub region: Option<s3s::region::Region>,
pub service: Option<String>,
}
fn convert_request(req: S3Request<Body>) -> http::Request<Body> {
let (mut parts, _) = http::Request::new(Body::empty()).into_parts();
parts.method = req.method;
parts.uri = req.uri;
parts.headers = req.headers;
parts.extensions = req.extensions;
parts.extensions.insert(Extra {
credentials: req.credentials,
region: req.region,
service: req.service,
});
http::Request::from_parts(parts, req.input)
}
fn convert_response(resp: http::Response<axum::body::Body>) -> S3Response<Body> {
let (parts, body) = resp.into_parts();
let mut s3_resp = S3Response::new(Body::http_body_unsync(body));
s3_resp.status = Some(parts.status);
s3_resp.headers = parts.headers;
s3_resp.extensions = parts.extensions;
s3_resp
}